38 · JavaScript 数学数据如何进入 Shader
1. 三类输入对应三种变化频率
JavaScript 中的 Vector3、Matrix3 是 CPU 对象;GLSL 中的 vec3、mat3 是 GPU 程序中的值。名字相似不意味着共享一块随时同步的 JavaScript 内存,渲染器需要按绘制流程上传数据。
| 数据通道 | 变化范围 | 常见内容 |
|---|---|---|
| attribute | 每个顶点可以不同 | 位置、法线、UV、顶点颜色 |
| uniform | 一次绘制中共享 | 时间、矩阵、光源位置、材质参数 |
| varying | 顶点输出,由光栅化阶段插值后进入片元 | UV、法线、观察空间位置 |
一个网格的 ShaderMaterial 并非为每个顶点创建一个 JavaScript 对象;几何体通过 BufferAttribute 组织批量数字。顶点着色器逐顶点处理,片元着色器逐片元处理。同一个三角形可能产生许多片元,因此放在片元阶段的重复计算可能更昂贵。
2. 一个完整的数据流示例
将本章主代码放入 README 说明的练习入口。它创建一个平面,使用自定义 Matrix3 变换 UV,并把世界光源位置转换为相机空间,供片元光照使用。
import { createDemo } from './runtime.js';
const { THREE, scene, camera, onFrame, dispose } = createDemo();
const uniforms = {
uvTransform: { value: new THREE.Matrix3() },
lightPositionView: { value: new THREE.Vector3() }
};
const geometry = new THREE.PlaneGeometry(4, 4, 1, 1);
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader: `
uniform mat3 uvTransform;
varying vec2 vUvTransformed;
varying vec3 vNormalView;
varying vec3 vPositionView;
void main() {
vec4 positionView = modelViewMatrix * vec4(position, 1.0);
vPositionView = positionView.xyz;
vNormalView = normalMatrix * normal;
vUvTransformed = (uvTransform * vec3(uv, 1.0)).xy;
gl_Position = projectionMatrix * positionView;
}
`,
fragmentShader: `
uniform vec3 lightPositionView;
varying vec2 vUvTransformed;
varying vec3 vNormalView;
varying vec3 vPositionView;
void main() {
vec3 normalUnit = normalize(vNormalView);
vec3 towardLight = normalize(lightPositionView - vPositionView);
float diffuse = max(dot(normalUnit, towardLight), 0.0);
float stripe = step(0.5, fract(vUvTransformed.x * 8.0));
vec3 base = mix(vec3(0.18, 0.35, 0.5), vec3(0.8, 0.35, 0.2), stripe);
gl_FragColor = vec4(base * (0.15 + 0.85 * diffuse), 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const lightWorld = new THREE.Vector3(2, 3, 5);
onFrame((dt, elapsed) => {
camera.updateWorldMatrix(true, false);
uniforms.lightPositionView.value.copy(lightWorld)
.applyMatrix4(camera.matrixWorldInverse);
uniforms.uvTransform.value.setUvTransform(0, 0, 1, 1, elapsed * 0.2, 0.5, 0.5);
});
if (import.meta.hot) import.meta.hot.dispose(dispose);
本例采用 ShaderMaterial 常用的 GLSL 1 风格语法,由 Three.js 在 WebGL2 渲染路径中处理兼容前缀。不要在 ShaderMaterial 中重复声明它已提供的 position、normal、uv 或内建矩阵。RawShaderMaterial 则需要自行提供更多声明,不能直接照搬隐含前提。
运行后应看到条纹平面:条纹随 UV 矩阵旋转,光照根据观察空间位置和法线计算。移动相机不应让固定的世界光源错误地跟着相机绕平面旋转。高频硬边条纹可能产生锯齿;生产效果可用导数与 smoothstep 进行过滤,本例保留简单公式以突出数据流。
3. 插值发生在哪里
顶点着色器为三角形三个顶点分别输出 varying。默认平滑 varying 在光栅化阶段进行透视校正插值,而不是简单按世界空间距离取平均。
如果顶点法线长度都是 1,中间插值出的向量也通常不是单位长度,因此应在片元阶段 normalize(vNormalView)。对点光源,在片元阶段计算“光源位置减表面位置”能够获得随位置变化的方向;只在顶点上算亮度再插值,低面数网格可能明显失真。
顶点着色器负责给出几何位置 gl_Position,片元着色器负责颜色等输出。仅在片元着色器中修改颜色无法真正移动网格轮廓;仅在 JavaScript 中改变一个没连接到材质的 Vector3 也不会自动改变 GPU 图像。
4. 更新 uniform 与 attribute 的区别
uniforms.lightPositionView.value.copy(...) 保留同一个 Vector3 对象,正常渲染时由 Three.js 上传。无需每帧重新创建 ShaderMaterial,也不需为了普通 uniform 值变化设置 material.needsUpdate=true;后者通常用于影响程序定义的变化。
修改 attribute 数组则必须标记更新:
const positions = geometry.getAttribute('position');
positions.setZ(0, 0.2);
positions.needsUpdate = true;
geometry.computeVertexNormals();
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
法线和包围体是否需要重新计算取决于业务。CPU 改顶点但保留过时法线会造成错误光照;GPU 顶点位移不会自动更新 CPU 射线查询和包围体,需要另行处理。
5. Matrix3 的传递不需要手动转置
Three.js 的 Matrix3 使用与上传机制匹配的列主序 elements。直接把 Matrix3 放入 mat3 uniform 即可,不要为了“GLSL 矩阵看起来按列”再转置一次。自定义 Float32Array 时则必须明确排列方式。
练习(不附答案)
- 给本例增加控制条纹密度的 float uniform,比较改 uniform 与重建材质的差异。
- 刻意将世界光源位置直接传给观察空间光照公式,移动相机观察错误,再修复。
- 将顶点数量增加后,用 position attribute 制作波浪,解释包围体和法线更新职责。
- 删除片元法线 normalize,在弯曲表面上比较亮度,并解释差异来源。