M1 · macOS 静态渲染
总纲:../PLAN.md §03 / §04 / §06。演示切片见 ../DEMO-PLAN.md D0–D4。
本文件是 M1 的详细施工文档。
目标
sokol_app/SDL2 窗口 + bgfx(Metal),把 M0 读出的 gr2 静态网格(绑定姿势)+ DDS 贴图渲染出来,
轨道相机可转。同时验证 bgfx ↔ 窗口层的交接(PLAN §03 的高风险点)。
前置
- M0 完成:
libgr2 能出骨架 + 网格;test/assets.list 产出。
- oracle 的 T6(确定态截图)—— M1 视觉对拍要它。
EterImageLib 的 CDXTCImage 冻结拷进 reuse/。
交付物
| 产物 |
位置 |
xrender-demo 可执行(macOS) |
build/app/ |
engine/rhi.{h,cpp} bgfx 薄封装 |
engine/ |
engine/scene.{h,cpp} camera.{h,cpp} |
engine/ |
着色器:vs_pnt / fs_pnt / fs_normalviz + texture_stage.sh |
app/shaders/ |
| 交接方案决议(sokol_app 还是 SDL2) |
记进 app/README.md |
任务分解(按 DEMO-PLAN 的 D0–D4 切)
T0a · toolchain 骨架(对应 D0a)
CMakeLists.txt:add_subdirectory(third_party/bgfx.cmake),pin bgfx 到与 .sc 草稿相近版本。
- 窗口层默认用 bgfx 自带
entry 或 SDL2(零风险,bgfx 所有 example 用的)——不是 sokol_app。
- 一个清屏 app:
bgfx::setViewClear(0x303030ff) + bgfx::setDebug(BGFX_DEBUG_STATS) + bgfx::frame()。
- 编一个 dummy
.sc(vs_flat.sc / fs_flat.sc),过 shaderc -p metal --platform osx + bin2c,断言 *.sc.bin.h 生成 —— 提前验证 shaderc 链路(否则要到 T2 才触发)。
- hi-dpi:resize 事件里
bgfx::reset(fbWidth, fbHeight)(retina 上用 framebuffer 像素,不是点)。
- 完成判据:窗口出现纯色背景 + stats 角标;
*.sc.bin.h 已生成。
T0b · sokol_app ↔ bgfx 交接实验(对应 D0b,不阻塞)
- 另开一个 target,用
sokol_app.h 取 sapp_metal_get_layer()(CAMetalLayer*)填 bgfx::PlatformData::nwh,init.type = Metal。
- 已知障碍:sokol_app 会自建 MTKView + 自己 present,和 bgfx 抢 layer(PLAN §03)。
- 成了 → M1 之后统一切 sokol_app(为移动端生命周期铺路)。没成 → demo 全程用 T0a 的 SDL2/entry,sokol_app 留到 M3 再单独攻。
- 完成判据:二选一有明确结论并记进
app/README.md。
T1 · engine/rhi —— bgfx 薄封装(≈150 行)
- 接口签名见 DEMO-PLAN §5「engine/rhi」。语义对齐
StateManager。
- 关键:
init(nativeHandle,w,h)、createVB/IB/Tex2D/Program、setModel/setBones/setTexture/setStageUniforms、submit、beginFrame/endFrame。
- 深度范围 / Y 翻转:用
bgfx::getCaps()->homogeneousDepth / originBottomLeft 决定 bx::mtxProj 参数,别手写。
T2 · engine/scene + vs_pnt/fs_pnt —— 白模上屏(对应 D2)
scene:libgr2::Mesh → 打包顶点 → bgfx::VertexLayout(映射表见 DEMO-PLAN §7.3)→ createVB;索引 → createIB;每 TriGroup 一个 draw item。
vs_pnt.sc / fs_pnt.sc:从 shaders.rar 拷改。vs_pnt 补 a_color0;fog 先关。
camera.{h,cpp}:轨道相机(鼠标拖 = 绕轨道、滚轮 = 距离)。
- 坐标系:加固定
basisFix(Granny art-tool basis → 左手 Y-up),用朝向明确的资产手调到"warrior 正着站、面朝 +Z",写进 scene.cpp 注释。
- 完成判据:可转的白模,轮廓 = Blender 里同模型。
T3 · DXT 解码 + 贴图(对应 D3)
reuse/EterImageLib 的 CDXTCImage:LoadHeaderFromMemory + LoadFromMemory + Decompress(0, rgba)。
warrior_cheongrin.dds 实测 512×512 DXT3 5mip —— 先只传 level 0,BGFX_SAMPLER_MIN_POINT 关 mip;T3 过后再补全 mip 链。
texture_stage.sh 从 shaders.rar 原样拷,喂"单 stage MODULATE(TEXTURE, DIFFUSE)"的 uniform(等价 tex * vertexColor)。
- 按
T 切贴图 / 白模。
- 完成判据:UV 无错位、无镜像;和 oracle 确定态截图目视一致。
T4 · 自检工具(对应 D4)
fs_normalviz.sc(10 行):gl_FragColor = vec4(v_normal*0.5+0.5, 1)。按 N 切。
- 线框:
BGFX_STATE_PT_LINES 或 bgfx::setDebug(BGFX_DEBUG_WIREFRAME)。按 W 切。
- 完成判据:法线朝外;三角组分段可见且正确。
T5 · 多 stage 材质专项(PLAN §04 要求)
- 从
test/assets.list 挑一个用了 D3DTOP_MODULATE2X 或 ADDSIGNED 的材质,单独渲一帧和 oracle 确定态截图对拍。
- 不能只靠整场景 SSIM 兜
texture_stage.sh 的正确性。
- 完成判据:该材质的着色结果与 oracle 一致(差异分类内)。
门禁(go / no-go)
- 子门禁 1(交接):bgfx 经窗口层 handle 在 macOS 出画面 + 输入可用。sokol_app 啃不下 → 用 SDL2/entry,本门禁照样算过(方案已决议)。
- 子门禁 2(几何):网格拓扑、UV 正确;无背面剔除错误 / 法线翻转。
- 子门禁 3(材质):一个多 stage 材质的着色结果与 oracle 对上。
验证
- 对比场景两侧都喂同一张预解码 RGBA(绕开 DXT),截图 diff 只反映几何 / 光照 / texture-stage,不被"软解 vs 硬件 S3TC"的 bit 级差异污染。
- oracle 侧进入确定态(T6:注入相机矩阵、固定光、绑定姿势、无程序化摇摆)。
- 与 oracle 截图像素 diff + SSIM;差异分类见 PLAN §07 视觉层。
- 法线可视化模式自检。
本步风险(从 PLAN §08 筛)
| 风险 |
状态 / 缓解 |
| sokol_app + bgfx 抢 context |
规避:M1 用 GLFW(GLFW_NO_API + glfwGetCocoaWindow → PlatformData.nwh),零冲突。sokol_app 留到 M3。 |
.sc 草稿针对某 bgfx 版本,shaderc 编不过 |
已解:vs_pnt/fs_pnt 照 shaders.rar 改写对上当前 pin 的 bgfx_shader.sh,shaderc -p metal 编过。 |
reuse/EterImageLib 从没链接过 |
规避:DXT1/3/5 自研 engine/dxt.cpp(~180 行),完全不碰 EterImageLib。 |
| 坐标系 basis:warrior 躺着 / 镜像 / 巨大 |
已解:basis_fix = rotX(-90°)·scale(0.01),warrior 正着站、比例约 1.7m。写死在 scene.cpp。 |
| DXT 软解 vs 客户端 GPU S3TC 色差 |
对拍时两侧都用软解 RGBA(engine/dxt.cpp 产出,喂给 oracle 侧同一张)。 |
| hi-dpi 视口 |
on_fb_size → glfwGetFramebufferSize → rhi::reset(framebuffer 像素)。 |
DoD 清单
M1 实现结果
| 项 |
结果 |
| 依赖 |
bgfx.cmake + bx/bimg/bgfx + glm + glfw(third_party/,pin 见 VERSIONS.md)。tools/bootstrap-submodules.sh 拉。 |
| 构建 |
cmake -B build -DXRENDER_BUILD_DEMO=ON -DXRENDER_BUILD_TOOLS=OFF → cmake --build build --target xrender-demo。着色器经 bgfx 的 shaderc 编成 metal .bin(cmake/xrender-shaders.cmake),运行时加载。 |
| 无头验证 |
XR_HIDDEN=1(隐藏窗口)+ XR_SCREENSHOT=<path>(bgfx requestScreenShot → TGA,rhi 自带 CallbackI)+ `XR_MODE=normals |
| 参考截图 |
test/golden/m1-warrior_cheongrin-bindpose-textured.png、-normals.png |
| 几何 |
5 mesh(Object16 / face / Object03 / Object09 / body),顶点/索引数与 M0 一致;bind pose = Granny T-pose;朝向、比例正确 |
| 坑 |
① 近期 bgfx 把 platform.h 并进 bgfx.h;② bgfx::setUniform 要在每次 submit 前调(frame 头单调一次不可靠)→ rhi 缓存 light/stage 每 draw 重设;③ 截图 TGA 带 alpha 通道,shader 输出非 1 的 alpha 会让 PNG 查看器合成成白 → 不透明物体 shader 固定 gl_FragColor.a = 1,截图 writer 也强制 alpha 255 |
画质改进 pass(档 1 + 档 2)
用户反馈"渲染毛糙"后做的一轮画质提升。基线:无 MSAA / 无 mip / 平光 / 贴图靠文件名瞎猜 / 无 sRGB。
档 1 —— 采样与着色正确性(engine/ + app/shaders/)
| 项 |
做法 |
| MSAA |
rhi.cpp init/reset 加 BGFX_RESET_MSAA_X4;state 加 BGFX_STATE_MSAA |
| mip 链 |
engine/dxt.{h,cpp} 重写:decode() 读 dwMipMapCount(off 28) 循环解每一级;Image.mips 存 level0..N。scene.cpp 把各级拼成一块传 rhi::create_tex2d(packed, …, mips),bgfx hasMips = mips>1 |
| sRGB 正确性 |
create_tex2d 带 BGFX_TEXTURE_SRGB(采样自动 sRGB→linear);fs_pnt.sc 线性空间着色,末尾 pow(col, 1/2.2) 编回显示空间 |
| 各向异性过滤 |
create_tex2d 带 BGFX_SAMPLER_{MIN,MAG}_ANISOTROPIC |
| 半球环境光 |
fs_pnt.sc:mix(u_ambientGround, u_ambientSky, n.y*0.5+0.5) 代替常数 ambient |
| 3 盏方向光 |
LightDesc 改 dir[3]/color[3](key/fill/顶光),.w = 强度;u_lightDir/u_lightColor 是 Vec4,3 uniform |
| 双面光照 |
fs_pnt.sc 用 abs(dot(n,l))(薄片/头发/飘带两面都受光,和 Metin2 一致) |
| 法线 renormalize |
vs 输出前 + fs normalize(v_normal) |
档 2 —— 材质绑定(libgr2 + engine/)
这是"毛糙"的最大来源:原来整个模型套一张 <gr2 名>.dds,sura_lord 根本没有 sura_lord.dds → 纯黑。
| 项 |
做法 |
| libgr2 解材质 |
gr2_mesh.cpp material_texture_name():granny_material → 直接 .Texture.FromFileName,或递归 .Maps[].Map(注意:本版 Granny 里 granny_material_map 的子材质成员名是 Map 不是 Material)。gr2_fileinfo.cpp 填 FileInfo.materials(顶层表,调试用)。 |
| 每网格贴图表 |
Mesh.material_textures(与 granny_mesh.MaterialBindings 平行);tri_group.material_index 索引它 |
| 逐 tri_group 上贴图 |
scene.cpp:SubMesh.ranges(DrawRange{ib,index_count,tex,alpha_cutout}),一个网格按 tri_group 切多段,每段切出独立 IB,各自贴图。贴图按 FromFileName 的 basename 在 gr2 同目录里大小写不敏感查找,带缓存。找不到时回落老的 stem 猜测。 |
| bind pose 也要蒙皮 |
很多 Metin2 模型(shaman_lord 等)的 raw 顶点不在 bind 空间,直接上 raw 会整块错位。Scene::set_bind_pose() 有骨架时走 File::bind_pose(0) 的蒙皮矩阵(= set_pose);bounds/相机框选也按 bind 姿势的顶点算。 |
| alpha-test(镂空) |
解贴图时看 mip0 的 alpha 分布:同时有 >5% 近 0 且 >20% 近 255(双峰)才判 cutout(头发/飘带/树叶)。只看"低 alpha 比例"会误伤 alpha 平面全 0 的不透明 DXT3(整块被 discard)。rhi::set_alpha_test 逐 draw 开,fs_pnt.sc 里 texColor.a < ref → discard。 |
排障中发现并修掉的两个真 bug
| bug |
现象 |
根因 / 修复 |
| 多材质网格第 2 段起整块飞出视锥 |
shaman_lord(1 网格 2 材质组)只渲出头顶一小撮;任何多组网格丢掉第一组之后的内容 |
Scene::draw 里 rhi::set_model()(→ bgfx::setTransform)每网格只调一次,但 bgfx 每次 submit 消费一次 transform。第 2 个 draw range 没设 transform → 用单位阵 → 顶点(~120 单位的 skin 空间坐标)画在 basis_fix 之外。修复:set_model 移进 range 循环,逐 submit 重设。 |
| bind pose 直接上 raw 顶点 |
shaman_lord 等模型整体错位、相机框选发飞(模型变成一个远处的点) |
raw 顶点不在 bind 空间。修复:set_bind_pose 走蒙皮矩阵(见上表)。 |
调试加了 XR_CAM_YAW / XR_CAM_PITCH / XR_CAM_DIST(乘子)env 覆盖初始相机,XR_VERBOSE 打 bounds / 相机参数,方便无头抽查各角度。
结果
warrior_cheongrin / warrior_novice / sura_lord / assassin / shaman_lord / snakeman 等正/背/动画各角度截图:贴图正确、比例朝向对、边缘平滑、明暗有层次(见 test/render-samples/)
- 无回归:
gr2fuzz 9166/9166、render_fuzz 9166/9166(0 crash/empty/nan)、oracle 逐字段对拍 23/23(≤6.5e-5)、iOS 交叉编译通过
明确不做(本 POC 范围外,记档)
| 项 |
原因 |
多 stage 材质混合(fs_stage.sc) |
Metin2 角色基本是单 stage MODULATE(TEXTURE,DIFFUSE);多 stage 主要用于地形。texture_stage.sh 已备,未接主路径 |
| alpha blend(半透明排序) |
cutout(alpha-test)已覆盖头发/飘带主要场景;真半透明要 OIT 或按深度排序,收益低 |
| 顶点色 |
语料里角色网格全是 PNT332(无 color 分量) |
.msm 装配(换发型/换肤) |
formats/msm.cpp 已能解析,装配是模型组合子系统,非渲染画质 |
| 法线贴图 / 切线帧 |
PNT332 无切线;Metin2 资产也没有法线贴图 |
| 阴影 / IBL / FXAA / LOD 选择 |
已有 MSAA x4;阴影/IBL 是独立子系统,env map 资产缺失;FXAA 在 MSAA 之上边际收益小 |
| 特效 / 粒子 / 地形 / 水 / SpeedTree |
独立子系统,POC(读+渲+动 .gr2 骨骼模型)范围外 |