前面两篇都在 片元 里做文章:颜色、纹理、噪声。GPU 流水线的另一半是 顶点着色器:每个顶点执行一次,决定最终进裁剪空间的位置,也能向片元传递更多信息。把顶点在局部空间里先挪动再乘矩阵,就能得到旗帜、水面式的起伏;把法线变到世界空间再做 dot,就能得到最简的 Lambert 明暗;片元里 discard 掉不要的像素,就能做硬边洞;用视线和法线算 Fresnel,就能得到边缘光——这些套路在业务里反复出现。
本篇依旧 只使用 shader-playground 目录,下面四段是四个独立演示,每一段都给 完整 main.js。建议你按顺序覆盖运行,每跑通一段就 git commit 一次,仓库里自然形成阶梯式历史。四段用到的几何体不同(高分段平面、静态平面、球体),是为了突出各自知识点;若强行揉进一个场景,初学者反而分不清相机该摆在哪,所以保持「一次覆盖 main、一次看清一个效果」更清晰。
第一段 顶点位移:平面在 XZ 上细分,position 上加正弦乘积,vHeight 传给片元做假高光。这里没有用真实法线算光照,而是用高度映射颜色,避免和第二段混淆。第二段 漫反射:几何体不动,专注 mat3(modelMatrix) * normal 与定向光 L 的点积。第三段 discard:圆外直接丢弃片元,体会与 alpha 混合的差异。第四段 Fresnel:球体上 cameraPosition 与视线,掠射角变亮。
非均匀缩放下法线变换不能再用简单 mat3(modelMatrix),需要法线矩阵;本篇示例几何体未做奇怪缩放,先把点积关系建立起来即可。
为什么顶点位移那一段不用「真光照」?因为一旦改了 position,原始 normal 就不再垂直于真实表面,除非你在 CPU 或片元里用偏导数重新求法线,或者用切线空间。教学上如果第一段就上「位移 + 正确法线」,公式会很长,容易掩盖「位移发生在乘投影矩阵之前」这条主线。所以第一段用 高度映射颜色 当视觉反馈,第二段再用静态平面把 点积 讲清楚,是刻意拆开的。你日后做水面,往往会在顶点里估一个近似法线,或者用 FFT 海洋那种更系统的做法,那是专题课的内容。
discard 会让部分片元既不写颜色也不写深度(依硬件与混合状态而定),可能打断 GPU 的 early depth test 优化,全屏大量 discard 时要心里有数。和 透明度混合 相比:discard 是硬切,半透明是柔过渡;和 UI、粒子、透明物体叠在一起时,排序问题常常比 Shader 本身更耗精力。业务里若只是「中间挖个洞」,discard 很合适;若需要羽化边缘,改用 alpha 配合 smoothstep 更常见。
Fresnel 这一段用的是 经验式 pow(1.0 - dot(N, V), k),物理上还有 Schlick 等更贴近测量的模型,但视觉上已经能区分「塑料泡罩」和「实心球」。把 k 调大,边缘变窄、像金属氧化膜;调小,边缘变宽、像雾里的月亮。和第二段 Lambert 合在一起时,记得统一在 同一空间 算法线和光向量,否则会出现「怎么调都别扭」的明暗。
验收时 OrbitControls 会改变相机,从而改变 Fresnel 里的视线向量;若你关掉控制器、固定相机,仍应看到边缘亮,说明 Shader 没问题。若只有拖动时才亮,多半是误把应在片元里恒成立的条件写成了依赖屏幕。
在 shader-playground 中继续
路径不变:index.html 与 package.json 保持第一篇配置。以下四份代码 每次用一份完整覆盖 main.js。
一、顶点位移与高度上色
将 main.js 替换为:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
const width = window.innerWidth;
const height = window.innerHeight;
const camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 100);
camera.position.set(1.2, 1.0, 1.8);
camera.lookAt(0, 0, 0);
const vertexShader = `
uniform float uTime;
varying vec2 vUv;
varying float vHeight;
void main() {
vUv = uv;
vec3 pos = position;
float w = sin(pos.x * 6.0 + uTime * 2.0) * cos(pos.y * 5.0 + uTime * 1.5);
pos.z += w * 0.12;
vHeight = w;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`;
const fragmentShader = `
varying vec2 vUv;
varying float vHeight;
void main() {
float h = vHeight * 0.5 + 0.5;
vec3 base = vec3(0.2, 0.45, 0.65);
vec3 crest = vec3(0.5, 0.85, 0.95);
vec3 color = mix(base, crest, h);
gl_FragColor = vec4(color, 1.0);
}
`;
const geometry = new THREE.PlaneGeometry(2, 2, 128, 128);
geometry.rotateX(-Math.PI / 2);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0 },
},
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
const clock = new THREE.Clock();
function tick() {
requestAnimationFrame(tick);
material.uniforms.uTime.value = clock.getElapsedTime();
controls.update();
renderer.render(scene, camera);
}
tick();
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
分段数从 32 改到 256,波纹更细、顶点着色器负担更重,这是调效果时的典型权衡。

二、世界空间法线与 Lambert
将 main.js 替换为:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111118);
const width = window.innerWidth;
const height = window.innerHeight;
const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100);
camera.position.set(1.2, 1.0, 1.5);
camera.lookAt(0, 0, 0);
const vertexShader = `
varying vec2 vUv;
varying vec3 vWorldNormal;
void main() {
vUv = uv;
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPos;
}
`;
const fragmentShader = `
varying vec2 vUv;
varying vec3 vWorldNormal;
void main() {
vec3 N = normalize(vWorldNormal);
vec3 L = normalize(vec3(0.35, 1.0, 0.45));
float ndl = max(dot(N, L), 0.0);
vec3 base = vec3(0.25, 0.45, 0.72);
vec3 ambient = base * 0.15;
vec3 diffuse = base * ndl;
vec3 color = ambient + diffuse;
gl_FragColor = vec4(color, 1.0);
}
`;
const geometry = new THREE.PlaneGeometry(2, 2, 1, 1);
geometry.rotateX(-Math.PI / 2);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
function tick() {
requestAnimationFrame(tick);
controls.update();
renderer.render(scene, camera);
}
tick();
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
可把 L 改成 uniform vec3,与 DirectionalLight 方向对齐,留作扩展。

三、discard 圆形洞
将 main.js 替换为:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a10);
const width = window.innerWidth;
const height = window.innerHeight;
const camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 100);
camera.position.set(0, 0, 2.2);
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 c = vUv - vec2(0.5);
float r = length(c);
float hole = 0.35 + 0.05 * sin(uTime * 3.0);
if (r > hole) {
discard;
}
float edge = smoothstep(hole - 0.02, hole, r);
vec3 inner = vec3(0.35, 0.55, 0.85);
vec3 rim = vec3(0.9, 0.95, 1.0);
vec3 color = mix(inner, rim, edge);
gl_FragColor = vec4(color, 1.0);
}
`;
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0 },
},
transparent: true,
depthWrite: true,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x0a0a10, 1);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
const clock = new THREE.Clock();
function tick() {
requestAnimationFrame(tick);
material.uniforms.uTime.value = clock.getElapsedTime();
controls.update();
renderer.render(scene, camera);
}
tick();
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});

四、Fresnel 边缘光(球体)
将 main.js 替换为:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080810);
const width = window.innerWidth;
const height = window.innerHeight;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
camera.position.set(0, 0.2, 2.8);
const vertexShader = `
varying vec3 vWorldNormal;
varying vec3 vWorldPos;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
vWorldPos = wp.xyz;
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * wp;
}
`;
const fragmentShader = `
varying vec3 vWorldNormal;
varying vec3 vWorldPos;
void main() {
vec3 N = normalize(vWorldNormal);
vec3 V = normalize(cameraPosition - vWorldPos);
float fresnel = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 3.0);
vec3 base = vec3(0.12, 0.18, 0.28);
vec3 rim = vec3(0.4, 0.85, 1.0);
vec3 color = mix(base, rim, fresnel);
gl_FragColor = vec4(color, 1.0);
}
`;
const geometry = new THREE.SphereGeometry(0.85, 64, 32);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
function tick() {
requestAnimationFrame(tick);
controls.update();
renderer.render(scene, camera);
}
tick();
window.addEventListener('resize', () => {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});

案例代码上传了小册仓库
总结
- 顶点阶段改
position就能动几何,代价是法线可能需重算,光照才严谨。 - 法线 + 光方向 点积是自定义漫反射的基石,与内置材质数学上一致,只是你自己写。
- discard 与 alpha 适用场景不同,后期合成时尤其要留心深度。
- Fresnel 把 视线 拉进片元,和只用法线、光照向量的 Lambert 形成互补。
- 下一篇仍在
shader-playground,用 EffectComposer 走一遍后期暗角,并做整段 Shader 入门的收束与练习建议。