Games202学习过程
games202 一些工程问题杂七杂八记录,待整理。
shadowmap调试相关
浏览器环境没有 require
const { Vector3 } = require("three"); // ❌ 浏览器无 requirerequire 是 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 类型陷阱
rgbaDepth -= rgbaDepth.gbaa * bitMask; // ✓ gbaa 是 vec4,与 vec4 的 bitMask 匹配
// rgbaDepth.gba 是 vec3,乘 vec4 会编译失败加载缓存导致「改了代码看不到效果」
THREE.FileLoader 请求 .glsl 会被浏览器缓存。改 shader 后看不到变化,加 cache-busting:
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 → 全黑。需要防御:
if (!isFinite(mn[j]) || !isFinite(mx[j])) continue;以及 mat4.ortho 若 left==right、bottom==top、near==far,会 1/0 → Infinity/NaN。
调试工具(bias 滑杆 + 调试视图)
全局调试参数
// engine.js
var GUIParams = {
bias: 0.02,
debugMode: 0, // 0=正常 1=阴影mask 2=shadowmap深度 3=片元深度 4=UV
};材质里用 getter 实时读取
// PhongMaterial.js
'uBias': { type: '1f', get value() { return GUIParams.bias; } },
'uDebugMode': { type: '1i', get value() { return GUIParams.debugMode; } },这样每次渲染读 value 都拿到 GUI 最新值,不需要重建材质。
GUI 面板
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 里的调试分支
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
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(核心)
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)
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/24 | Mesh/MeshRender 增加 extraAttribs 通路 |
Mesh.js 参数与局部变量重名 | SyntaxError → 整个脚本失效 → 连锁 Mesh is not defined | 删冗余局部变量 |
| ps1 脚本含中文注释 | PowerShell 5.1 按 ANSI 读脚本 → 乱码报错 | 脚本用 ASCII |
shadowed 的 SH 振铃(不是 bug)
- 可见性
是 0/1 跳变的不连续函数,2 阶 SH 截断产生振荡 → 重建出负值 → clamp 成黑块。 - 这是低阶 SH 的固有伪影,C++ 的
prt.png预览同样存在。 - unshadowed 是平滑的
项,没有此问题 → 可作为报告对比内容。 - 高模黑块更小:顶点密度高时振铃更细碎。
校验与工具
- 对齐校验(node):
transport.txttoken 数 ==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 编码) | uGDiffuse | GetGBufferDiffuse |
[1] | 视空间深度 vDepth = gl_Position.w | uGDepth | GetGBufferDepth |
[2] | 世界空间法线(经法线贴图) | uGNormalWorld | GetGBufferNormalWorld |
[3] | 阴影可见性 V | uGShadow | GetGBufferuShadow |
[4] | 世界坐标位置 | uGPosWorld | GetGBufferPosWorld |
- 深度统一为视空间深度:
vDepth = gl_Position.w,对透视投影等于"相机前方的距离"(),越远越大。 vWorldToScreen = uProjectionMatrix * uViewMatrix,
三、
| 空间 | 用途 | 怎么来 |
|---|---|---|
世界空间 vec3 pos | 光线求交、法线、光照 | vPosWorld.xyz / vPosWorld.w |
屏幕空间 vec2 uv ∈ [0,1]² | 查 GBuffer | GetScreenCoordinate(pos) |
| 局部空间(法线为 +z 的切空间) | 采样方向 | SampleHemisphereCos 的返回值 |
// 世界坐标 → 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
随机数生成(每个像素一个确定性种子)
float InitRand(vec2 uv) { ... } // 用 gl_FragCoord 生成种子
float Rand1(inout float p) { ... } // 伪随机 [0,1),顺序产生
vec2 Rand2(inout float p) { ... } // 两个随机数半球采样
// 均匀采样: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θ / π
}漫反射的积分核是
LocalBasis:TBN
void LocalBasis(vec3 n, out vec3 b1, out vec3 b2) { ... }给定单位法线 n,构造两个与之正交的切向量 b1, b2,使 {b1, b2, n} 构成正交归一基。
局部方向 → 世界方向的两种等价写法:
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;直接光照的两个函数
// 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;
}直接光照公式:
RayMarch
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;
}- 必须起始偏移:
t从 0 开始的话,起点就是片元本身,diff≈0会立即"命中自己"。 - 视空间深度符号:
rayDepth - sceneDepth > 0表示"光线比表面更远",与vDepth约定一致。
monte carlo
无解析解 → 蒙特卡洛:采样
对照代码的一个样本:
| 符号 | 代码 | 含义 |
|---|---|---|
SampleHemisphereCos(s, pdf) | 采一个方向 | |
pdf = dirLocal.z * INV_PI | 采样概率密度 | |
Li | 命中点 q 出射的光 | |
albedo / M_PI | 当前点的 BSDF | |
dirLocal.z | 方向与法线夹角余弦 |
result += Li * (albedo / M_PI) * dirLocal.z / pdf; // = Li·fr·cosθ / pdf
...
return result / float(SAMPLE_NUM); // = (1/N) Σ余弦采样的化简:
所以理论上每项等于
可见性 V 藏在哪里:渲染方程里看似没有显式
| 可见性 | 承担者 |
|---|---|
| 采样方向 | RayMarch 命中 / 未命中 |
| 命中点 q 对方向光是否在阴影? | EvalDirectionalLight 里的 GetGBufferuShadow |
OneBounceIndirectLight:一次反射
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:两次反射(非递归)
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,但代价,
Lo
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
}两次反射是
每像素的 RayMarch 次数:
SAMPLE_NUM | RayMarch 次数/像素 |
|---|---|
| 3 | 3 + 9 = 12 |
| 5 | 5 + 25 = 30 |
| 10 | 10 + 100 = 110 |
噪点
蒙特卡洛误差 SAMPLE_NUM 小时噪点严重。作业的标准结果本身就有噪点(去噪是作业四/五内容,这里不需要)。
减轻手段:调大 SAMPLE_NUM(5~8 较平衡);时域累积(超纲);分层采样(复杂)。
演示

