48 · 从现有项目迁移:逐项检查一个真实 CSM 示例
本章使用项目现有的 main.js、vert.vert、frag.frag 作为代码阅读对象。这里提供独立的改进教学版本,不改动现有展示页。
完整新示例是 csm-migration.js。它保留“对环面结施加 X 方向位移”的思路,同时处理帧率、法线、非退化条件、两类阴影材质和可见性边界。
1. 先识别现有代码中哪些已经正确
现有页面使用 /vanilla 导入 CSM,把 MeshStandardMaterial 作为基础材质,通过 Vite 的 ?raw 导入 GLSL 字符串,再用 uniform 驱动动画。这是适合本项目的组织方式,无需更换为 React 入口。
顶点着色器向 csm_Position 写入局部变形后的位置,没有提前乘投影矩阵,这也正确。片元着色器没有写入输出时,基础材质会继续完成正常着色,不需要为了“输出颜色”强行写 csm_FragColor。
因此迁移应保留已正确的边界,只针对具体问题调整,而不是把 CSM 当成另一个需要全面重写的渲染器。
2. 时间更新要与刷新率分开
现有入口每帧在 render 之后执行 uTime += 0.01。高刷新率屏幕每秒执行更多次,动画会更快;更新放在 render 后也意味着新值等下一帧才使用。
// 使用本系列 runtime 的渲染前回调;elapsed 为秒。
onFrame((dt, elapsed) => {
uniforms.uDemoTime.value = elapsed;
});
也可以自己用帧时间累计 time += dt。重点是统一单位和更新时机,不能一部分代码用帧数、一部分用秒。runtime 的 dt 有上限,elapsed 是模拟时间;需要真实墙上时钟的业务应采用相应策略。
现有 uPulse 在当前 GLSL 中没有被使用,更新它不会产生可见效果。需要脉冲时应明确把它用于振幅、频率或颜色,否则应从教学状态中去掉,减少无意义参数。
3. 形变法线需要逆转置,而不是直接乘雅可比
现有变形可以写为:
它的雅可比是:
雅可比把原切向量变成新切向量;法线必须保持与切线垂直,因此在非奇异条件下:
当前顶点文件用有限差分构造 J,但随后使用 normalize(J * normal)。它一般不能保持垂直关系。有限差分不是问题,问题是把适合切向量的变换直接应用到了法线。
可以用一个简单数值对照:原切线 t=(1,1,0),原法线 n=(1,-1,0),J=diag(2,1,1)。新切线是 (2,1,0),错误法线 (2,-1,0) 与它点积为 3;正确法线 (0.5,-1,0) 与它点积为 0。
对于本章的特定对角雅可比,直接除以 s 比每顶点构造通用 inverse 更清晰:
float demoPhase = position.x * uDemoFrequency + uDemoTime * 2.0;
float demoStretchX = 1.0 + uDemoAmplitude * uDemoFrequency * cos(demoPhase);
csm_Position = position;
csm_Position.x += uDemoAmplitude * sin(demoPhase);
csm_Normal = normalize(vec3(normal.x / demoStretchX, normal.y, normal.z));
这是特定形变的推导,不是所有波浪通用的法线公式。普通 XZ 水面是高度场,使用 (-∂h/∂x,1,-∂h/∂z);复杂弯曲可根据局部切线差分或完整 J 求解,见第 39 章和前面的法线章节。
4. 必须先排除局部塌陷
原例 A=0.6、k=3,所以 Ak=1.8,s 在 [-0.8,2.8] 变化,会经过零。在这些位置,变形局部压扁,逆矩阵不存在,甚至出现折叠。添加一个很小的 epsilon 到分母无法恢复真实几何关系。
本章改进示例选 A=0.12、k=3,Ak=0.36,因此 s 至少为 0.64,保证这条单轴形变始终局部可逆且不翻转。若业务需要强烈折叠,应该显式设计自交、双面显示、面绕序和碰撞行为,不能只要求法线“看起来不闪”。
更一般地,应限制 abs(A * k) < 1 并留出余量,使 s >= 1 - abs(A * k) > 0。如果增加 GUI 调参,需同时限制频率与幅度;只限制幅度不足以保证可逆。
这个限制与 CSM 无关。手动 onBeforeCompile、ShaderMaterial、CPU 顶点变形都会遇到同样的数学问题。
5. 阴影使用相同形变,不复制无关输出
主材质的 csm_Position 变形不会自动传给阴影材质。新示例创建 CSM MeshDepthMaterial 和 CSM MeshDistanceMaterial,使用相同时间、振幅、频率容器,以及同一条位置表达式。
mesh.customDepthMaterial = new CustomShaderMaterial({
baseMaterial: THREE.MeshDepthMaterial,
vertexShader: deformationSource(false),
uniforms
});
mesh.customDistanceMaterial = new CustomShaderMaterial({
baseMaterial: THREE.MeshDistanceMaterial,
vertexShader: deformationSource(false),
uniforms
});
deformationSource(false) 只生成 csm_Position 计算,不携带主材质才需要的法线或表面参数输出。CSM 按字符串关键字检查可用性,把一个包含 csm_Roughness 的完整表面 shader 放到 Depth 材质,再用 #ifdef 围起来,不一定能避开 JavaScript 侧检查。
配套脚本实际只创建方向光,所以运行它能验证 Depth 路径;Distance 材质虽已配置,还需要增加开启 castShadow 的点光源才会真正执行。第 47 章的完整案例同时创建两种光源,可用于理解两条路径。
本例没有透明裁切,不使用 csm_DepthAlpha。需要溶解遮罩时按第 47 章同步 discard,不把 alpha 输出误当成深度写入开关。
6. 边界与拾取仍在 CPU 一侧
由于 X 最大位移为 A,原局部包围盒的 X 两侧各扩展 A,就能覆盖所有时间的这个形变:
geometry.computeBoundingBox();
geometry.boundingBox.expandByVector(new THREE.Vector3(maxAmplitude, 0, 0));
geometry.boundingSphere = geometry.boundingBox.getBoundingSphere(new THREE.Sphere());
如果后来让振幅超过 maxAmplitude,应重新计算边界或限制输入。更大的包围体只能避免错误剔除,不能让 Raycaster 自动求交到 GPU 变形后的三角形。
对于一个静态截图的测量工具,可以在 CPU 应用同样形变再查询;连续交互可以使用解析或近似碰撞表面。不要简单读取 material.uniforms 就以为几何体顶点已被更新。
7. 迁移检查表
| 检查项 | 需要成立的条件 |
|---|---|
| 导入入口 | 原生 Three.js 项目使用 /vanilla |
| 位置输出 | csm_Position 写局部、未投影位置;Raw 使用明确裁剪坐标 |
| 法线 | 基于真实形变推导,输出空间正确,处理退化 |
| 颜色输出 | 明确 DiffuseColor 与 FragColor 的照明和输出差别 |
| 状态 | 更新稳定 uniform 容器,不每帧 update 或 new |
| 阴影 | Depth、Distance 共享位置/裁切,输出编码保留 |
| 可见性与拾取 | CPU 边界覆盖形变;拾取方案另行处理 |
| 版本 | 根据本地 6.4.0 + r185 核对特殊变量,不只看类型表 |
| 释放 | 主材质、阴影材质和独立纹理由明确所有者释放 |
使用同一动画时间对比原位置和改进位置,暂停后观察切线与法线,再切换方向光/点光源并把对象移到视口边缘。这样才能区分数学正确性、阴影一致性和剔除问题。
练习(不附答案)
- 用数值差分验证本章 J 的对角元素,再比较正确法线与变换后切线的点积。
- 把振幅渐增到
Ak=1附近,记录为什么归一化无法解决局部退化。 - 在新示例中加入颜色脉冲,使用独立 uniform,并决定它应修改 DiffuseColor 还是 Emissive。
- 将主材质和两种阴影材质封装到工厂,设计克隆与资源释放,不依赖 CSM.clone 的隐式共享。