32 · 定位 GLSL 编译与视觉错误
1. 先区分四种失败
| 表现 | 先检查 | 常见原因 |
|---|---|---|
| 没报错,但效果完全没出现 | 锚点断言、实际网格材质、回调安装时机 | replace 没匹配;改错材质;首次编译后安装补丁却未通知更新 |
| 编译或链接失败 | GLSL 日志、声明、宏条件、阶段接口 | 未声明变量;varying 类型不一致;同名重复声明 |
| 可以渲染但效果不对 | 中间变量、空间、修改与消费顺序 | 在光照已消费参数后修改;把世界法线与观察空间方向点积 |
| 部分对象、灯光或版本下失败 | 程序变体、共享状态、锚点差异 | 缓存键遗漏结构选项;变量仅在某个宏开启时存在 |
不要一看到画面变黑就改 ShaderChunk。无灯光、相机背面、被裁剪、透明排序和材质没有挂到对象上,都可能产生类似症状。
2. 保留注入后的源码
在补丁全部执行之后记录源码,便于查看你的声明和执行代码是否真的出现:
// material 已由调用者创建,并已安装需要诊断的补丁。
const patch = material.onBeforeCompile;
const cacheKey = material.customProgramCacheKey();
material.onBeforeCompile = function (shader, renderer) {
patch.call(this, shader, renderer);
console.debug('patched vertex', shader.vertexShader);
console.debug('patched fragment', shader.fragmentShader);
console.debug('uniform names', Object.keys(shader.uniforms));
};
// 诊断包装不改变 GLSL,保留原来的固定缓存身份;动态结构键见正文说明。
material.customProgramCacheKey = () => cacheKey;
material.needsUpdate = true;
这段适合固定结构的临时诊断。若原缓存键根据结构配置动态变化,应记录并调用原自定义键函数,而不是冻结其当前字符串;但默认键内部又依赖当前 onBeforeCompile.toString,所以不能不加分析地包装。开发中最清晰的做法是让材质工厂从开始就提供显式、与日志包装无关的结构键。
日志来自 onBeforeCompile 参数,此时 include 通常尚未展开,而且 renderer 的全部前缀也未加入。它不能直接解释 GPU 日志中的所有行号。
3. 从编译错误取得真正提交的 GLSL
r185 提供 renderer.debug.onShaderError,可以读取 GPU 收到的完整源码:
function enableShaderDiagnostics(renderer) {
renderer.debug.checkShaderErrors = true;
const previous = renderer.debug.onShaderError;
const handler = (gl, program, vertexShader, fragmentShader) => {
console.error('program link log:', gl.getProgramInfoLog(program));
console.error('vertex log:', gl.getShaderInfoLog(vertexShader));
console.error('fragment log:', gl.getShaderInfoLog(fragmentShader));
console.error('vertex source:', gl.getShaderSource(vertexShader));
console.error('fragment source:', gl.getShaderSource(fragmentShader));
if (typeof previous === 'function') previous(gl, program, vertexShader, fragmentShader);
};
renderer.debug.onShaderError = handler;
return () => {
if (renderer.debug.onShaderError === handler) renderer.debug.onShaderError = previous;
};
}
这是出错时的诊断入口,不是每次编译都会触发。设置自定义处理器后,应自行输出所需日志,而不是假定默认日志仍完整出现。行号要与这里取得的最终源码对照,不能直接对照你手写的十行补丁。
4. 只为阅读展开 include
下面工具帮助查看递归模块,但不会模拟 renderer 的全部参数替换、宏前缀、GLSL 转换和驱动预处理。输出用于阅读,不应直接替换整个生产 shader。
import * as THREE from 'three';
function expandForReading(source, stack = []) {
return source.replace(/^[ \t]*#include +<([\w\d./]+)>/gm, (line, name) => {
if (stack.includes(name)) throw new Error(`Include cycle: ${name}`);
const chunk = THREE.ShaderChunk[name];
if (typeof chunk !== 'string') throw new Error(`Unknown chunk: ${name}`);
return `// begin ${name}\n${expandForReading(chunk, stack.concat(name))}\n// end ${name}`;
});
}
console.debug(expandForReading(THREE.ShaderLib.standard.fragmentShader));
通过 begin/end 注释可以定位变量从哪个模块进入。若版本存在特殊重命名或解析映射,这个简化工具需要相应扩展,最终行为仍以 WebGLProgram 的实际组装为准。
5. 将中间量变成可以观察的颜色
法线观察通常用 0.5 * normal + 0.5 将 [-1,1] 映射到 [0,1];UV 可以画 vec3(fract(vDemoUv), 0.0);遮罩可以输出灰度。先验证数据分布,再恢复材质照明。
function showNormalBeforeOutput(shader, replaceOnce) {
shader.fragmentShader = replaceOnce(
shader.fragmentShader,
'#include <opaque_fragment>',
`outgoingLight = 0.5 * normalize(normal) + 0.5;
#include <opaque_fragment>`,
'normal diagnostic'
);
}
本片段只适用于存在 normal 与 outgoingLight 的目标模板,且后续仍有色调映射、颜色转换和雾,因此它是方向的视觉诊断,不是精确数值读回。要比较数值需要关闭相关效果或使用专门的离屏数据输出,并明确格式和颜色空间。
如果法线在相机空间,相机旋转时调试颜色会变化,这是预期。若你以为它在世界空间,则暴露了空间理解错误。
6. 构造最小失败组合
先使用未蒙皮 SphereGeometry、单个 Standard 材质、无贴图、单方向光。确认基础补丁后,再逐个加入 map、normalMap、实例化、阴影、透明度和 Physical 扩展。每次只增加一个变量。
对程序缓存问题,创建两个源码不同但回调文本相同的材质,交替渲染并观察输出;对 uniform 问题,先用两个独立材质验证,再尝试共享优化;对阴影问题,同时测试方向光和点光源,避免只验证一个通道。
练习(不附答案)
- 故意写错一个 include 名,分别观察严格替换检查与最终 GLSL 编译能够发现哪类问题。
- 让 vertex 与 fragment 的 varying 类型不同,用最终源码与链接日志定位。
- 将一个扫描遮罩输出为灰度,检查坐标方向和阈值范围,再恢复原始光照。
- 为一个只在 normalMap 开启时失败的补丁设计最小复现,逐项排除宏条件与切线空间问题。