Games202学习过程


games202 一些工程问题杂七杂八记录,待整理。

shadowmap调试相关 ​

浏览器环境没有 require ​

js
const { Vector3 } = require("three");   // ❌ 浏览器无 require

require 是 Node.js 的 CommonJS 语法,浏览器执行到会抛 Uncaught ReferenceError: require is not defined,整个文件中断,类无法定义,随后 DirectionalLight is not defined。本项目用 <script> 直接引用的全局脚本风格,应直接删掉 require。

shader 编译是静默失败的 ​

WebGL 里 shader 编译/链接失败不抛 JS 异常。Shader.js 里 linkShader 用了未定义的 abort(...),一旦失败会抛 ReferenceError 且被 promise 吞掉,最终 renderer.meshes 空 → 全黑。

排查要点:

  • 在 compileShader/linkShader 打印 gl.getShaderInfoLog / gl.getProgramInfoLog。
  • 常见编译错误:引用未定义变量(如 shadowCoord)、vec3(vec3表达式) 非法构造、swizzle 分量数不匹配(vec3.gba * vec4)。

GLSL 类型陷阱 ​

glsl
rgbaDepth -= rgbaDepth.gbaa * bitMask;  // ✓ gbaa 是 vec4,与 vec4 的 bitMask 匹配
// rgbaDepth.gba 是 vec3,乘 vec4 会编译失败

加载缓存导致「改了代码看不到效果」 ​

THREE.FileLoader 请求 .glsl 会被浏览器缓存。改 shader 后看不到变化,加 cache-busting:

js
loader.load(filename + '?t=' + Date.now(), (data) => { ... });

删方法前先搜引用 ​

DirectionalLight.js 里删了 addToLightViewAABB,但 loadOBJ.js 还在调用 → TypeError → 异步加载中断 → 全黑只剩点光源。

固定动作:删代码前先全局搜索方法名确认无引用;删后看控制台有没有 TypeError: xxx is not a function。

AABB 污染(NaN / Infinity) ​

自动包围盒时,若某个 mesh 的顶点数据产生 NaN,会通过 Math.min/max 传染整个总 AABB,最终 ortho 收到 NaN → 全黑。需要防御:

js
if (!isFinite(mn[j]) || !isFinite(mx[j])) continue;

以及 mat4.ortho 若 left==right、bottom==top、near==far,会 1/0 → Infinity/NaN。


调试工具(bias 滑杆 + 调试视图) ​

全局调试参数 ​

js
// engine.js
var GUIParams = {
  bias: 0.02,
  debugMode: 0, // 0=正常 1=阴影mask 2=shadowmap深度 3=片元深度 4=UV
};

材质里用 getter 实时读取 ​

js
// PhongMaterial.js
'uBias': { type: '1f', get value() { return GUIParams.bias; } },
'uDebugMode': { type: '1i', get value() { return GUIParams.debugMode; } },

这样每次渲染读 value 都拿到 GUI 最新值,不需要重建材质。

GUI 面板 ​

js
const panel = gui.addFolder('Shadow Debug');
panel.add(GUIParams, 'bias', 0.0, 0.05, 0.0005).name('Bias').listen();
panel.add(GUIParams, 'debugMode', {
  'Normal': 0, 'ShadowMask': 1, 'ShadowMapDepth': 2, 'FragDepth': 3, 'UV': 4,
}).name('Debug View').listen();
panel.open();

shader 里的调试分支 ​

glsl
if (uDebugMode == 1) {
  gl_FragColor = vec4(vec3(visibility), 1.0);        // 阴影 mask
} else if (uDebugMode == 2) {
  float z = unpack(texture2D(uShadowMap, shadowCoord.xy));
  gl_FragColor = vec4(vec3(z), 1.0);                 // shadow map 深度
} else if (uDebugMode == 3) {
  gl_FragColor = vec4(vec3(shadowCoord.z), 1.0);     // 当前片元光空间深度
} else if (uDebugMode == 4) {
  gl_FragColor = vec4(shadowCoord.xy, 0.0, 1.0);     // UV 分布
} else {
  gl_FragColor = vec4(phongColor * visibility, 1.0); // 正常渲染
}

各调试视图该长什么样 ​

视图正确时异常说明
ShadowMask物体脚下/背光处黑块bias 调大调小看黑边收放
ShadowMapDepth从光源看过去,近黑远白深度方向/写入有问题
FragDepth与 ShadowMapDepth 几乎一样明显不同 → MVP 或 UV 错位
UV红绿渐变平滑直接验证 UV 映射

(我感觉自己如果讲的话就很像应付上课小组作业念ppt(...表达困难户,人类早期语言康复训练珍贵录像...

关键代码改动汇总(git diff 摘要) ​

phongVertex.glsl ​

glsl
varying highp vec4 vPositionFromLight;

void main(void) {
  vFragPos = (uModelMatrix * vec4(aVertexPosition, 1.0)).xyz;
  vNormal = (uModelMatrix * vec4(aNormalPosition, 0.0)).xyz;
  gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertexPosition, 1.0);
  vTextureCoord = aTextureCoord;
  vPositionFromLight = uLightMVP * vec4(aVertexPosition, 1.0);
}

phongFragment.glsl(核心) ​

glsl
uniform sampler2D uShadowMap;
uniform float uBias;
uniform int uDebugMode;
varying vec4 vPositionFromLight;

float unpack(vec4 rgbaDepth) {
  const vec4 bitShift = vec4(1.0, 1.0/256.0, 1.0/(256.0*256.0), 1.0/(256.0*256.0*256.0));
  return dot(rgbaDepth, bitShift);
}

float useShadowMap(sampler2D shadowMap, vec4 shadowCoord){
  float z = unpack(texture2D(shadowMap, shadowCoord.xy));
  return step(shadowCoord.z - uBias, z);
}

void main(void) {
  float visibility;
  vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w * 0.5 + 0.5;
  visibility = useShadowMap(uShadowMap, vec4(shadowCoord, 1.0));
  vec3 phongColor = blinnPhong();
  // ... debugMode 分支 ...
  gl_FragColor = vec4(phongColor * visibility, 1.0);
}

loadShader.js(cache-busting) ​

js
loader.load(filename + '?t=' + Date.now(), (data) => { ... });

作业二 prt相关 ​

dcc obj对接问题

核心假设 ​

整个 PRT 管线建立在一条隐含假设上:

一个 OBJ 文件 = 一个 mesh = 一整份按文件顺序平铺的顶点数据(transport.txt 就是按"面-顶点顺序"写的平铺数组,长度 = 3×面数×9)。

任何破坏该假设的模型特性,都会导致顶点与系数错位,表现为"三角形面分明、局部黑块"。

错位来源(模型文件特性) ​

模型特性后果修复
四边形/多边形面Nori 与 three.js 三角化方式不同 → 面序错位三角化(triangulate_obj.js,扇形)
usemtl 多材质three.js 按材质拆多个子 mesh,每个都拿整份平铺数组从头读删除 usemtl 行
o/g 多对象three.js 按对象拆多个子 mesh,同上删除 o/g 行
scenes 与 assets 文件版本不同几何与 transport 来自不同模型两端用同一份文件

bunny 是单对象、单材质、全三角形的干净 OBJ,所以官方流程用 bunny 从未暴露这些问题。

WebGL 与工程坑 ​

问题后果修复
drawElements + Uint16Array 索引顶点数 > 65535 时索引回绕 → 三角形乱画改 gl.drawArrays(TRIANGLES, 0, count)(索引本来就是 0..N-1)
OBJ/MTL/txt 加载无 cache-busting浏览器喂旧数据URL 加 ?t=Date.now()
transport 解析 off-by-one丢最后一个系数跳过首行(顶点数)后取完所有 token
mat3 attribute 绑定需 3 个连续 location、stride=36、offset 0/12/24Mesh/MeshRender 增加 extraAttribs 通路
Mesh.js 参数与局部变量重名SyntaxError → 整个脚本失效 → 连锁 Mesh is not defined删冗余局部变量
ps1 脚本含中文注释PowerShell 5.1 按 ANSI 读脚本 → 乱码报错脚本用 ASCII

shadowed 的 SH 振铃(不是 bug) ​

  • 可见性 V(ω) 是 0/1 跳变的不连续函数,2 阶 SH 截断产生振荡 → 重建出负值 → clamp 成黑块。
  • 这是低阶 SH 的固有伪影,C++ 的 prt.png 预览同样存在。
  • unshadowed 是平滑的 cos 项,没有此问题 → 可作为报告对比内容。
  • 高模黑块更小:顶点密度高时振铃更细碎。

校验与工具 ​

  • 对齐校验(node):transport.txt token 数 == 1 + 9 × 3 × 面数。
  • 同步脚本:Assignment2/sync_prt.ps1(scenes/cubemap → assets/cubemap,三个环境贴图)。
  • 标准流程:改 prt.xml → 跑 nori → 跑 sync → Ctrl+Shift+R。
  • 换任何模型前:三角化 + 删 o/g/usemtl + 两端同文件。

作业三 one bounce ​

Outline ​

最终输出 = 直接光 + 两次反射的间接光。


GBuffer 各通道含义(第 2 次着色的产物) ​

gbufferFragment.glsl 用 WEBGL_draw_buffers 写出 5 张纹理:

gl_FragData内容对应 sampler读取函数
[0]反照率 kd(gamma 编码)uGDiffuseGetGBufferDiffuse
[1]视空间深度 vDepth = gl_Position.wuGDepthGetGBufferDepth
[2]世界空间法线(经法线贴图)uGNormalWorldGetGBufferNormalWorld
[3]阴影可见性 VuGShadowGetGBufferuShadow
[4]世界坐标位置uGPosWorldGetGBufferPosWorld
  • 深度统一为视空间深度:vDepth = gl_Position.w,对透视投影等于"相机前方的距离"(−zview),越远越大。
  • vWorldToScreen = uProjectionMatrix * uViewMatrix,

三、 ​

空间用途怎么来
世界空间 vec3 pos光线求交、法线、光照vPosWorld.xyz / vPosWorld.w
屏幕空间 vec2 uv ∈ [0,1]²查 GBufferGetScreenCoordinate(pos)
局部空间(法线为 +z 的切空间)采样方向SampleHemisphereCos 的返回值
glsl
// 世界坐标 → NDC → [0,1] 屏幕坐标
vec2 GetScreenCoordinate(vec3 posWorld) {
  return Project(vWorldToScreen * vec4(posWorld, 1.0)).xy * 0.5 + 0.5;
}

// 世界坐标 → 视空间深度(与 GBuffer 存的 vDepth 同约定)
float GetDepth(vec3 posWorld) {
  return (vWorldToScreen * vec4(posWorld, 1.0)).w;
}

ssrFragment.glsl ​

随机数生成(每个像素一个确定性种子) ​

glsl
float InitRand(vec2 uv) { ... }   // 用 gl_FragCoord 生成种子
float Rand1(inout float p) { ... } // 伪随机 [0,1),顺序产生
vec2  Rand2(inout float p) { ... } // 两个随机数

半球采样 ​

glsl
// 均匀采样:pdf = 1/(2π)
vec3 SampleHemisphereUniform(inout float s, out float pdf) { ... }

// 余弦加权采样:pdf = cosθ / π
vec3 SampleHemisphereCos(inout float s, out float pdf) {
  vec2 uv = Rand2(s);
  float z = sqrt(1.0 - uv.x);          // z = cosθ
  float phi = uv.y * TWO_PI;
  float sinTheta = sqrt(uv.x);
  return vec3(sinTheta * cos(phi), sinTheta * sin(phi), z);
  pdf = z * INV_PI;                     // cosθ / π
}

漫反射的积分核是 frcos⁡θ,

LocalBasis:TBN ​

glsl
void LocalBasis(vec3 n, out vec3 b1, out vec3 b2) { ... }

给定单位法线 n,构造两个与之正交的切向量 b1, b2,使 {b1, b2, n} 构成正交归一基。

局部方向 → 世界方向的两种等价写法:

glsl
mat3 TBN = mat3(b1, b2, normal);   // GLSL 按列构造
vec3 dir = TBN * dirLocal;         // = dirLocal.x*b1 + dirLocal.y*b2 + dirLocal.z*normal

//vec3 dir = dirLocal.x * b1 + dirLocal.y * b2 + dirLocal.z * normal;

直接光照的两个函数 ​

glsl
// Lambert BSDF:fr = ρ / π(与 wi、wo 无关)
vec3 EvalDiffuse(vec3 wi, vec3 wo, vec2 uv) {
  vec3 albedo = GetGBufferDiffuse(uv);   // 内部 pow(diffuse, 2.2) 解码回线性
  return albedo / M_PI;
}

// 方向光入射辐射度 × 阴影可见性
vec3 EvalDirectionalLight(vec2 uv) {
  float v = GetGBufferuShadow(uv);       // 第 2 次着色已算好阴影
  return uLightRadiance * v;
}

直接光照公式:

Lo=ρπ⏟EvalDiffuse×Llight⋅V⏟EvalDirectionalLight×max(N⋅L, 0)⏟main

RayMarch ​

glsl
bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
  const float step = 0.05;
  float t = 0.01;                       // 起始偏移,避免与自身表面相交
  for (int i = 0; i < 200; ++i) {
    vec3 p = ori + dir * t;
    vec2 uv = GetScreenCoordinate(p);
    if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0)
      return false;                     // 走出屏幕,无交
    float sceneDepth = GetGBufferDepth(uv);  // 该像素表面深度
    float rayDepth = GetDepth(p);            // 光线上该点深度
    float diff = rayDepth - sceneDepth;
    if (diff > 0.0 && diff < 0.5) {          // 光线已越过表面
      hitPos = p;
      return true;
    }
    t += step;
  }
  return false;
}
  1. 必须起始偏移:t 从 0 开始的话,起点就是片元本身,diff≈0 会立即"命中自己"。
  2. 视空间深度符号:rayDepth - sceneDepth > 0 表示"光线比表面更远",与 vDepth 约定一致。

monte carlo ​

Lo(p,ωo)=∫ΩLi(p,ωi)frcos⁡θidωi

无解析解 → 蒙特卡洛:采样 N 个方向取平均

≈1N∑k=1NLi(ωk)frcos⁡θkp(ωk)

对照代码的一个样本:

符号代码含义
ωkSampleHemisphereCos(s, pdf)采一个方向
p(ωk)pdf = dirLocal.z * INV_PI采样概率密度
Li(ωk)Li命中点 q 出射的光
fralbedo / M_PI当前点的 BSDF
cos⁡θkdirLocal.z方向与法线夹角余弦
glsl
result += Li * (albedo / M_PI) * dirLocal.z / pdf;  // = Li·fr·cosθ / pdf
...
return result / float(SAMPLE_NUM);                   // = (1/N) Σ

余弦采样的化简:pdf=cos⁡θ/π,于是

frcos⁡θpdf=(ρ/π)cos⁡θcos⁡θ/π=ρ

所以理论上每项等于 Li⋅ρ,代码里保留显式形式是为了可读、防错。

可见性 V 藏在哪里:渲染方程里看似没有显式 V,它被拆成两层——

可见性承担者
采样方向 ωk 被物体挡住?RayMarch 命中 / 未命中
命中点 q 对方向光是否在阴影?EvalDirectionalLight 里的 GetGBufferuShadow

OneBounceIndirectLight:一次反射 ​

glsl
vec3 OneBounceIndirectLight(vec3 pos, vec3 normal, vec3 albedo, inout float s) {
  vec3 b1, b2;
  LocalBasis(normal, b1, b2);
  vec3 result = vec3(0.0);

  for (int i = 0; i < SAMPLE_NUM; ++i) {
    float pdf;
    vec3 dirLocal = SampleHemisphereCos(s, pdf);
    mat3 TBN = mat3(b1, b2, normal);
    vec3 dir = TBN * dirLocal;                 // 局部 → 世界

    vec3 hitPos;
    if (RayMarch(pos, dir, hitPos)) {
      vec2 hitUV = GetScreenCoordinate(hitPos);
      vec3 hitNormal = normalize(GetGBufferNormalWorld(hitUV));

      // 命中点 q 朝 -dir 出射的直接光
      float NoL = max(dot(hitNormal, normalize(uLightDir)), 0.0);
      vec3 Li = EvalDiffuse(dir, -dir, hitUV) * EvalDirectionalLight(hitUV) * NoL;

      result += Li * (albedo / M_PI) * dirLocal.z / pdf;
    }
  }
  return result / float(SAMPLE_NUM);
}

流程:p 处采样方向 → RayMarch 找到 q → 取 q 的直接光作为入射 → 按蒙特卡洛累加。

TwoBounceIndirectLight:两次反射(非递归) ​

glsl
vec3 TwoBounceIndirectLight(vec3 pos, vec3 normal, vec3 albedo, inout float s) {
  // 结构与 OneBounce 相同,唯一区别是命中点 q 的入射光:
  //   Li = Ld(直接光) + L_indirect(q 处的一次反射)
  ...
  vec3 Ld = EvalDiffuse(dir, -dir, hitUV) * EvalDirectionalLight(hitUV) * NoL;
  vec3 L_indirect = OneBounceIndirectLight(hitPos, hitNormal, hitAlbedo, s);
  vec3 Li = Ld + L_indirect;
  result += Li * (albedo / M_PI) * dirLocal.z / pdf;
  ...
}

WebGL1 用的是 GLSL ES 1.00,禁止函数递归(自己调自己、互相调都会编译失败)

"A 调 B、B 调 C"是固定、有限的调用链,编译器允许;"自己调自己"才是递归,被禁止。想加第 3 跳就再手写一个 ThreeBounceIndirectLight,但代价 O(N3),

Lo ​

glsl
void main() {
  float s = InitRand(gl_FragCoord.xy);

  vec3 pos = vPosWorld.xyz / vPosWorld.w;   // 当前片元世界坐标
  vec2 uv = GetScreenCoordinate(pos);
  vec3 normal = normalize(GetGBufferNormalWorld(uv));
  vec3 albedo = GetGBufferDiffuse(uv);

  // 直接光
  vec3 LD = normalize(uLightDir);
  float cosTerm = max(dot(normal, LD), 0.0);
  vec3 direct = EvalDiffuse(LD, LD, uv) * EvalDirectionalLight(uv) * cosTerm;

  // 间接光(两次反射)
  vec3 indirect = TwoBounceIndirectLight(pos, normal, albedo, s);

  vec3 color = direct + indirect;
  gl_FragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);  // 最后才 gamma
}

两次反射是 O(N2) ​

每像素的 RayMarch 次数:

次数=N⏟外圈+N×N⏟每个命中点再内圈 N 次
SAMPLE_NUMRayMarch 次数/像素
33 + 9 = 12
55 + 25 = 30
1010 + 100 = 110

噪点 ​

蒙特卡洛误差 ∝1/N,收敛慢。两次反射的方差叠加,所以 SAMPLE_NUM 小时噪点严重。作业的标准结果本身就有噪点(去噪是作业四/五内容,这里不需要)。

减轻手段:调大 SAMPLE_NUM(5~8 较平衡);时域累积(超纲);分层采样(复杂)。


演示 ​

2026-09-2811-25-49

2026-09-2811-25-02