前言
调色预设用腻了?那就自己写一个滤镜。不就是给画面加特效吗,GPU 说它很乐意帮忙。
上一篇文章我们聊了电影级调色,用现成的 Bloom 和 LUT 把画面整得挺像回事。但有读者留言:“这些预设是挺好,但我想要那种赛博朋克的色彩偏移效果,或者复古胶片的感觉,LUT 找不到合适的。”
这确实是个问题。LUT 再丰富也有限,真正的自定义还得靠 Shader。
其实 Three.js 的后期处理流水线本来就是用 Shader 搭起来的。BloomPass、FilmPass 这些内置效果,源码里都是一堆 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; // 最后一个
不同的顺序会产生完全不同的视觉效果,可以多试试。
六、坑点汇总
- 纹理坐标:
vUv的范围是 0~1,采样时要小心边缘溢出。可以用clamp或者repeat模式,但一般默认就是clampToEdgeWrapping。 - 分辨率:有些滤镜需要知道纹理的实际像素尺寸(比如边缘检测),需要传入
resolutionuniform,并在窗口变化时更新。 - 性能:每个 Pass 都是一次全屏渲染,Pass 越多越耗性能。可以用
composer.setSize()降低内部分辨率来优化。 - 最后一个 Pass:必须设置
pass.renderToScreen = true,否则画面会消失。 - uniform 更新:记得在动画循环里更新自定义 uniform。
- 调试技巧:可以在片元着色器里直接返回固定颜色,比如
gl_FragColor = vec4(1.0,0.0,0.0,1.0);来确认 Pass 是否生效。
七、总结
今天我们手写了三个自定义后期滤镜:灰度、边缘检测、RGB 分裂。掌握了 ShaderPass 的基本用法,你就可以创造出无限种视觉效果。
Three.js 内置了几十个 Shader 例子,在 examples/jsm/shaders/ 目录下。下次需要什么奇怪的效果,不妨先去看看源码,说不定就能改出一个自己的版本。
以下是全部的效果对比图:
互动
你打算用自定义 Shader 实现什么效果?或者你在写 Shader 时遇到过什么诡异的问题?评论区分享出来,咱们一起解决 😏
下篇预告:【Three.js 项目复盘】一个智慧工厂监控大屏的踩坑实录