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 问题,先用两个独立材质验证,再尝试共享优化;对阴影问题,同时测试方向光和点光源,避免只验证一个通道。

练习(不附答案)

  1. 故意写错一个 include 名,分别观察严格替换检查与最终 GLSL 编译能够发现哪类问题。
  2. 让 vertex 与 fragment 的 varying 类型不同,用最终源码与链接日志定位。
  3. 将一个扫描遮罩输出为灰度,检查坐标方向和阈值范围,再恢复原始光照。
  4. 为一个只在 normalMap 开启时失败的补丁设计最小复现,逐项排除宏条件与切线空间问题。