7.1 着色器:顶点与片元的定制涂装 本节摘要:着色器是写在显卡上的小程序:顶点着色器决定每个顶点去哪,片元着色器决定每个像素是什么色。本节讲清 ShaderMaterial 的接线方式——uniform 传参数、varying 跨阶段传值——并用一个流光扫描面走完整流程,同时给出不上 ShaderMaterial 的三个替身。 涂装规则的最后一段路 前三章的材质调参,本质是在"填表":roughness 填多少、metalness 填多少,内置的光照程序按表计算。但有些效果没有表可填——一道从左到右扫过货架的流光、一张随时间扭曲的水波贴图、一片程序化生成的噪声星空。这些效果的涂装规则必须自己写,写在哪?显卡能直接执行的两段小程序:顶点着色器与片元着色器。
本节摘要:着色器是写在显卡上的小程序:顶点着色器决定每个顶点去哪,片元着色器决定每个像素是什么色。本节讲清 ShaderMaterial 的接线方式——uniform 传参数、varying 跨阶段传值——并用一个流光扫描面走完整流程,同时给出不上 ShaderMaterial 的三个替身。
前三章的材质调参,本质是在"填表":roughness 填多少、metalness 填多少,内置的光照程序按表计算。但有些效果没有表可填——一道从左到右扫过货架的流光、一张随时间扭曲的水波贴图、一片程序化生成的噪声星空。这些效果的涂装规则必须自己写,写在哪?显卡能直接执行的两段小程序:顶点着色器与片元着色器。
分工一句话记住:顶点管形,片元管色。顶点着色器对几何体的每个顶点各跑一遍,输出顶点的最终屏幕位置——想让平面波动、让模型膨胀,改它。片元着色器对最终画面上的每个像素各跑一遍,输出颜色——流光、描边、噪声,改它。ShaderMaterial 就是把这两段程序交给 Three.js 托管的接口:你写 GLSL,Three.js 负责编译、喂参数、组织渲染。

背景:竞标演示需要一个"科技感"细节:一道亮带每隔几秒从货架左端扫到右端,扫过之处漆面瞬间提亮。内置材质没有任何参数能表达"随时间移动的亮带",这是一道标准的片元涂装题。
操作:ShaderMaterial 两段程序加一个每帧下发的 uniform;顶点程序原样搬官方默认(本效果不需要动形),全部逻辑写在片元里。
const scanUniforms = { uScanX: { value: 0.0 }, // 扫描线当前位置,JS 每帧下发 uWidth: { value: 0.08 }, // 亮带宽度 uBaseCol: { value: new THREE.Color(0x3a6ea5) } }; const scanMat = new THREE.ShaderMaterial({ uniforms: scanUniforms, vertexShader: ` varying vec2 vUv; // 顶点阶段:把 uv 转交给像素阶段 void main() { vUv = uv; // uv 是材质自带的顶点属性 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`, fragmentShader: ` uniform float uScanX; // 每像素可读的扫描位置 uniform float uWidth; uniform vec3 uBaseCol; varying vec2 vUv; void main() { float d = abs(vUv.x - uScanX); // 当前像素离扫描线的横向距离 float glow = smoothstep(uWidth, 0.0, d); // 距离越近越亮,边缘平滑 vec3 col = uBaseCol + glow * vec3(0.6); // 基色加提亮 gl_FragColor = vec4(col, 1.0); }` }); shelfTrailer.material = scanMat; stage.loop((dt, elapsed) => { scanUniforms.uScanX.value = (elapsed * 0.35) % 1.3 - 0.15; // 扫过带回扫间隙 });
结果:一道柔边亮带约三秒扫过货架一次,边缘由 smoothstep 磨得没有锯齿;改 uWidth 一个数字,亮带立即变宽变窄。整段效果没有新建任何几何体,帧开销只多了一次每像素的减法与一次平滑插值。
解读:接线逻辑比 GLSL 语法更值得记。uniform 是"广播"——主程序每帧更新一次,全部像素读到同一个值;varying 是"接力"——顶点阶段算好逐点数值,显卡在三角形内部自动插值,像素阶段拿到的是插值后的连续量。本例把 uv 通过 varying 递下去,片元里才有"我在表面的哪里"这个信息。性能心算看图下那行注记:片元程序是"百万像素乘帧率"的账,里面的每一步乘法都值钱——能用顶点阶段算的就别挪到片元,能用一维距离的就别做二维采样。
变式:三个替身先试过再上 ShaderMaterial。其一,onBeforeCompile:往内置材质的着色程序里注入几行代码,保留全部光照管线,适合"在标准材质上加一点料";其二,内置参数组合:emissive 配贴图能做出简单的滚动光效;其三,顶点色与几何体动画(顶点直接改数据):不动涂装规则就能做波动。真正需要完整 ShaderMaterial 的信号只有一个——效果需要"自己的涂装规则"而不是"现有规则的参数"。
着色器的报错体验与 JavaScript 完全不同:GLSL 编译错误不会抛在熟悉的堆栈里,而是藏在控制台的长串日志中,且往往只有一个行号没有上下文。会诊按固定顺序:先看报错行号附近的分号与类型——GLSL 是强类型语言,整数浮点混用(写成 1.0 与 1 的区别)是最常见的第一刀;再查 varying 在两个阶段的名字与类型是否完全一致,接力棒名字对不上直接编译失败;最后确认 uniform 变量在 JS 侧的 uniforms 对象里登记齐全,漏登记的 uniform 在某些驱动上静默当零处理,效果"莫名不生效"。调试利器是"输出调试法":把可疑变量直接塞进 gl_FragColor 的某个通道,屏幕颜色立刻告诉你它的取值——比任何日志都直观。写自定义着色器的第一周,你会觉得这是在与显卡对暗号;两周之后,这些会诊顺序会变成肌肉记忆。
涂装规则已可定制。下一节把整幅画面送进抛光车间:后期处理链为什么必须留降级开关,泛光为什么不能对全屏开。