前言
以前我觉得 Shader 是神仙才能看懂的东西,直到我发现它其实就是告诉 GPU“怎么画”的说明书。
两年前我第一次接触 Shader,看了一篇教程,开头就是 gl_FragColor、varying、uniform 这些天书一样的词。我心想:这玩意儿是人写的吗?
后来项目里有个需求:让模型边缘发蓝光。网上搜了一堆方案,最后发现除了自己写 Shader 别无他法。硬着头皮啃了一周,终于把第一个能跑起来的着色器怼出来了。运行起来的那一刻,模型边缘真的泛着蓝光,我当时激动得差点原地蹦起来。
原来 Shader 没那么可怕,它就像一本固定的菜谱,你告诉 GPU“颜色怎么混合、顶点怎么移动”,它就能画出你想要的效果。
今天我就用最笨的方式,带你写三个最简单的自定义着色器。不扯虚的,代码直接跑,效果直接看。
一、Shader 是啥?
通俗点说,渲染一个 3D 模型要经过两个阶段:
- 顶点着色器(Vertex Shader):负责处理每个顶点的位置、变换。好比整容医生,决定骨架长啥样。
- 片元着色器(Fragment Shader):负责计算每个像素的颜色。好比化妆师,给每个点涂上什么颜色。
Three.js 默认的材质(比如 MeshStandardMaterial)内部已经有一套写好的着色器。我们用自定义着色器,就是替换掉默认的,自己控制这两个阶段。
二、Three.js 里的自定义材质
Three.js 提供了两种方式来写自定义着色器:
ShaderMaterial:自动帮你补全一些默认的 uniform 和 attribute,适合初学者。RawShaderMaterial:完全自己控制,什么都不帮你补,适合进阶。
我们先从 ShaderMaterial 开始,省事。
基础结构
const material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
color: { value: new THREE.Color(0xffaa00) }
},
vertexShader: `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 color;
void main() {
gl_FragColor = vec4(color, 1.0);
}
`
});
uniforms:可以从 JavaScript 传给着色器的变量,每帧可以更新。vertexShader:顶点着色器代码,字符串形式。fragmentShader:片元着色器代码。
gl_Position 是顶点着色器必须输出的最终位置,gl_FragColor 是片元着色器必须输出的最终颜色。
三、第一个例子:让模型颜色随时间变化
我们用上面的基础结构,加一个 time uniform,让颜色在红色和蓝色之间循环。
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(2, 2, 5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
new OrbitControls(camera, renderer.domElement);
// 创建一个立方体
const geometry = new THREE.BoxGeometry(2, 2, 2);
// 自定义着色器材质
const material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 }
},
vertexShader: `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float time;
void main() {
// 让红色分量在 0.5 到 1.0 之间变化,蓝色分量反向变化
float r = 0.6 + 0.4 * sin(time);
float b = 0.6 + 0.4 * cos(time);
gl_FragColor = vec4(r, 0.2, b, 1.0);
}
`
});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
function animate() {
requestAnimationFrame(animate);
// 更新 uniform 中的时间
material.uniforms.time.value += 0.05;
renderer.render(scene, camera);
}
animate();
把这段代码贴进一个 HTML 文件里运行,你会看到一个立方体颜色在紫色系里渐变。
四、第二个例子:让顶点动起来(波浪效果)
现在我们来动顶点。让立方体的顶点按照正弦波上下浮动,像一块果冻。
const geometry = new THREE.BoxGeometry(2, 2, 2, 32, 32, 32); // 增加细分段数,让波浪更平滑
const material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 }
},
vertexShader: `
uniform float time;
void main() {
// 根据顶点原来的位置计算偏移量
float offsetX = sin(position.y * 2.0 + time * 3.0) * 0.2;
float offsetZ = cos(position.y * 2.0 + time * 2.0) * 0.2;
vec3 newPosition = position + vec3(offsetX, 0.0, offsetZ);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
`,
fragmentShader: `
void main() {
gl_FragColor = vec4(0.6, 0.8, 1.0, 1.0);
}
`
});
这里的关键是:我们在顶点着色器里修改了 position,然后再进行 MVP 变换。注意 position 是模型局部坐标,计算时要小心不要破坏模型结构。
运行后,立方体的侧面会像波浪一样起伏。
五、第三个例子:简单边缘光
边缘光(Fresnel Effect)是让模型边缘发光的常见效果。原理是视线方向与法线方向越垂直(边缘),光越强。
我们需要在顶点着色器里把法线和视线方向传给片元着色器。
const material = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 }
},
vertexShader: `
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewPosition = -mvPosition.xyz; // 指向相机的方向
vNormal = normalize(normalMatrix * normal); // 将法线转换到视图空间
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec3 normal = normalize(vNormal);
vec3 viewDir = normalize(vViewPosition);
// 计算视线与法线的点积,越接近0(垂直)强度越大
float fresnel = 1.0 - abs(dot(normal, viewDir));
fresnel = pow(fresnel, 2.0); // 增强对比
vec3 baseColor = vec3(0.3, 0.6, 1.0);
vec3 edgeColor = vec3(0.8, 0.9, 1.0);
vec3 finalColor = mix(baseColor, edgeColor, fresnel);
gl_FragColor = vec4(finalColor, 1.0);
}
`
});
这个效果在球体上最明显,模型边缘会有一圈亮光,非常有科技感。
六、坑点总结
- 矩阵顺序:
projectionMatrix * modelViewMatrix * vec4(position, 1.0)顺序不能错,Three.js 的矩阵是右乘,坐标从右向左变换。 - 法线变换:不能用
modelViewMatrix直接乘法线,要用normalMatrix(它是 modelViewMatrix 的逆转置的左上3x3)。 - 变量精度:移动设备上可能需要指定精度,比如
precision highp float;放在着色器开头。 - uniform 更新:记得在动画循环里更新
material.uniforms.xxx.value。 - 调试技巧:可以用
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);先确认片元着色器是否运行,用gl_Position = vec4(position, 1.0);(跳过 MVP)看顶点原始位置。
七、进阶方向
这三个例子只是冰山一角。有了自定义着色器,你还可以做:
- 流光效果
- 溶解消失
- 噪声纹理生成地形
- 后处理滤镜
Three.js 官方提供了很多现成的着色器例子,在 examples/jsm/shaders/ 目录下。有空可以翻翻源码,看看大佬们怎么写。
互动
你写过最得意的 Shader 效果是啥?或者你在学习 Shader 时遇到过什么坑?评论区分享出来,咱们一起讨论 😏
下篇预告:【Three.js 后期处理进阶】用 Shader 实现自己的滤镜,让画面拥有电影质感