阅读视图

发现新文章,点击刷新页面。

【Three.js 后期处理进阶】用 Shader 实现自己的滤镜,让画面拥有电影质感

前言

调色预设用腻了?那就自己写一个滤镜。不就是给画面加特效吗,GPU 说它很乐意帮忙。

上一篇文章我们聊了电影级调色,用现成的 Bloom 和 LUT 把画面整得挺像回事。但有读者留言:“这些预设是挺好,但我想要那种赛博朋克的色彩偏移效果,或者复古胶片的感觉,LUT 找不到合适的。”

这确实是个问题。LUT 再丰富也有限,真正的自定义还得靠 Shader。

其实 Three.js 的后期处理流水线本来就是用 Shader 搭起来的。BloomPassFilmPass 这些内置效果,源码里都是一堆 GLSL 代码。既然官方能写,我们也能写。

今天我就带你写三个自定义后期滤镜:一个简单的灰度,一个边缘发光,一个炫酷的色彩偏移(RGB Split)。全部手写 Shader,跑起来的那一刻,你会发现原来自己也能造轮子。


一、后期处理流水线回顾

Three.js 的后期处理核心是 EffectComposer。它就像一条传送带,上面挂着一个个 Pass,每个 Pass 可以对画面做一次加工。

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

const customPass = new ShaderPass({
  uniforms: {},
  vertexShader: '',
  fragmentShader: ''
});
composer.addPass(customPass);

ShaderPass 需要两个关键东西:顶点着色器和片元着色器。顶点着色器几乎不用动,片元着色器里写的就是像素级别的滤镜逻辑。


二、第一个滤镜:灰度

先从最简单的开始。把画面变成黑白的,感受一下 ShaderPass 的工作流程。

import * as THREE from 'three';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';

// 基础场景(随便放几个物体)
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(3, 2, 5);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 添加一些好看的物体
const geometry = new THREE.SphereGeometry(1.2, 64, 64);
const material = new THREE.MeshStandardMaterial({ color: 0xffaa44, roughness: 0.3, metalness: 0.1 });
const sphere = new THREE.Mesh(geometry, material);
sphere.position.set(-1.5, 0.5, 0);
scene.add(sphere);

const boxGeometry = new THREE.BoxGeometry(1.5, 1.5, 1.5);
const boxMaterial = new THREE.MeshStandardMaterial({ color: 0x44aaff, roughness: 0.2, metalness: 0.3 });
const box = new THREE.Mesh(boxGeometry, boxMaterial);
box.position.set(1.5, 0.5, 0);
scene.add(box);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(2, 5, 3);
scene.add(light);
scene.add(new THREE.AmbientLight(0x404060));

// 后期合成器
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);

// 自定义灰度滤镜
const grayscalePass = new ShaderPass({
  uniforms: {
    tDiffuse: { value: null } // 这是 ShaderPass 约定的输入纹理
  },
  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);
    }
  `
});

composer.addPass(grayscalePass);
// 确保最后一个 Pass 输出到屏幕
grayscalePass.renderToScreen = true;

// 动画循环
function animate() {
  requestAnimationFrame(animate);
  sphere.rotation.y += 0.01;
  box.rotation.x += 0.01;
  composer.render();
}
animate();

运行这段代码,画面就变成黑白的了。关键点:

  • tDiffuse 是 ShaderPass 内置的 uniform,代表上一个 Pass 渲染好的纹理。
  • 顶点着色器只需要传递 UV 坐标。
  • 片元着色器里用 texture2D 采样,然后算灰度值。

三、第二个滤镜:边缘发光

这个效果其实是用 Sobel 算子检测边缘,然后在边缘处叠加发光颜色。

const edgePass = new ShaderPass({
  uniforms: {
    tDiffuse: { value: null },
    resolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform vec2 resolution;
    varying vec2 vUv;

    void main() {
      vec2 texel = vec2(1.0 / resolution.x, 1.0 / resolution.y);
      
      // Sobel 算子
      float gx = 0.0;
      float gy = 0.0;
      
      // 采样周围8个点
      for (int i = -1; i <= 1; i++) {
        for (int j = -1; j <= 1; j++) {
          vec2 offset = vec2(float(i), float(j)) * texel;
          vec4 c = texture2D(tDiffuse, vUv + offset);
          float gray = dot(c.rgb, vec3(0.299, 0.587, 0.114));
          
          // Sobel 权重
          float wx = float(j) * (1.0 - abs(float(i)));
          float wy = float(i) * (1.0 - abs(float(j)));
          
          gx += gray * wx;
          gy += gray * wy;
        }
      }
      
      float edge = sqrt(gx * gx + gy * gy);
      edge = clamp(edge * 2.0, 0.0, 1.0);
      
      vec4 original = texture2D(tDiffuse, vUv);
      vec3 edgeColor = vec3(0.2, 0.6, 1.0); // 蓝色边缘
      
      vec3 finalColor = mix(original.rgb, edgeColor, edge);
      gl_FragColor = vec4(finalColor, original.a);
    }
  `
});

把这个 Pass 添加到 composer 里,替换掉灰度滤镜,你会看到物体边缘有一圈蓝色光晕,很有科技感。


四、第三个滤镜:色彩偏移(RGB Split)

故障艺术里常见的 RGB 分裂效果,把红、绿、蓝三个通道稍微错开一点。

const rgbSplitPass = new ShaderPass({
  uniforms: {
    tDiffuse: { value: null },
    amount: { value: 0.01 } // 偏移量
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform float amount;
    varying vec2 vUv;

    void main() {
      vec2 offset = vec2(amount, 0.0);
      
      float r = texture2D(tDiffuse, vUv + offset).r;
      float g = texture2D(tDiffuse, vUv).g;
      float b = texture2D(tDiffuse, vUv - offset).b;
      
      gl_FragColor = vec4(r, g, b, 1.0);
    }
  `
});

为了让效果更动态,可以在动画里让 amount 随机变化:

let time = 0;
function animate() {
  requestAnimationFrame(animate);
  
  time += 0.01;
  rgbSplitPass.uniforms.amount.value = 0.01 + Math.sin(time * 5) * 0.005;
  
  composer.render();
}

运行起来,画面边缘会出现彩色错位,像老式 CRT 显示器故障的感觉。


五、组合多个滤镜

后期处理的魅力在于可以组合。先做 RGB 分裂,再做边缘检测,最后加一点噪点。

composer.addPass(rgbSplitPass);
composer.addPass(edgePass);
composer.addPass(grayscalePass); // 注意顺序会影响结果
edgePass.renderToScreen = false;
grayscalePass.renderToScreen = true; // 最后一个

不同的顺序会产生完全不同的视觉效果,可以多试试。


六、坑点汇总

  1. 纹理坐标vUv 的范围是 0~1,采样时要小心边缘溢出。可以用 clamp 或者 repeat 模式,但一般默认就是 clampToEdgeWrapping
  2. 分辨率:有些滤镜需要知道纹理的实际像素尺寸(比如边缘检测),需要传入 resolution uniform,并在窗口变化时更新。
  3. 性能:每个 Pass 都是一次全屏渲染,Pass 越多越耗性能。可以用 composer.setSize() 降低内部分辨率来优化。
  4. 最后一个 Pass:必须设置 pass.renderToScreen = true,否则画面会消失。
  5. uniform 更新:记得在动画循环里更新自定义 uniform。
  6. 调试技巧:可以在片元着色器里直接返回固定颜色,比如 gl_FragColor = vec4(1.0,0.0,0.0,1.0); 来确认 Pass 是否生效。

七、总结

今天我们手写了三个自定义后期滤镜:灰度、边缘检测、RGB 分裂。掌握了 ShaderPass 的基本用法,你就可以创造出无限种视觉效果。

Three.js 内置了几十个 Shader 例子,在 examples/jsm/shaders/ 目录下。下次需要什么奇怪的效果,不妨先去看看源码,说不定就能改出一个自己的版本。


以下是全部的效果对比图:

image.png

互动

你打算用自定义 Shader 实现什么效果?或者你在写 Shader 时遇到过什么诡异的问题?评论区分享出来,咱们一起解决 😏

下篇预告:【Three.js 项目复盘】一个智慧工厂监控大屏的踩坑实录

❌