ShaderPass 基础:从原理到第一个自定义滤镜

大家好,我是你们这门课的老朋友。今天咱们来聊聊 Three.js 后处理里最核心的一个概念——ShaderPass

说实话,我刚接触 Three.js 后处理那会儿,也被 EffectComposer、RenderPass、ShaderPass 这一堆概念搞得有点晕。但后来我发现,只要你搞懂了 ShaderPass,后处理的大门就算被你踹开了。

这一章,我会带你从原理到实战,手写第一个自定义 ShaderPass。嗯,咱们不搞虚的,直接上代码。

ShaderPass 到底是什么?

说白了,ShaderPass 就是一个“滤镜容器”。它接收上一帧渲染好的画面,然后通过你写的着色器(Shader)对每个像素做处理,最后输出新的画面。

你想想看,我们平时用 Photoshop 加滤镜,是不是先有一张原图,然后点一下“模糊”或者“锐化”,图片就变了?ShaderPass 干的就是同样的事——只不过它是在 GPU 上实时处理,每秒 60 帧。

我个人习惯把 ShaderPass 理解成一个“像素加工厂”

  • 输入:上一帧渲染好的纹理(比如场景截图)
  • 加工:你写的着色器代码(决定怎么处理每个像素)
  • 输出:处理后的新纹理

核心要点:ShaderPass 本质上是一个特殊的 Mesh,它渲染一个全屏四边形,把纹理采样结果经过着色器处理后再输出。你不需要手动创建几何体,Three.js 已经帮你封装好了。

ShaderPass 的工作原理

咱们稍微深入一点。ShaderPass 内部其实做了这几件事:

  1. 创建全屏四边形:一个覆盖整个视口的平面,只有两个三角形。
  2. 绑定输入纹理:把上一帧的结果作为纹理传入。
  3. 执行着色器:对每个像素运行你的片元着色器。
  4. 输出到帧缓冲:结果写入新的纹理,供下一个 Pass 使用。

这里有个关键点——顶点着色器通常是固定的。为什么?因为全屏四边形不需要任何变换,你只需要传递位置和 UV 坐标就行。我刚开始学的时候,总想着在顶点着色器里搞花样,结果发现根本没必要。你真正需要花心思的,是片元着色器

小技巧:Three.js 内置了一个 CopyShader,它什么都不做,就是把输入纹理原样输出。你可以把它当成 ShaderPass 的“Hello World”。

编写第一个自定义 ShaderPass

好,理论说够了,咱们动手。我会带你写一个最简单的反色滤镜(Invert Colors)。

先看完整代码,我再逐行解释:

// 1. 定义着色器
const InvertShader = {
  uniforms: {
    tDiffuse: { value: null }  // 输入纹理,名称固定
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    varying vec2 vUv;

    void main() {
      vec4 color = texture2D(tDiffuse, vUv);
      gl_FragColor = vec4(1.0 - color.rgb, color.a);
    }
  `
};

// 2. 创建 ShaderPass
const invertPass = new THREE.ShaderPass(InvertShader);

// 3. 添加到 EffectComposer
composer.addPass(invertPass);

代码不长,但每个部分都有讲究。咱们拆开来看。

uniforms 传递:tDiffuse 的秘密

你注意到 tDiffuse 这个变量名了吗?这是 ShaderPass 的约定。Three.js 会自动把上一帧的渲染结果绑定到这个 uniform 上。你不需要手动赋值,框架帮你搞定。

我在项目中遇到过一个问题:有次我自定义了一个 uniform 叫 inputTexture,结果画面一直是黑的。查了半天才发现,ShaderPass 只认 tDiffuse 这个名称。嗯,这个坑我替你们踩过了。

如果你想传递自己的数据,比如时间、鼠标位置,可以这样:

const MyShader = {
  uniforms: {
    tDiffuse: { value: null },
    uTime: { value: 0 },
    uIntensity: { value: 1.0 }
  },
  // ... 着色器代码
};

// 更新 uniform
myPass.uniforms.uTime.value = performance.now() / 1000;

注意:所有自定义 uniform 都需要在创建 Pass 之后手动更新。Three.js 只会自动处理 tDiffuse

顶点着色器:为什么几乎不用改?

你看上面的顶点着色器,其实就是标准的 Three.js 模板。它做了两件事:

  • 把 UV 坐标传递给片元着色器(vUv = uv
  • 计算顶点位置(gl_Position

我建议你直接复制粘贴这个模板,除非你有特殊需求(比如做扭曲效果时需要修改顶点位置)。99% 的情况下,你只需要改片元着色器。

片元着色器:真正干活的地方

反色的逻辑很简单:1.0 - color.rgb。每个颜色通道取反,红色变青色,绿色变品红,蓝色变黄色。

你可以在这里做任何像素级的操作:

  • 灰度化:float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
  • 亮度调整:color.rgb *= uBrightness;
  • 颜色偏移:color.r = texture2D(tDiffuse, vUv + vec2(0.01, 0.0)).r;

避坑指南:我曾经在片元着色器里直接修改 gl_FragColor 的 alpha 值,结果发现透明区域出现了奇怪的边缘。后来才意识到,后处理管线里 alpha 通道的处理需要格外小心,尤其是涉及混合模式的时候。

完整示例:灰度滤镜

咱们再写一个灰度滤镜,巩固一下:

const GrayscaleShader = {
  uniforms: {
    tDiffuse: { value: null }
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    varying vec2 vUv;

    void main() {
      vec4 color = texture2D(tDiffuse, vUv);
      float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
      gl_FragColor = vec4(vec3(gray), color.a);
    }
  `
};

const grayscalePass = new THREE.ShaderPass(GrayscaleShader);
composer.addPass(grayscalePass);

你看,结构完全一样,只是片元着色器的逻辑变了。这就是 ShaderPass 的威力——你只需要关注像素处理逻辑,其他事情框架帮你搞定

ShaderPass 与 EffectComposer 的配合

最后提一嘴 ShaderPass 怎么接入管线。你需要在初始化时创建 EffectComposer,然后按顺序添加 Pass:

const composer = new THREE.EffectComposer(renderer);
const renderPass = new THREE.RenderPass(scene, camera);
composer.addPass(renderPass);

const myPass = new THREE.ShaderPass(MyShader);
composer.addPass(myPass);

// 在动画循环中
composer.render();

顺序很重要。第一个 Pass 通常是 RenderPass,它负责渲染场景。后面的 ShaderPass 依次处理上一 Pass 的输出。

总结一下:ShaderPass = 固定的顶点着色器 + 自定义的片元着色器 + 自动绑定的 tDiffuse uniform。你只需要写好片元着色器,就能实现各种炫酷的滤镜效果。

下一章,咱们会深入 AfterimagePassGlitchPass 这些内置特效,看看 Three.js 官方是怎么写复杂 ShaderPass 的。到时候你会发现,理解了基础,复杂的东西也不过是堆叠和组合而已。

好,今天就到这儿。动手试试反色和灰度滤镜,有任何问题咱们评论区见。