前面几篇都在 同一个 shader-playground 工程里,用 ShaderMaterial 直接往屏幕 framebuffer 画东西。实际产品里还有另一条常见路径:先把场景画进离屏纹理,再经过一个全屏四边形跑片元,做暗角、调色、模糊、Bloom——这叫 后期 Post-processing。Three.js 用 EffectComposer 把多个 Pass 串起来:RenderPass 负责「场景 → 纹理」,后面的 ShaderPass 负责「纹理 → 纹理」,最后一站输出到屏幕。
本篇分两块:第一块仍在 shader-playground 目录,只替换 main.js,接入 EffectComposer、RenderPass、ShaderPass,做一个最简单的 暗角 vignette,体会 tDiffuse 如何从上一 Pass 传到下一 Pass,以及 resize 时为什么要同时更新 composer.setSize。第二块不再引入新代码,而是把之前散落的点串成知识地图,给你 自测题与延伸方向,作为 Shader 入门段的句号。
后期这一段的场景故意换回 MeshStandardMaterial 的立方体加灯光,避免和前面 Shader 平面示例混淆:你要分清「场景里物体用什么材质」和「后期全屏用什么 Shader」是两层事。Composer 链路上若色彩发灰、发曝,往往和 outputColorSpace、OutputPass(随 Three 版本查阅官方示例)有关;本篇示例以 跑通管线 为主,升级引擎时请以当前版本文档为准。
学会后期之后,你会在 examples/jsm/postprocessing 里看到 Bloom、SSAO、SSR 等现成 Pass,本质都是 RenderPass + 若干图像处理 Pass,和本节暗角没有本质区别,只是 uniform 更多、采样更重。
在 shader-playground 中继续
index.html 仍可不变。将 main.js 整体替换为下面代码(覆盖前请先备份你上一版球体 Fresnel,若需要留档可复制为 main.fresnel.js):
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a22);
const width = window.innerWidth;
const height = window.innerHeight;
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100);
camera.position.set(2, 1.6, 2.8);
camera.lookAt(0, 0, 0);
const box = new THREE.Mesh(
new THREE.BoxGeometry(0.9, 0.9, 0.9),
new THREE.MeshStandardMaterial({ color: 0x6688cc, roughness: 0.35, metalness: 0.1 }),
);
scene.add(box);
const light = new THREE.DirectionalLight(0xffffff, 1.2);
light.position.set(2, 4, 3);
scene.add(light);
scene.add(new THREE.AmbientLight(0xffffff, 0.25));
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
const composer = new EffectComposer(renderer);
composer.setPixelRatio(window.devicePixelRatio);
composer.setSize(width, height);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const vignetteShader = {
uniforms: {
tDiffuse: { value: null },
uStrength: { value: 0.55 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uStrength;
varying vec2 vUv;
void main() {
vec4 tex = texture2D(tDiffuse, vUv);
vec2 uv = (vUv - 0.5) * 2.0;
float d = length(uv);
float v = 1.0 - d * uStrength;
v = clamp(v, 0.15, 1.0);
gl_FragColor = vec4(tex.rgb * v, tex.a);
}
`,
};
const vignettePass = new ShaderPass(vignetteShader);
composer.addPass(vignettePass);
function tick() {
requestAnimationFrame(tick);
box.rotation.y += 0.008;
controls.update();
composer.render();
}
tick();
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
composer.setSize(w, h);
});
主循环里务必调用 composer.render(),不要再调 renderer.render(scene, camera),否则后期 Pass 不会执行。resize 里 renderer 与 composer 同步改尺寸,否则窗口拉伸后会出现模糊或采样错位。
若你希望暗角边缘带一点冷色,可在 gl_FragColor 前对 tex.rgb 乘 vec3(0.95, 0.97, 1.0) 之类微调,只动片元即可,不必改场景。

阶段回顾:你现在已经掌握什么
Shader 入门段覆盖了:
- 数据流:
attribute(如position、uv)、uniform(时间、贴图、参数)、varying(vUv、法线、世界坐标)。 - 片元:颜色、纹理采样、程序化噪声、discard、Fresnel。
- 顶点:位移、法线传递(世界空间)。
- 后期:Composer 串联、全屏 UV 采样上一张纹理。
性能直觉:顶点次数等于顶点数,片元次数等于像素量级;噪声循环、多重纹理采样、全屏 Pass 叠太多,都是常见瓶颈。优化时优先Profiler 再下结论。
案例代码上传了小册仓库
总结
- 后期 是「场景渲染结果当纹理再加工」,与物体上的
ShaderMaterial并行存在于工具箱。 - 入门段目标:能搭环境、能读典型 GLSL、能根据 Console 排编译错、知道 uniform/varying 分工;更复杂的项目再拆模块、拆 glsl 文件、上构建链即可。
到这里,Shader 入门系列在结构上可以 告一段落;实战中把本篇的片段组合进真实场景时,记得关注 色彩空间、透明排序、性能剖析 三件事。