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 时则必须明确排列方式。

练习(不附答案)

  1. 给本例增加控制条纹密度的 float uniform,比较改 uniform 与重建材质的差异。
  2. 刻意将世界光源位置直接传给观察空间光照公式,移动相机观察错误,再修复。
  3. 将顶点数量增加后,用 position attribute 制作波浪,解释包围体和法线更新职责。
  4. 删除片元法线 normalize,在弯曲表面上比较亮度,并解释差异来源。