# M1 · macOS 静态渲染 > 总纲:[`../PLAN.md`](../PLAN.md) §03 / §04 / §06。演示切片见 [`../DEMO-PLAN.md`](../DEMO-PLAN.md) D0–D4。 > 本文件是 M1 的详细施工文档。 --- ## 目标 `sokol_app`/SDL2 窗口 + bgfx(Metal),把 [M0](./M0-gr2-reader.md) 读出的 gr2 静态网格(绑定姿势)+ DDS 贴图渲染出来, 轨道相机可转。同时验证 **bgfx ↔ 窗口层的交接**(PLAN §03 的高风险点)。 ## 前置 - [M0](./M0-gr2-reader.md) 完成:`libgr2` 能出骨架 + 网格;`test/assets.list` 产出。 - [oracle](./00-oracle.md) 的 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](./M3-mobile.md) 再单独攻。 - **完成判据**:二选一有明确结论并记进 `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 清单 - [x] `xrender-demo` 在 macOS 起窗口(GLFW)+ bgfx Metal init 成功(`renderer: Metal`)(T0a) - [x] 交接方案定为 **GLFW**,记进 `app/README.md`(T0b)。sokol_app 不用(抢 layer),留到 M3。 - [x] warrior 绑定姿势上屏,正着站、`basis_fix` = rotX(-90°)·scale(0.01)(Granny Z-up cm → Y-up m),轨道相机 + 键位 T/N/W/R(T2) - [x] DXT1 / DXT3 软解(`engine/dxt.cpp`,绕开 `reuse/EterImageLib`),贴图 + UV 正确、无镜像(`warrior_cheongrin.dds` 512² DXT3、`warrior_face.dds` 256² DXT1)(T3) - [x] `N` 法线可视化(朝外、平滑)/ `W` 线框(拓扑正确)自检(T4) - [ ] 一个多 stage 材质与 oracle 对拍通过(T5)—— **待 oracle**。`texture_stage.sh` 单独走 `fs_stage.sc`,M1 主路径用固定 MODULATE(TEXTURE,DIFFUSE) - [ ] 与 oracle 确定态截图 SSIM ≥ 0.98 或差异分类通过 —— **待 oracle** ## 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=`(bgfx `requestScreenShot` → TGA,`rhi` 自带 `CallbackI`)+ `XR_MODE=normals|wireframe|bindpose` + `XR_FRAMES=n`。CI / 无显示器也能出确定态截图。 | | 参考截图 | `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/`) **这是"毛糙"的最大来源**:原来整个模型套一张 `.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` 骨骼模型)范围外 |