KisekiEngine 渲染 Bug 排查实录:从“立方体全亮、地面纹理消失”到 7/7 自动化测试全通过
KisekiEngine 渲染 Bug 排查实录:从“立方体全亮、地面纹理消失”到 7/7 自动化测试全通过
背景
KisekiEngine 是一个基于 Metal 的 3D 图形引擎,实现了 Blinn-Phong 点光源光照、纹理贴图、OBJ 模型加载和多物体场景渲染。在完成所有功能后,用户报告了两个渲染问题:
- 立方体全亮:没有明暗面,整个立方体看起来亮度均匀
- 地面纹理消失:设置了 ground.png 纹理但地面显示为纯色
第一轮尝试:盲修(失败)
假设 1:uniform 布局不对齐
怀疑 C++ 端 simd_float3(16 字节)和 Metal shader 端 float3(12 字节)的对齐差异导致数据错位。
操作:将所有 simd_float3 + padding 改为 simd_float4 打包。
结果:编译通过但问题未解决。事后验证发现结构体偏移量实际上是一致的(offset 80 处 hasTexture),这个修改虽然没直接修到 bug,但让布局更安全了。
假设 2:无纹理物体残留纹理
怀疑光源球体(无纹理)渲染后残留了之前的纹理绑定。
操作:对无纹理物体显式调用 [encoder setFragmentTexture:nil atIndex:0]。
结果:问题仍未解决。
教训
盲修效率极低。每次修改都是猜测,无法验证是否真正触及根因。
转折点:先做验证工具,而不是继续猜
真正改变排查策略的一句要求是:
先不要解决问题,先设计一套测试工具验证修复到底正不正确。
这一步很关键。对于图形渲染问题,仅凭肉眼看窗口往往不够,必须建立一套可以重复执行的观测和验证机制。
第二轮:构建诊断系统
1)Debug Mode Shader
利用 Metal function constants 实现了多种 shader 可视化模式,用来分别观察不同信号:
| 模式 | 显示内容 | 诊断目的 |
|---|---|---|
| 0 | 正常渲染 | 默认 |
| 1 | 世界法线 RGB | 检查法线方向 |
| 2 | NdotL 灰度 | 检查光照计算 |
| 4 | hasTexture 标志 | 绿=有纹理,红=无 |
| 6 | 原始纹理采样 | 洋红=无纹理绑定 |
通过数字键 0-9 可以运行时切换。
2)帧回读能力 captureFrame()
在 MetalRenderer 中增加离屏渲染接口:
std::vector<PixelRGBA> captureFrame(Scene& scene, float dt, float time, int width, int height);
核心实现包括:
- 创建
MTLStorageModeShared离屏纹理,保证 CPU 可读 - 渲染到离屏纹理而不是 drawable
- 通过
[cmdBuffer waitUntilCompleted]等待 GPU 完成 - 用
[texture getBytes:...]读回 BGRA 数据并转换为 RGBA
3)RenderTest 自动化测试框架
新增 7 个像素级测试,通过 ./build/KisekiEngine --test 执行:
testBackgroundColor:验证 clear colortestGroundHasTexture:mode 4 下地面应为绿色testGroundTextureContent:mode 6 下地面不应是洋红testGroundNormals:mode 1 下地面法线 Y 分量应为 1testGroundNdotL:mode 2 下地面应受到光照testCubeHasNoTexture:mode 4 下立方体应为红色testCubeNdotL:mode 2 下立方体应有明暗变化
4)自适应采样点校准
首次运行 mode 4(hasTexture)后,扫描绿色像素(地面)和红色像素(立方体)自动定位采样点,避免把测试写死在固定屏幕坐标上。
第三轮:测试驱动排查根因
现象 1:所有采样像素都是 (26,26,26)
[captureFrame] 800x600, non-clear pixels: 25700 / 480000
[captureFrame] bounding box: x=[581..799] y=[0..118]
这说明场景并不是完全没画出来:确实有物体被渲染了,但它们全都缩在右上角,和预期不符。
进一步结合 NDC→screen 映射分析后发现,世界原点理论上应该落在更靠近画面中部的位置,因此这里明显存在几何或剔除层面的问题。
PPM 帧 dump:真正看见问题
随后把帧缓冲导出为 PPM,再转 PNG 直接查看。
在 mode1_normals 图里,能看到的法线主要是 (-1,0,0) 和 (0,-1,0),也就是立方体的左面和底面。但相机位置在 (0,1.5,5),正常应该优先看到的是前面和顶面。
这一步几乎直接锁定了方向:问题和面剔除 / 绕序高度相关。
Bug 1:面剔除方向错误
根因:setFrontFacingWinding:MTLWindingCounterClockwise 与地面平面的顶点索引绕序在屏幕空间中不匹配。
立方体前面顶点 indices {0,1,2} 投影后是 CCW,因此 MTLWindingCounterClockwise 本身没问题;但地面平面的 {0,1,2} 投影后是 CW,于是被错误剔除了。
修复方式:保持 front face 规则不变,只调整地面索引顺序:
{0,2,1, 0,3,2}
这样它在屏幕空间中的绕序就与当前 front-facing 设定一致。
Bug 2:uniform buffer 竞争
修复面剔除后,再观察 mode4:地面终于显示为绿色(hasTexture=1),立方体也显示为红色。但 CPU 侧日志虽然已经打印出正确对象数据,GPU 实际表现仍出现不一致。
根因:所有物体共用同一个 MTLBuffer(vertex / fragment uniform buffer),每次 draw call 都通过 memcpy 覆盖 buffer 内容。但 Metal 的 command buffer 是延迟执行的:编码阶段只是记录“这个 draw call 之后要去读某个 buffer 地址”,等 GPU 真执行时,那个地址里的内容可能早就被后续 draw call 改掉了。
也就是说,多个 draw call 其实在争用同一块 uniform 数据,GPU 最后常常只看到“最后一次写入”的那份值。
修复方式:把这套逻辑从“共享 buffer + memcpy”改成“每次 draw 内联提交数据”:
// Before (BUG)
memcpy([ub contents], &uniforms, sizeof(Uniforms));
[encoder setVertexBuffer:ub offset:0 atIndex:1];
// After (FIX)
[encoder setVertexBytes:&uniforms length:sizeof(Uniforms) atIndex:1];
fragment uniforms 同理改为 setFragmentBytes。
对于这类小于 4KB 的 per-draw 数据,这种方式简单且安全。
最终结果
========================================
[RenderTest] Running 7 pixel tests...
========================================
[Test 1] testBackgroundColor: PASS (26, 26, 26)
[Test 2] testGroundHasTexture: PASS (0, 255, 0) = hasTexture=1
[Test 3] testGroundTextureContent: PASS (200, 121, 60) = 纹理内容
[Test 4] testGroundNormals: PASS (128, 255, 128) = normal (0,1,0)
[Test 5] testGroundNdotL: PASS (166, 166, 166) = 有光照
[Test 6] testCubeHasNoTexture: PASS (255, 0, 0) = hasTexture=0
[Test 7] testCubeNdotL: PASS NdotL > 0 = 有明暗面
[RenderTest] Result: 7/7 passed ✓ ALL PASS
========================================
最终 7/7 全部通过,两个原始渲染问题都被稳定复现、定位并验证修复。
关键经验
1. 不要盲修,先建观测能力
一开始最大的失误不是不会修,而是过早开始“猜”。当你还没有可靠观测手段时,修改几乎都只是碰运气。
2. 像素级测试是图形引擎非常硬的验证方式
这套方法的价值在于形成闭环:
- debug mode 分离信号
- captureFrame 读取 GPU 的真实输出
- 自动化像素对比替代人眼判断
这样一来,修复不再只是“看起来好了”,而是“可以被测试证明好了”。
3. Metal command buffer 的延迟执行特性必须时刻记住
setVertexBuffer / setFragmentBuffer 并不会复制数据,只是记录引用。凡是共享 buffer 再循环覆盖的写法,都要格外警惕同步与生命周期问题。
4. 帧 dump 是最直接的调试手段之一
很多时候,一张 debug 图胜过一百行日志。把法线、纹理采样、光照强度直接可视化,问题会暴露得非常快。
涉及的文件变更
| 文件 | 变更内容 |
|---|---|
src/renderer/MetalRenderer.h/.mm |
添加 captureFrame、setDebugMode、encodeScene 重构、setVertexBytes 修复 |
shaders/Triangle.metal |
10 种 debug 可视化模式(function constants) |
src/test/RenderTest.h/.mm |
7 个自动化像素测试 + 自适应采样点校准 |
src/core/InputManager.h/.mm |
数字键支持 |
src/main.mm |
--test 入口、球体 mesh、点光源 |
src/platform/MacWindow.mm |
修饰键支持、窗口焦点、鼠标边界检查 |
src/scene/Scene.h |
点光源位置 |
CMakeLists.txt |
添加 RenderTest.mm |
如果你对图形调试、Metal 渲染管线、或像素级自动化测试感兴趣,欢迎直接查看项目源码:https://github.com/sznswjr/kiseki_engine