想象一下制作一个 3D 粘土模型(网格) 然后 给它拍照:
顶点着色器 = 模型造型师:负责确定模型的 关键点(顶点) 在最终照片中的位置。
片段着色器 = 油漆工/摄影师:负责确定模型上 每一微小表面(像素) 的颜色。

主要工作是把 3D 模型从本地坐标一步步转换到屏幕上的 2D 坐标。
glsl
// Three.js 中最简单的顶点着色器示例
// 展示了标准坐标变换流水线
void main() {
// 1. 将顶点从模型本地空间转换到世界空间
// 2. 再转换到摄像机观察空间
// 3. 最后通过投影矩阵转换到裁剪空间
// 这三步在 Three.js 中通常被合并为:
// modelViewMatrix = viewMatrix × modelMatrix
// projectionMatrix × modelViewMatrix
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
顶点着色器能做什么?
移动顶点位置:创建动态几何体
glsl
// 正弦波效果
float wave = sin(position.x * 10.0 + time) * 0.2;
vec3 newPosition = position;
newPosition.y += wave;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
传递数据到片段着色器:通过 varying 变量
glsl
varying vec2 vUv;
varying vec3 vWorldPosition;
varying vec3 vNormal;
void main() {
vUv = uv; // 传递UV坐标
vNormal = normalMatrix * normal; // 变换法线
vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
主要工作是为屏幕上每个像素计算最终颜色。
glsl
// 最简单的片段着色器 - 纯色
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 红色
}
// 使用顶点着色器传递的UV坐标采样纹理
uniform sampler2D map;
varying vec2 vUv;
void main() {
vec4 texColor = texture2D(map, vUv);
gl_FragColor = texColor;
}
片段着色器能做什么?
纹理采样与混合
光照计算(冯氏、Phong、PBR等)
glsl
// 简单的漫反射光照
varying vec3 vNormal;
uniform vec3 lightDirection;
uniform vec3 diffuseColor;
void main() {
float diffuse = max(dot(normalize(vNormal), normalize(-lightDirection)), 0.0);
vec3 color = diffuseColor * diffuse;
gl_FragColor = vec4(color, 1.0);
}
顶点着色器和片段着色器之间通过 varying 变量(或 GLSL 300 es 中的 in/out) 通信:
text
顶点着色器 → [插值器] → 片段着色器
↓ ↓
每个顶点计算 每个像素接收插值后的数据
关键点:片段着色器接收的是插值后的数据!
三角形三个顶点的颜色不同 → 内部像素获得平滑渐变
三个顶点的UV坐标 → 内部像素获得正确的纹理坐标
glsl
// 顶点着色器
attribute vec3 color;
varying vec3 vColor;
void main() {
vColor = color; // 每个顶点有自己的颜色
gl_Position = ...;
}
// 片段着色器
varying vec3 vColor;
void main() {
// vColor 在这里是三个顶点颜色的插值混合!
gl_FragColor = vec4(vColor, 1.0);
}
顶点着色器执行次数 ≈ 顶点数量
片段着色器执行次数 ≈ 屏幕像素数 × 模型覆盖面积(有深度测试和提前终止优化)
优化原则:
将计算放在执行频率更低的阶段更高效
能在顶点着色器计算的,就不要在片段着色器重复计算
但顶点着色器无法访问纹理(现代GPU可以但有限制),所以纹理采样必须在片段着色器
glsl
// 顶点着色器 - 计算波浪位移
uniform float time;
varying float vHeight;
void main() {
float height = sin(position.x + time) * 0.5;
vHeight = height;
vec3 pos = vec3(position.x, position.y + height, position.z);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
// 片段着色器 - 基于高度着色
varying float vHeight;
void main() {
vec3 color = mix(vec3(0.0, 0.0, 1.0), vec3(1.0, 1.0, 0.0), vHeight + 0.5);
gl_FragColor = vec4(color, 1.0);
}
glsl
// 顶点着色器 - 只传递世界位置
varying vec3 vWorldPos;
void main() {
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// 片段着色器 - 计算波浪和颜色
uniform float time;
varying vec3 vWorldPos;
void main() {
// 问题:每个像素都计算sin,开销大!
float height = sin(vWorldPos.x + time) * 0.5;
vec3 color = mix(vec3(0.0, 0.0, 1.0), vec3(1.0, 1.0, 0.0), height + 0.5);
gl_FragColor = vec4(color, 1.0);
}
结果对比:
方案A更高效:波浪计算只在每个顶点执行一次
方案B有性能问题:每个像素都计算sin函数,但视觉效果可能更平滑(无顶点插值的锯齿)
顶点着色器决定 "形状和位置"
片段着色器决定 "外观和颜色"
能用顶点着色器预计算的,就不要在片段着色器重复计算
需要逐像素精确计算的(如纹理、光照细节),必须在片段着色器
理解这个区别是掌握Three.js高级渲染技术的基石。通常,一个效果需要两者协作:顶点着色器准备数据、片段着色器实现最终视觉效果。
text
modelViewMatrix = viewMatrix × modelMatrix
注意:矩阵乘法是从右往左应用的,所以:
先应用 modelMatrix:将顶点从模型局部空间 → 世界空间
再应用 viewMatrix:将顶点从世界空间 → 摄像机观察空间
glsl
// 在顶点着色器中,这相当于:
vec4 modelViewPosition = viewMatrix * modelMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * modelViewPosition;
为了优化!Three.js 在 CPU 端预先计算了这个乘法,避免在 GPU 的每个顶点上重复计算:
javascript
// Three.js 内部大概是这样做的:
material.uniforms.modelViewMatrix.value = camera.matrixWorldInverse.multiply(object.matrixWorld);
让我们看看从模型局部坐标到屏幕坐标的完整过程:
text
模型局部空间 → 世界空间 → 观察空间 → 裁剪空间 → 屏幕空间
↓ ↓ ↓ ↓ ↓
position modelMatrix viewMatrix projectionMatrix viewport变换
\ / /
modelViewMatrix gl_Position
在顶点着色器中的三种写法(效果相同):
position 是顶点在模型局部空间(Model Local Space / Object Space)中的坐标。
它描述的是网格在自身坐标系中的原始几何形状,不考虑物体在场景中的位置、旋转或缩放。
关键点:position 永远不变!
glsl
// 写法1:使用预计算的 modelViewMatrix(最常用、最优化)
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
// 写法2:显式分解(更清晰理解过程)
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * worldPosition;
gl_Position = projectionMatrix * viewPosition;
// 写法3:直接使用 modelViewProjectionMatrix(如果Three.js提供了的话)
// gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
想象一个在 (1, 0, 0) 位置的立方体和一个在 (0, 0, 5) 的摄像机:
javascript
// JavaScript 设置
cube.position.set(1, 0, 0);
camera.position.set(0, 0, 5);
camera.lookAt(0, 0, 0);
// 在顶点着色器中的变换过程:
// 1. modelMatrix:将立方体从局部中心移到世界空间的 (1, 0, 0)
// 2. viewMatrix:将所有物体反向移动,使摄像机在原点看向 -Z
// (相当于把世界移动,让摄像机在原点)
// 3. projectionMatrix:应用透视/正交投影

javascript
// 创建着色器材质
const material = new THREE.ShaderMaterial({
vertexShader: `
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;
uniform mat4 modelViewMatrix;
// 这些是等价的!
void main() {
// 方式1:直接使用预计算的 modelViewMatrix
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
// 方式2:自己组合(验证它们相等)
vec4 test1 = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vec4 test2 = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
// test1 应该完全等于 test2(可能有浮点误差)
}
`,
fragmentShader: `...`
});
误区1: modelViewMatrix = modelMatrix × viewMatrix
❌ 错误!矩阵乘法顺序很重要
✅ 正确:modelViewMatrix = viewMatrix × modelMatrix
误区2: modelViewMatrix 包含了投影
❌ 错误!它只包含模型和视图变换
✅ 正确:需要再乘以 projectionMatrix 才能到裁剪空间
误区3: 可以随意修改这些矩阵
⚠️ 小心!Three.js 每帧自动更新这些矩阵
如果要修改,通常应该通过 uniform 传递自定义变换
大多数时候用 modelViewMatrix 就行,但有些特殊情况需要分解:
glsl
// 例子:需要在世界空间中计算光照
varying vec3 vWorldPosition;
varying vec3 vViewPosition;
void main() {
// 世界空间位置(用于世界空间的光照计算)
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPos.xyz;
// 观察空间位置(也可以从 modelViewMatrix 获得)
vec4 viewPos = modelViewMatrix * vec4(position, 1.0);
vViewPosition = viewPos.xyz;
// 裁剪空间位置(最终输出)
gl_Position = projectionMatrix * viewPos;
}
✅ modelViewMatrix = viewMatrix × modelMatrix(顺序很重要!)
Three.js 提供它是为了优化(避免每个顶点重复矩阵乘法)
理解这个变换链是掌握 3D 图形编程的基础
大多数情况下直接用 modelViewMatrix 即可,需要世界空间位置时才分解计算