# 神兽国 A1 场景最终渲染开发文档 > 状态:实施规格(Implementation Specification) > 目标工程:`metin2-client` > 资源来源:仓库内 `assets/`(gitignored,`MT_ASSETS` 可覆盖) > 原客户端行为参考:同级仓库 `m2dev-client-src-main/src` > 目标参考图:`ingame-shinsoo.png`,1920×1080 的神兽国村庄第三人称画面 > ## 定位(先读) > > **这不是 Phase 1 / POC 收尾,是「正式移植」的一个独立子项目。** > [`GODOT-POC-PLAN.md`](./GODOT-POC-PLAN.md) 的 POC 判定问题是「能否脱离 Granny 正确读+渲+动 `.gr2` > 骨骼模型、且跨平台」——已答「能」。本文是世界渲染工程,**前置 = Phase 2 的三设备 bring-up > (Mac / 一加 13 / iPhone 16)完成**——即确认整套桥接在两台移动设备上也能跑。项目自用、不对外 > 发布,没有正式 go/no-go 门禁;**W0 可随时开始**(纯解析、无 Godot 依赖),W1 及以后建议等 > bring-up 有结果,避免在移动端还没验证时就投入几个月的重 shader 工作。 > > 本文也**扩大了 POC 的排除范围**:POC §01 把「UI / Python 脚本层」列为「明确不做」;本文 R2 把 > HUD / 小地图 / 头顶文字纳入交付。这是有意的范围扩张,因为最终画面对标的是完整客户端。 > > **规模粗估(置信度低,排期须细化)**:重写客户端的地形 + 地图对象 + 环境系统,参照原 > `PRTerrainLib` / `GameLib/Area` / `Property` / `MapType`(原客户端里数千行)。 > > | 阶段 | 单人粗估 | > |---|---| > | W0(AssetResolver + 6 个格式 parser + 坐标 + 单测) | 2–3 周 | > | W1–W2(地形几何 + splat + 烘焙阴影) | 3–5 周 | > | W3(Property/AreaData 静态对象) | 2–3 周 | > | W4(树木;含 `.spt` 工具链未知,见 §9 W4) | 2–6 周 | > | W5–W6(环境/光照 + 玩家/NPC/相机) | 3–4 周 | > | R1 合计 | **≈ 3.5–5.5 月** | > | W7(水 + 必要 `.mse` + HUD) | 3–6 周 | > | W8(流送 + 缓存 + 移动端性能) | 4–8 周(含一加 13 / iPhone 16 实测) | > | R2 合计(在 R1 之上) | **≈ +2–3.5 月** | > > 叠加 Phase 2 的 bring-up。多人并行可压缩 W1/W3/W4/W6,但 W0 和坐标模块是串行硬前置。 > 距参考图「目视等价」的保真化剩余量,见 [`PARITY-GAP.md`](./PARITY-GAP.md)(本文管「怎么渲出来」, > 那文管「怎么做到参考图那样」)。 ## 1. 文档目的 本文定义在「Phase 2 三设备 bring-up 完成」之后,从当前“单个 `.gr2` 角色展示 POC”推进到“能够直接加载原始客户端资源并渲染神兽国 A1 村庄画面”的完整开发方案(前置见上方「定位」)。 本文是实现和验收依据,解决以下问题: 1. 哪些数据直接来自 `assets/`; 2. 每种地图资源如何解析并转换为 Godot 运行时对象; 3. 新增模块与现有 `Metin2Model`、`libgr2`、DDS 解码器如何协作; 4. 按什么顺序实现,才能尽早得到可比较的场景截图; 5. 何时可以认为已经达到参考图中的地形、建筑、植被、角色、光照和 HUD 效果。 本文不要求还原网络、任务、战斗、商店等游戏逻辑。最终交付是可运行、可移动、可稳定截图的客户端渲染样板。 ## 2. 最终目标 ### 2.1 目标画面组成 最终 1920×1080 输出必须包含: - 神兽国 A1 的原始地形高度与道路/草地/石地分布; - 原始坐标中的村庄建筑、围栏、灯柱和其他静态摆件; - 树木及透明叶片; - 玩家角色、至少一个 NPC、角色动画和地面阴影; - 与 `A1.msenv` 接近的天空、阳光、环境光、远景雾和整体色调; - 可见水域时的基础水面; - 第三人称游戏相机; - 最终验收阶段的背包、快捷栏、状态栏、小地图和头顶文字。 ### 2.2 两级交付 为了避免 UI、粒子或完整地图流送阻塞三维场景验证,交付分成两级: | 级别 | 名称 | 必须包含 | 用途 | |---|---|---|---| | R1 | 世界渲染完成 | 地形、splat、建筑、树木、角色、相机、天空、光照、雾、阴影 | 验证截图主体是否成立 | | R2 | 最终画面完成 | R1 + 水面 + 必要环境特效 + HUD/小地图/头顶文字 | 与参考图做最终视觉比较 | R1 是后续工作的硬前置;R2 是本文的最终完成条件。 ## 3. 当前基础与真实缺口 ### 3.1 已可复用能力 当前仓库已经具备: - `.gr2` v6/v7 解析; - 骨架、网格、UV、四骨权重和动画采样; - CPU 蒙皮和保留 Granny shear 的自定义 GPU 蒙皮; - `.msa`、`.msm` 基础解析; - DDS/DXT level 0 软解码; - opaque、alpha、alpha-test、additive 的近似材质; - Godot `DirectionalLight3D` 阴影; - procedural sky、Filmic tonemap、MSAA 4× 和可选深度雾; - 固定时间截图及部分 golden 回归能力。 对应实现主要位于: - [`extension/src/metin2_model.cpp`](../extension/src/metin2_model.cpp) - [`extension/src/metin2_anim.cpp`](../extension/src/metin2_anim.cpp) - [`extension/src/gr2_bridge.cpp`](../extension/src/gr2_bridge.cpp) - [`extension/src/m2_material.cpp`](../extension/src/m2_material.cpp) - [`extension/src/dxt.cpp`](../extension/src/dxt.cpp) - [`formats/`](../formats/) - [`libgr2/`](../libgr2/) ### 3.2 必须补齐的能力 当前不能直接输出目标画面的主要原因是以下系统尚不存在: 1. 统一虚拟资源路径与跨 pack 目录索引; 2. 地图设置、TextureSet、Environment、AreaData 和 Property 解析; 3. heightmap 地形网格; 4. `tile.raw` 多层地表 splatting; 5. 区块阴影贴图; 6. CRC 驱动的建筑/树木/特效实例化; 7. SpeedTree 替代或兼容路径; 8. 水面; 9. 地图区块流式加载和跨实例资源缓存; 10. 第三人称相机、NPC 放置和 HUD。 [`BACKLOG.md`](./BACKLOG.md) 中 E8、E9、E10、E7 的判断正确,但它们不足以覆盖地图渲染的全部前置:“地图文本格式”“区块二进制 + AreaData”“Property CRC 注册表”“虚拟资产解析器”“坐标统一”“地图回归”是独立工作项,已在 `BACKLOG.md` 补为 `E11`–`E13` / `G7` / `H7` / `I5`,不是 E8 内部可以忽略的小步骤。 ## 4. 资源使用原则 ### 4.1 直接读取原始资源 运行时默认直接读取: ```text assets/ ``` 禁止为了构建一张演示图而手工重新摆放整座村庄。地形和对象位置必须由原地图数据驱动。 允许生成派生缓存,例如: - 解码后的 DDS GPU 纹理缓存; - `tile.raw` 生成的 splat alpha/权重纹理; - GR2 转换后的静态 `ArrayMesh`; - 树木离线转换出的普通 Mesh; - 地图区块碰撞和导航缓存。 派生缓存必须可删除并从原始 assets 重建,不能成为新的唯一数据源。 ### 4.2 虚拟路径不能简单拼接 资源内部经常使用: ```text d:\ymir work\zone\... d:/ymir work/environment/... ``` 真实散文件按原 eterpack 名称分散在多个一级目录,例如: ```text assets/Zone/ymir work/... assets/Terrain/ymir work/... assets/ETC/ymir work/... assets/PC/ymir work/... assets/metin2_patch_*/ymir work/... ``` 因此必须建立 `AssetResolver`,不能假设文件总在 `assets/ymir work/...`。 规范化规则: 1. `\` 转 `/`; 2. 移除盘符和开头 `/`; 3. ASCII 小写作为索引键,但保留真实路径用于打开文件; 4. 去除重复 `/`、`.`,拒绝越过 assets 根目录的 `..`; 5. 以“虚拟路径 → 真实散文件路径”建立索引; 6. 同名资源按显式 pack 优先级解析,冲突必须记录日志; 7. 开发模式允许输出全部候选,便于确认 patch 覆盖关系。 推荐优先级配置,而不是依赖目录遍历顺序:基础包在前,patch 包在后,后注册者覆盖前注册者。最终采用的顺序要写入测试快照。 ### 4.3 授权约束 技术实现可以读取本地原客户端资源,但发布物不得默认携带这些资源。构建、测试和分发流程必须把代码许可与游戏素材许可分开处理。 ## 5. A1 地图数据规格 目标地图位于: ```text assets/OutdoorA1/metin2_map_a1 ``` ### 5.1 全局设置 [`setting.txt`](../assets/OutdoorA1/metin2_map_a1/setting.txt) 的当前数据为: ```text CellScale 200 HeightScale 0.5 ViewRadius 128 MapSize 4 5 BasePosition 409600 896000 TextureSet textureset\metin2_A1.txt Environment A1.msenv ``` 由原客户端常量可得: - 每个区块:128×128 个地形格; - 每格:200 cm,即 2 m; - 每个区块:256 m×256 m; - A1 全图:4×5 个区块,即约 1024 m×1280 m; - 高度:`uint16_value × HeightScale` cm,再乘 `0.01` 变成 Godot 米。 `BasePosition` 是地图在全局 Metin2 世界中的坐标基准,不应该直接变成离原点几千米的 Godot 节点坐标。渲染场景使用 map-local 坐标,网络/地图逻辑需要时再通过原点偏移换算。 ### 5.2 区块目录编号 区块目录编号采用: ```text tile_id = tile_x * 1000 + tile_y directory = six_digit_decimal(tile_id) ``` 例如 `(0, 0) → 000000`,`(1, 3) → 001003`。 加载器不得把六位目录名按连续序号理解。 ### 5.3 区块文件 | 文件 | 规格 | 用途 | |---|---|---| | `height.raw` | 131×131,little-endian `uint16`,34322 B | 含一圈边界样本的高度图 | | `tile.raw` | 258×258,`uint8`,66564 B | TextureSet 图层编号 | | `attr.atr` | 6 B header + 256×256 `uint8` | 阻挡、水域、禁 PK 等属性 | | `water.wtr` | 7 B header + 128×128 layer id + layer heights | 水层索引与高度 | | `shadowmap.dds` | 256×256 DDS | 地形烘焙阴影/颜色调制 | | `shadowmap.raw` | 256×256 RGB565,131072 B | CPU 阴影颜色查询 | | `minimap.dds` | 区块小地图 | HUD 小地图与地形分布校验 | | `areadata.txt` | 对象记录 | 坐标、CRC、YPR、heightBias、portal id | | `areaambiencedata.txt` | 环境声音对象 | R2 或后续音频阶段使用 | | `areaproperty.txt` | 区块元数据 | AreaName、NumWater | 二进制常量以原客户端 [`Terrain.h`](../../m2dev-client-src-main/src/PRTerrainLib/Terrain.h) 和 [`Terrain.cpp`](../../m2dev-client-src-main/src/PRTerrainLib/Terrain.cpp) 为真值来源。 ### 5.4 TextureSet A1 使用: ```text assets/textureset/textureset/metin2_a1.txt ``` 当前文件含 17 个图层。每个图层记录: ```text texture_path u_scale v_scale u_offset v_offset splat_enabled height_begin height_end ``` 索引 `0` 是原客户端内部的空图层;资源文件的 `Texture001` 对应运行时索引 `1`。不得产生一位偏移错误。 ### 5.5 Environment A1 环境资源可由虚拟路径解析到: ```text assets/ETC/ymir work/environment/a1.msenv ``` 当前包含: - background 和 character 两组方向光; - material diffuse/ambient/emissive; - fog level 和雾色; - sky gradient; - cloud texture、scale、height、speed; - lens flare 参数。 文件头中的 `EnvrionmentData` 保留了原格式拼写,解析器应接受该拼写,并对 token 大小写不敏感。 > **真值来源(W0 已定位)**:`.msenv` 是文本(`Group NAME { ... }` + `List NAME { ... }` 匿名 rgba 行), > 首行 `ScriptType EnvrionmentData`(原格式拼写错误,照收)。A1 实盘用 `foglevel `(非老版 > `Enable`/`NearDistance`/`FarDistance`),10 行 `List Gradient`。`formats/environment.cpp` 已解析。 > `.wtr` 头 = `{u16 magic=5426, u16 w=128, u16 h=128, u8 layerCount}` + `u8[128*128]` + > `layerCount×(u16|u32)` 层高(`Terrain.cpp LoadWaterMapFile` 两种都收);`formats/terrain_files.cpp` 已实现。 ### 5.6 AreaData 与 Property `areadata.txt` 每个对象按以下字段解析: ```text position.x position.y position.z property_crc yaw#pitch#roll height_bias optional_portal_ids... ``` > **`position` 的坐标系 —— W3 修正(2026-08-29):地图全局厘米,不是区块本地。** W0 时只看了 > `000000`(在地图原点附近,全局≈本地)误判成本地;实测 20 个区块:`001003` 的 x∈[25665,49665]、 > `003003` 的 x∈[77531,94553] —— 明显是 `tile_x·25600 + 区块内偏移`。`Area::SetCoordinate` 不偏移, > 正因为 position 已是全局。`x` 正=东,`y` 负=南(`|y|` 随 tile_y 增大)。渲染时**直接** > `position_to_godot(x, y, z+heightBias)`,**不再加区块原点**。`extension/src/metin2_world.cpp` > `place_objects()` 已修正,`formats/tests/map_formats_test.cpp` 有 `001003` 的全局断言。 所有 `assets/Property/property/**/*` Property 文件都必须注册进 CRC 索引。格式(W0 实测):行1 = `YPRT` fourcc;行2 = 十进制 uint32 CRC;行3+ = `\t""`(`propertyname` / `propertytype` + 类型专属键)。A1 扫出 1330 CRC(Building 1084 / Tree 85 / Effect 76 / DungeonBlock 84 / Ambience 2), `formats/property.cpp` 的 `PropertyRegistry` 已实现,A1 areadata 的 136 个 CRC 全部命中。 支持的原始对象类型: | PropertyType | 扩展名 | 关键资源字段 | R1 处理 | |---|---|---|---| | `Tree` | `.prt` | `TreeFile`、`TreeSize`、`TreeVariance` | 转换树木并实例化 | | `Building` | `.prb` | `BuildingFile`、`ShadowFlag` | 用 GR2 静态渲染器实例化 | | `Effect` | `.pre` | `EffectFile` | R1 可占位,R2 必须实现必要效果 | | `Ambience` | `.pra` | 声音与播放参数 | 不影响 R1 画面 | | `DungeonBlock` | `.prd` | 模型和属性文件 | 按静态建筑处理 | 解析和实例化行为以 [`Area.cpp`](../../m2dev-client-src-main/src/GameLib/Area.cpp)、[`Property.cpp`](../../m2dev-client-src-main/src/GameLib/Property.cpp) 和 [`MapType.cpp`](../../m2dev-client-src-main/src/GameLib/MapType.cpp) 为依据。 ## 6. 坐标系统 Metin2 资源使用厘米和 Z-up;Godot 使用米和 Y-up。全项目只能有一套转换: ```text Godot.x = Metin2.x * 0.01 Godot.y = Metin2.z * 0.01 Godot.z = -Metin2.y * 0.01 ``` 这与当前 `make_conv(unit_scale=0.01, flip_z=false)` 的 `-90° X` 转换一致。 要求: - 地形顶点、建筑位置、树木、角色、特效、相机目标和碰撞全部调用同一个转换函数; - 法线只做轴转换,不做平移和单位缩放; - YPR 使用与原客户端相同的 yaw/pitch/roll 顺序,先通过已知 A1 建筑验证方向,再固化成测试; - AreaData `position` 是**地图全局 cm**(§5.6,W3 修正)—— 直接做 §6 轴转换,不加区块原点; - `heightBias` 加在原始 Z 上,再转换到 Godot Y; - 不允许分别在 GDScript、地形 builder 和 GR2 loader 中复制不同公式。 建议新增: ```cpp namespace mtgodot::coord { godot::Vector3 position_to_godot(double x_cm, double y_cm, double z_cm); godot::Vector3 direction_to_godot(double x, double y, double z); godot::Basis ypr_to_godot(double yaw_deg, double pitch_deg, double roll_deg); } ``` ## 7. 目标运行时架构 ```text Metin2World (Node3D) ├── AssetResolver ├── PropertyRegistry ├── MapDefinition ├── TerrainStreamer │ └── TerrainChunk[0..N] │ ├── Terrain Mesh/Material │ ├── Water Mesh │ └── Collision ├── AreaObjectStreamer │ ├── StaticBuilding instances │ ├── Tree MultiMesh groups │ └── Effect instances ├── CharacterRoot │ ├── Player Metin2Model │ └── NPC Metin2Model ├── WorldEnvironment ├── DirectionalLight3D ├── ThirdPersonCamera └── HUD (CanvasLayer) ``` 数据流: ```text assets_root -> AssetResolver index -> map setting / textureset / environment -> visible tile set -> binary parse on worker thread -> mesh/mask/instance description -> Godot resource creation on main/render thread -> cache + scene attach ``` ### 7.1 建议新增源码布局 ```text formats/ map_setting.{h,cpp} texture_set.{h,cpp} environment.{h,cpp} terrain_files.{h,cpp} area_data.{h,cpp} property.{h,cpp} tests/map_formats_test.cpp extension/src/ asset_resolver.{h,cpp} metin2_world.{h,cpp} terrain_builder.{h,cpp} terrain_material.{h,cpp} property_registry.{h,cpp} area_object_loader.{h,cpp} metin2_static_model.{h,cpp} environment_builder.{h,cpp} water_builder.{h,cpp} resource_cache.{h,cpp} coordinate.{h,cpp} project/ world_demo.gd third_person_camera.gd hud/ test/ maps/ golden/world-a1-r1.png golden/world-a1-r2.png ``` 格式解析层不得依赖 godot-cpp,以便使用 CTest 做快速单元测试。Godot 对象创建留在 `extension/src`。 ## 8. Godot API 契约 新增一个 GDExtension 节点: ```text Metin2World : Node3D ``` 建议暴露: ```gdscript @export var assets_root: String @export var map_path: String = "OutdoorA1/metin2_map_a1" @export var load_radius_tiles: int = 1 @export var player_global_position_cm: Vector3 @export var enable_water: bool = true @export var enable_effects: bool = true @export var enable_collision: bool = true func load_map() -> bool func unload_map() -> void func set_focus_position(global_position_cm: Vector3) -> void func sample_height(global_x_cm: float, global_y_cm: float) -> float func sample_attribute(global_x_cm: float, global_y_cm: float) -> int func get_load_report() -> Dictionary func capture_reference_frame(path: String) -> void ``` 信号: ```gdscript signal map_loaded(report: Dictionary) signal tile_loaded(tile_x: int, tile_y: int) signal tile_unloaded(tile_x: int, tile_y: int) signal asset_missing(virtual_path: String, context: String) signal load_failed(stage: String, detail: String) ``` `get_load_report()` 至少返回:地图尺寸、已加载区块、活动 terrain layers、AreaData 对象数、各 PropertyType 成功数、缺失资源数、缓存命中率和加载耗时。 ## 9. 分阶段实施方案 > **贯穿全程的两条硬约束:** > 1. **目标渲染器 = Godot Mobile renderer(Vulkan / Metal),不涉及 Compatibility(GLES3)。** > 三台目标设备(Mac / 一加 13 / iPhone 16)都是现代硬件。R1 可在 macOS 上用 Forward+ 开发,但每个 > 新写的 shader(地形、splat、水、天空、`SRC_SKIN`)要尽早在一加 13 / iPhone 16 上各跑一次, > 不把「移动端能不能跑」推到 W8。 > 2. **文本编码要显式声明。** `setting.txt` / `areadata.txt` / `areaproperty.txt` / Property 文本、 > AreaName 等可能是 CP949/EUC-KR(原版)或 ASCII/UTF-8(m2dev fork)。每个文本 parser 必须声明它按 > 什么编码读,`formats/tests` 要有一个非 ASCII 用例。 ### Phase W0:资源索引与格式真值测试 目标:先证明所有后续系统能够稳定找到并读懂数据。 > **进度(2026-08-29)—— W0 解析层基本完成**。`formats/` 下已落地并通过 CTest `formats.map_formats` > (内联单测 + `M2_ASSETS` 设时跑真实 A1 断言)+ `tools/map_probe` load report: > - `m2_tokvec`(`LoadMultipleTextData` 等价)、`m2_coord`(I5:坐标 / 单位 / 区块编号) > - `map_setting`(setting.txt)、`texture_set`(17 层)、`area_data`(areadata / ambience / property) > - `terrain_files`(height/tile/attr/water,magic 2634/5426 + 尺寸校验,真值取自 `Terrain.cpp`) > - `environment`(`.msenv` Group/List 文本树;A1 `foglevel 6` + 10 行 Gradient) > - `property`(`.prb/.prt/.pre/.prd/.pra` + CRC 注册表:A1 扫出 1330 CRC,Building 1084 / Tree 85 …) > - `asset_resolver`(虚拟路径 → 散文件;索引 54212 文件 / 26106 ymir key) > > A1 实测:MapSize 4×5 / 20 区块全可发现 / TextureSet 17 层且贴图 17/17 可解析 / `000000` areadata > 23 对象 / **136/136 个 areadata CRC 都命中 Property 注册表** / 全 975 对象坐标为地图全局 cm(W3 修正,见 §5.6) / > CRC `569394331` = Object000 = Property "Pagoda1"(**类型 Tree**,非 Building)。 > > **剩余**:`ingame-shinsoo.png` = A1 的 minimap 目视核对;`ypr_to_godot`(YPR→Basis,留 W3 用已知 > A1 建筑验顺序);extension 侧 `Metin2World` 节点(W1);Property 模型引用 1200/1244 可解析,缺口 > 是部分 `.spt` 树 / 未包含的 patch 包,非 W0 阻塞。 实现: 1. `AssetResolver` 扫描 `assets` 并生成规范化索引; 2. 解析 `setting.txt`、TextureSet、`.msenv`; 3. 解析六类区块文件头和尺寸; 4. 扫描 Property 并建立 `uint32 CRC -> Property`; 5. 解析 `areadata.txt` 和 `areaambiencedata.txt`; 6. 所有 parser 返回结构化错误,不允许静默截断或越界读取; 7. 所有文本 parser 显式声明输入编码(见本节开头约束 2),非 ASCII 字段有测试覆盖。 验收: > 以下多数项已由 `tools/map_probe` + CTest `formats.map_formats` 对真实 A1 跑通(2026-08-29,见上方 > 进度块)。仍需人工完成的只剩第一条(参考图 = A1 的目视核对)和最后的落点校验。 - **先确认参考图 = A1**(人工):核对 `ingame-shinsoo.png` 里的建筑 / 地貌 / 远景与 `OutdoorA1/metin2_map_a1` 的 `minimap.dds` 拼图一致;若不是 A1,改用实际地图目录,§5 常量随之更新; - ✅ `MapSize` = `4 5`(字段序 `x y`:目录 000000..003004 = tile_x 0..3 / tile_y 0..4); - ✅ 20 个区块目录全部可发现; - ✅ `height.raw` 131×131 `uint16`(无头)/ `tile.raw` 258×258 `uint8`(无头); - ✅ `attr.atr` magic 2634 / `water.wtr` magic 5426(真值取自 `Terrain.cpp`); - ✅ TextureSet 17 层,运行时保留 index 0 空图层,17/17 贴图经 `AssetResolver` 可解析; - ✅ `000000/areadata.txt` 声明 23 = 解析 23;`.msenv` / `.prb.prt.pre.prd.pra` 全部解析; - ✅ Property CRC 注册表:A1 全 136 个 distinct areadata CRC 都命中;CRC `569394331` = Object000 = Property "Pagoda1"(类型 **Tree**,非 Building —— §9-W0 原示例猜错了); - ✅ **AreaData `position` = 地图全局 cm**(§5.6 W3 修正;`001003` 全局断言在 `map_formats_test.cpp`); - ⬜ 落点校验(人工):取一个已知 `Building` 对象(从 `map_probe` 输出里挑一个 type=Building 的 CRC),其 `position` 经 §6「加区块原点 + 轴转换」后落点与 `minimap.dds` 上该建筑一致; - ⬜ `AssetResolver` 的未解析路径 / 重复虚拟路径进报告(`map_probe` 已打印计数,需补明细导出)。 ### Phase W1:地形几何 目标:在 Godot 中正确显示 A1 高度地形。 > **进度(2026-08-29)—— W1 几何做通**。`formats/terrain_mesh.{h,cpp}`(纯数学,CTest 覆盖): > 129×129 顶点 / 128×128 quad,对角线 TL→BR(= `CTerrain::GetHeight` 的 h1/h2),中心差分法线用 > `height.raw` 边界样本(跨区块无缝),坐标经 `m2_coord` 转 Godot 空间 + 区块原点。`terrain_height_at()` > 逐字节照 `AreaTerrain.cpp CTerrain::GetHeight` 的左/右三角插值。 > `extension/src/metin2_world.{h,cpp}` = `Metin2World` GDExtension 节点(SHINSOO §8 API 子集: > `assets_root` / `map_path` / `load_radius_tiles` / `load_map()` / `unload_map()` / `sample_height()` / > `get_load_report()`)。headless 冒烟 `project/world_probe.gd`:A1 20/20 区块建成 `MeshInstance3D` > (165ms),`sample_height(128,128)=175.7m`(与 areadata z≈178m 一致),地图外返回 0,RESULT PASS。 > **打磨(2026-08-29)**:每区块 `StaticBody3D` + `HeightMapShape3D`(129²,缩放承载格距)已加。 > **剩余**:16×16 patch 裁剪单元、相邻区块共享边 = 0 的显式断言、线框截图。 实现: 1. 每个区块生成 129×129 可见顶点和 128×128 个 quad; 2. `height.raw` 的 131×131 数据保留边界样本,用于相邻区块法线; 3. 三角线方向必须与原客户端 `GetHeight()` 的左右三角判定一致; 4. 法线按原客户端中心差分算法生成; 5. 先以 16×16 格 patch 为裁剪/LOD 单元; 6. 生成简单灰色材质和 `HeightMapShape3D` 或等价碰撞; 7. 提供 `sample_height()` 并与网格表面交叉验证。 > **线程:R1 主线程同步构建。** W1–W7 的区块解析 + mesh/splat 生成都在主线程同步做,接受加载时的 > 短暂卡顿(截图样板不追求流畅游览)。§7 数据流里的 worker-thread 拆分是 W8 的事,别提前引入 > `RenderingServer` 跨线程调用的复杂度。 验收: - 四个相邻区块的共享边高度误差为 0; - 接缝处法线连续,无可见裂缝; - 已知 AreaData 物体落在正确地形高度附近; - 地图尺寸和区块原点换算正确; - 线框截图能辨认出 A1 地貌。 ### Phase W2:地表 splatting 与烘焙阴影 目标:得到参考图中最关键的道路/草地分布和明暗结构。 > **进度(2026-08-29)—— W2 多图层地表着色器**。`formats/splat.{h,cpp}` 逐字节照 > `AreaTerrain.cpp RAW_GenerateSplat`(`tile.raw` → 每图层 258² alpha + 1px 羽化)。 > `extension/src/terrain_splat.{h,cpp}` = `build_chunk_terrain_material()`:当前把图层 alpha 打进 > ≤2 张 RGBA8 权重贴图;图层色 DDS(`dxt.cpp`)解码 + 强制 resize 512² + 运行时 mipmap 后堆成 > `Texture2DArray`; > `ShaderMaterial`(`SRC_TERRAIN`)逐图层按权重 `mix`,UV 按 `TextureLayer.u_scale/v_scale/offset` > 平铺,`shadowmap.dds` 作 albedo 乘法项(有则),光照交给 Godot `DirectionalLight`。 > **窗口渲染验证**:`project/world_shot.gd` 出 `test/golden/world-a1-terrain-top.png` + > `world-a1-persp.png` —— 当前 8 层子集下道路 / 草地 / 沙滩 / 岩壁轮廓可辨且跨区块无明显接缝; > 在补齐最多 13 个活动层之前,不能据此宣称完整地表分布已与原客户端一致。 > **已确认的 P1 缺口**:当前 `min(8, splat.layers.size())` 静默截断活动图层;A1 单区块最多有 > **13 个非零图层**,最坏丢 5 层。当前 UV `128/scale` 也不等于原客户端公式。 > **剩余**:支持全部活动图层;把 UV 改为 `fTerrainTexCoordBase * Scale`(对当前区块归一化 UV 即 > U=`8*UScale`、V=`-8*VScale`,offset 同步符号);保留源分辨率/原 mip,严格 parity 时关闭程序化 > `macro_detail`;再用 minimap/受控帧验收,并跑 Mobile renderer。 兼容路径先实现原客户端语义: 1. `tile.raw` 的值是 TextureSet 图层编号; 2. 按原 `RAW_GenerateSplat` 逻辑生成每图层 258×258 alpha; 3. 图层按原顺序叠加; 4. 使用原公式 `fTerrainTexCoordBase * UScale`、`-fTerrainTexCoordBase * VScale` 和对应 offset 生成 UV; 5. `shadowmap.dds` 作为地表颜色乘法项; 6. 只有当前 patch 出现的图层才提交绘制,但不得按固定 8 层上限截断。 第一版允许“一图层一 pass”来优先保证准确。R1 达标后,再把每 4 个权重打包到 RGBA 或使用纹理数组减少 draw call。优化版本必须与兼容路径做截图对比,不能改变道路边缘。 > **移动端提前验**:17 图层「一图层一 pass」在移动 GPU 上仍然很重。W2 里就在一加 13 / iPhone 16 上 > 跑一次单区块 splat,确认合批路径(RGBA 权重打包 or `sampler2DArray` —— Vulkan / Metal 都支持数组 > 采样)帧率可接受;不行则地表方案在 W2 内定,别拖到 W8 才返工。 材质最低要求: - sRGB 地表纹理; - mipmap、重复寻址和各向异性过滤; - 太阳漫反射; - `.msenv` material ambient/emissive 近似; - `shadowmap.dds` 调制; - 可选距离宏细节衰减,不能破坏原纹理分布。 验收: - 道路、草地、石地和沙地轮廓与 `minimap.dds` 对齐; - 区块边缘没有 mask 接缝; - `Texture001..017` 不发生索引偏移; - 真实 A1 最多 13 个非零层的区块不会丢层,并有自动化断言; - 固定俯视相机截图可稳定生成 golden; - 关闭实时阴影时,烘焙阴影仍可见。 ### Phase W3:Property 驱动的静态场景 目标:不用手工摆放即可恢复村庄建筑和摆件。 > **进度(2026-08-29)—— W3 首版**。决策落地:不新建 `Metin2StaticModel` 节点,改用轻量自由函数 > `extension/src/static_object.{h,cpp}` `get_static_mesh()` —— 复用 `gr2_bridge` 的 `build_parts` / > `build_mesh`,逐 surface 从 gr2 material binding(`Mesh::material_textures`)取贴图,走 `AssetResolver` > 定位 + `dxt.cpp` 解码,**按真实路径缓存、跨实例共享同一 `ArrayMesh`**,不建 Skeleton3D。 > `Metin2World::place_objects()`:遍历各区块 areadata → `PropertyRegistry` 查 CRC → `Building` 用 > `buildingfile` / `DungeonBlock` 用 `dungeonblockfile` → 实例化 `MeshInstance3D`。 > A1 实测:975 个 areadata → **601 个 Building/DungeonBlock 实例,119 个共享 mesh,0 缺模型**; > 6 个跳过(Effect/Ambience;368 个 Tree 走 W4)。 > **W3 尾巴已补**:位置改用**地图全局 cm**(W0 误判为区块本地,见 §5.6 修正 —— 视觉核对时发现 > 建筑飘到地图外才抓到);`ShadowFlag=0/空` → `SHADOW_CASTING_OFF`;`portal_ids` 存进节点 meta; > 材质默认 `CULL_BACK`(贴图名含 leaf/grass/fence/ivy 才 alpha-scissor + 双面);有同名 `.mdatr` > 的实例计数(A1 = 601,碰撞未建)。`ypr_basis()` = `Ry(yaw)·Rx(pitch)·Rz(roll)`(Metin2 Z-up 空间,忠实 > 移植 `D3DXMatrixRotationYawPitchRoll`);放置用 `object_basis_godot()` = 其共轭到 Godot Y-up(`C·R·C⁻¹`, > `C=rotate(-90°,X)`)—— **修了之前漏轴转换的 bug**(roll=朝向被当成绕 Godot Z 翻滚,影响 66% A1 物体)。 > CTest 钉了 identity / roll=90→绕 Godot+Y / 正交性。 > **B1 材质数据(2026-08-29)**:libgr2 `MaterialInfo` 加 `map_count` / `alpha_blend`(Name~"Blend"+maps>1,照 > `EterGrnLib Material.cpp:233`)/ `two_sided`(名字关键字近似 ExtendedData "Two-sided")。`static_object` 据此 > 切 `TRANSPARENCY_ALPHA` / `CULL_DISABLED`。gr2fuzz 9166/9166 不回归。 > **剩余**:`ypr` 逐建筑朝向对原客户端截图复核(轴共轭 bug 已修,见上);`.mdatr` 碰撞体;`_lod_01..03` 距离切换;`ExtendedData` 精确解析。 实现: 1. 根据 AreaData CRC 查 Property; 2. 读取对象位置、YPR、heightBias 和 portal ids; 3. `Building`、`DungeonBlock` 复用 `libgr2`,但走专用静态资源路径; 4. 同一 GR2 只解析一次,Mesh 和 Material 跨实例共享; 5. 支持 `_lod_01.gr2`、`_lod_02.gr2`、`_lod_03.gr2`; 6. `ShadowFlag` 决定是否参与实时阴影; 7. `.mdatr` 用于静态碰撞,若暂未实现则必须在报告中列出; 8. portal 信息先保存,R1 可不做室内 portal 裁剪。 不要为每栋静态建筑创建完整动画播放器和独立纹理。**W3 开工前须定方向**:新增 `Metin2StaticModel`,还是 把 `Metin2Model` 的“资源解析”与“节点实例化”拆开。它决定 W3 的大部分工作量,别拖到撞上再定。 验收: - `000000` 的每条 AreaData(W0 实测 23,全部命中 Property 注册表)都有明确结果:成功、暂不支持或缺资源; - 一个已知 `Building` 对象(从 `map_probe` 输出挑一个 type=Building 的 CRC;`569394331` 是 Tree,不能用) 的位置和旋转正确; - 建筑屋顶朝向、正反面剔除和贴图没有明显错误; - 重复建筑共享 Mesh/Texture; - 缺失资源只影响单个实例,不导致地图加载失败。 ### Phase W4:树木与植被 目标:恢复参考图中影响轮廓和色彩最大的树冠。 > **`.spt` 调研修正(2026-08-29)**:几何 parser 仍在闭源 `CSpeedTreeRT::LoadTree()`,但仓库 > `speedtree_static.lib` 实测是保留大量类/函数符号的 **COFF x86-64**,并附完整 `SpeedTreeRT.h`;它不能 > 链进 macOS/移动端,授权与转换产物分发也必须先确认。优先方案是 Windows x64 离线 exporter 调用 > `LoadTree/Compute/GetGeometry`,不是先完整反编译 `.spt`。 > > **W4/R2(真实纹理 proxy,已落地)**:`formats/spt.{h,cpp}` 除 magic/内嵌贴图外,会提取二进制 > 尾标中的 `CompositeMap*` / `CompositeShadowMap*`;`extension/src/tree_placeholder.{h,cpp}` 从 > AssetResolver 加载真实 bark DDS 和 B1/B2/B3/N1/N2 composite atlas,生成确定性树干/分枝与多组 > 交叉叶簇,`SRC_LEAF` 使用 alpha-scissor、mipmap anisotropic 和按实例错相风摆。解析失败才回退到 > 程序化叶片。 > `Metin2World::place_chunk_objects()` 按 `treefile` 缓存共享 Mesh → 每组一个 `MultiMeshInstance3D`; > 逐实例只使用 areadata 的全局位置,已移除旧 proxy 未经原客户端证实的随机 yaw/scale jitter。 > A1 实测 **368 棵 / 14 树种**;MultiMesh `visibility_range_end=420m`(+60m 淡出)。 > **剩余**:真实 branch/frond/leaf table、按树种精确尺寸、LOD/impostor、环境色调和阴影策略。 两条路径: 1. **当前 R2 proxy**:真实 bark/composite atlas + 确定性枝干/叶簇,先保证地图可运行和轮廓明显优于占位; 2. **最终 parity 路径**:验证授权后,用 Windows x64 离线 exporter 将 `.spt` 转成普通 branch/frond/leaf Mesh、LOD 与 impostor。 > **离线 exporter 仍是待决项**:真实资产共 **118** 个;先拿最终机位的 3 个代表树种,在兼容的 > Windows x64/MSVC 环境验证 `LoadTree → Compute(seed=1) → GetTextures/GetGeometry`。导出器需保留 > branch/frond strip、leaf cluster、wind weight/index、每级 LOD、billboard、包围盒和纹理名;未确认 > 授权前不提交专有库、反编译实现或转换资产。 R1 树木材质要求: - 叶片 `alpha_scissor`,避免全透明区域参与排序; - `cull_disabled` 双面; - 树干 opaque; - 叶片颜色受环境光和太阳影响; - 简单顶点风摆; - 相同树种使用 `MultiMeshInstance3D`; - 远距离使用低模或 billboard。 `TreeSize` 和 `TreeVariance` 必须参与实例尺度,随机结果以 CRC/对象索引为种子,保证每次截图一致。 验收: - A1 村庄主要树木的位置、尺度和树种一致; - 叶片无黑色方块、严重白边或单面消失; - 固定截图中的树冠轮廓稳定; - 树木实例不会导致每帧大量 CPU 更新。 ### Phase W5:环境、灯光和画面风格 > **观感打磨(2026-08-29 第二轮,向参考图靠)**:`environment_builder` —— 环境光改 `AMBIENT_SOURCE_COLOR` > 用 `.msenv Material.Ambient` 暖色提亮阴影面;4-split 阴影 + 收紧 cascade(500m)+ bias 调; > `adjustment` 饱和 1.22 / 对比 1.10;开轻 glow(HDR 溢出)+ 轻 SSAO。地形着色器加各向异性过滤 + > 低频宏观明暗破平铺。`static_object` 用 **B1**(见下)的 alpha/two-sided 切材质。整体色调更接近 > Metin2 高对比高饱和暖调。 目标:从“资源正确”推进到参考图的经典高亮、高饱和视觉。 > **进度(2026-08-29)—— W5 首版**。`extension/src/environment_builder.{h,cpp}` `apply_environment()` > 把 `fmt::Environment`(`.msenv`)落到 Godot: > - `DirectionalLight.Background` → 场景 `Sun`(方向经 `direction_to_godot` + `Basis::looking_at`, > 色 = `bg_diffuse`,energy 按亮度,`shadow=on`) > - `SkyBox.Gradient`(10 行)→ `ProceduralSkyMaterial` 三段色(zenith / horizon / ground) > - `Material.Ambient` → `AMBIENT_SOURCE_SKY` energy + `ambient_light_color` > - `Fog`(`foglevel 6`)→ **深度雾** begin≈225m / end≈925m(`foglevel` 越大起雾越近;首版误用 > 指数密度把全图刷白,已改深度雾) > - 色调 = Filmic + `adjustment`(saturation 1.12 / contrast 1.05) > > `Metin2World` load 时解析 `A1.msenv`(`resolver` 定位)→ `apply_environment(this)`,建 `Sun` + > `WorldEnv` 子节点。`world-a1-{persp,terrain-top}.png` 重出:蓝天渐变 + 远山轻霭 + 定向光阴影 + > 偏饱和绿地。 > **剩余**:`DirectionalLight.Character` 角色光(留角色材质 uniform,§9-W5 note);`SkyBox` 云层 > texture/speed;lens flare(R2);烘焙 `shadowmap.dds` 与实时阴影的双影规避;同机位 ROI 直方图对 > 参考帧。 实现映射: | `.msenv` 数据 | Godot 映射 | |---|---| | DirectionalLight.Background | 地形/建筑主方向光 | | DirectionalLight.Character | 角色灯光参数或角色材质修正 | | Material Ambient/Emissive | WorldEnvironment + 材质参数 | | Fog Color/Level | 深度雾颜色与距离 | | SkyBox Gradient | 自定义天空 shader | | Cloud texture/speed | 天空云层 shader | | LensFlare | R2 屏幕空间 flare/billboard | > **`DirectionalLight.Character` 落地方式 W5 开工前定**:Godot Forward+ 不易做「地形一套光 / 角色另一 > 套光」。候选:给 `SRC_MIX` / `SRC_SKIN` 加一组 character-light uniform,或用 Godot light `cull_mask` > 把角色光单独分层。别留到调色阶段才发现角色偏色是光照分层没做。 阴影: - 地形使用烘焙 shadowmap,并继续接收玩家/NPC 的实时接触阴影; - 角色实时投影;静态树木/建筑按是否已经烘焙选择性关闭实时投影,避免双影; - 根据米制场景重新标定 shadow distance、cascade split、normal bias 和 bias; - 角色脚下必须有清晰接触阴影; - 避免烘焙阴影与实时建筑阴影造成不可接受的双影。 色调: - 保留 Filmic 作为起点,但最终以参考图为准; - 增加曝光、对比度、饱和度和暖色调参数; - 不以“物理正确 PBR”作为验收目标; - 禁止用极端曝光掩盖贴图、材质或光照错误。 验收采用同机位图像统计:天空、道路、草地、建筑和角色分别取 ROI,对比平均亮度、饱和度、主色和边缘结构。 ### Phase W6:玩家、NPC 与游戏相机 目标:把当前模型演示器变成第三人称场景。 > **进度(2026-08-29)—— W6 首版**。`project/world_demo.gd`(`SceneTree` 脚本):`Metin2World` + > 玩家(`Metin2Model` warrior + `Metin2AnimPlayer`)+ NPC(shaman,`wait.msa`)+ 第三人称相机。 > - 玩家:WASD 相对相机朝向移动,`_player.rotation.y` 朝移动方向;`Metin2World.is_blocked()` > (`attr.atr` bit0,新加 `sample_attribute()`)挡移动;`sample_height()` 逐帧贴地 > - 动画状态机:speed 0 → `general/wait.msa`,< 阈值 → `walk`,否则 `run`(切 `anim_path` 触发重载) > - 相机:`yaw/pitch/dist` 环绕 + 沿视线采 `sample_height` 的防穿逼近 > - 截图模式跑脚本化「前进 + 右转」→ `test/golden/world-a1-r1.png`:warrior 跑姿在地形上, > 朝向正确,相机跟随不穿地,背景是 W1–W5 的完整世界 > **打磨(2026-08-29)**:NPC `Label3D` 名字牌;**玩家发型挂接**(`Metin2Model.hair_gr2`,骨骼按名重映射 > 到 base skeleton,并入 CPU 蒙皮,`alpha_scissor`;warrior 74/74 骨匹配)—— 角色不再是秃头。 > **剩余**:鼠标转视角交互壳、相机遮挡淡出、武器挂点、GPU 路径的 hair、确定性机位 preset。 实现: 1. 复用 `Metin2Model` 和 `Metin2AnimPlayer`; 2. 正式补齐头发、武器和共享骨架附件; 3. 玩家和 NPC 默认使用 GPU 自定义蒙皮; 4. 玩家 Y 位置实时贴合 `sample_height()`; 5. `attr.atr` 的 `ATTRIBUTE_BLOCK` 用于移动阻挡; 6. 相机包含跟随、yaw/pitch、缩放、近远裁剪和遮挡处理; 7. 调出与参考图相似的高度、距离和 FOV,并保存成确定性 preset; 8. NPC 名称和交互提示使用 `Label3D` 或屏幕空间跟随 UI。 R1 不要求完整角色控制器,但必须能用键盘移动、转向、从 idle 切到 walk/run,并稳定截图。 R1 的「可移动」= 贴地(`sample_height`)+ `attr.atr` 阻挡;建筑 / 摆件的实体碰撞(`.mdatr`,见 W3 第 7 项) 是 R2 或之后,不阻塞 R1。 验收: - 玩家比例与建筑、道路一致; - 角色脚不明显悬空或陷地; - 相机不会穿过地面; - 固定 preset 能重复生成相同构图; - 玩家和 NPC 动画正常,无 CPU/GPU 蒙皮差异回归。 ### Phase W7:水面、必要特效与 HUD 目标:完成 R2 最终画面。 > **进度(2026-08-29)—— W7 首版**。 > - **水面**:`extension/src/water_builder.{h,cpp}` —— `water.wtr`(`formats` 已解析)逐层按 texel > **行合并 span** 生成掩膜网格,Y = `heights[layer]·HeightScale·0.01`;同层/区块为一个 mesh surface。 > `SRC_WATER` 仍是程序化波纹法线 + 菲涅耳 alpha + 深/浅色 mix + 高 SPECULAR 太阳光斑。 > A1 = **28 片水面 / 19 区块**。 > - **HUD**:`project/hud.gd`(`CanvasLayer`)—— 小地图 = 4×5 张 `minimap.dds`(`Metin2World.load_dds` > 新方法解 DDS)拼图 + 玩家黄点;HP/MP 条、8 格快捷栏、`I` 开关背包(占位)。 > - `test/golden/world-a1-r2.png` = 当前已实现部分的叠加(第三人称 + 占位 HUD + 程序化水 + `.msenv`), > 不代表 W7 的必要特效和真实 UI 已完成。 > **`.mse` 特效尚未端口**:资产是 ASCII 文本,原客户端 EffectLib 已有 parser 和语义真值;缺口是 > Godot 数据模型、粒子/mesh/light 运行时、材质与挂点,不是格式逆向。 > **剩余**:原客户端 30 帧水纹、逐顶点水深 alpha/高度动画、`.mse` 最小子集、HUD 真实 UI 贴图, > 以及玩家朝向箭头/缩放。 水面: - 解析 `water.wtr` 的 128×128 layer id; - `0xFF` 表示无水,其余值引用层高度; - 按连续水域生成合并网格,不能一格一个节点; - parity 路径加载 `special/water/01.dds`…`30.dds`,约每 70ms 翻页,并使用相机空间位置 UV; - 按 `waterHeight - terrainHeight` 复现逐顶点 alpha,并复现轻微高度动画; - `ReflectionProbe` 是 cubemap,不作为平面反射;需要时用镜像相机 + SubViewport 或 SSR; - Godot 4 折射使用 `hint_screen_texture` sampler + `SCREEN_UV`,不能沿用 Godot 3 的 `SCREEN_TEXTURE`; - normal map、折射、岸线泡沫和高成本反射属于增强项,只有参考机位明确可见时才纳入 R2; - 行列矩形合并只减少顶点/索引;减少 draw call 需跨区块/水层合批,必须分别测量。 特效: > **真值来源已定位**:`.mse` 是 ASCII Group/List 文本。顶层见 `EffectLib/EffectData.cpp`,粒子见 > `ParticleSystemData.cpp`,mesh effect 见 `EffectMesh.cpp`,light 见 `SimpleLightData.cpp`;运行时由 > `CEffectManager` 等类驱动。端口这些结构和时间事件语义,不凭字段名重做一套推断 parser。 - 先统计最终机位内实际出现的 `.mse`; - 为必要的 billboard、粒子和 mesh emitter 实现最小兼容子集; - 未出现在最终机位的完整技能特效不阻塞本里程碑; - 不允许用永久静态贴图替代会明显影响参考画面的动态环境效果。 HUD: - 用 Godot `CanvasLayer`/`Control` 构建; - UI 贴图通过 `AssetResolver` 从各 pack 的 `ymir work/ui/` 和 `assets/locale/locale//ui/` 读取; - 小地图由各区块 `minimap.dds` 拼接并按玩家全局坐标定位;先保持 north-up、旋转玩家 heading 箭头, 只有原客户端配置/受控帧证明整图旋转时才旋转地图; - 背包和快捷栏只要求展示与基础开关,不要求服务端物品逻辑; - UI 在 1920×1080 下先做像素对齐,再补缩放策略; - 字体、描边、层级和透明度需接近参考图。 验收:最终帧包含参考图中相同类别的 HUD 元素,并且不改变三维场景机位和曝光。 ### Phase W8:流式加载、缓存和性能 目标:从固定截图样板变成可稳定游览的 A1 地图。 > **进度(2026-08-29)—— W8 首版**。`Metin2World` 重构成**逐区块自包含**:每个区块一个 > `Chunk_` 根节点,挂 terrain + water + 该区块的建筑 + 该区块的树 MultiMesh;卸载 = free 根节点。 > - **流式**:`load_radius_tiles >= 0` 时按 `focus` 的 (2r+1)² 装/卸。`set_focus_position(gx,gz)` → > 算 focus tile,变了就 `stream_update()`(卸半径外 + 入队半径内)。`_process` 每帧建 `stream_budget` > 个(默认 1)。`load_radius_tiles = -1` 保留「一次全建」。 > - **缓存**:`PropertyRegistry`(1330 CRC)、`AssetResolver`(5.4 万文件)、`StaticMeshCache` > (建筑 gr2 → ArrayMesh,119 份)、DDS 解码缓存跨区块装卸复用。 > - **性能报告**:`get_perf()` → fps / process_ms / draw_calls / primitives / video_mem / tex_mem / > objects_3d / nodes / resident_chunks。 > - `world_demo.gd` 实测(`radius=1`,3×3):**resident_chunks 9**(非 20),稳定 30fps(headless 上限), > ~34ms/帧,draw 450–550,显存 320MB / 纹理 221MB。 > **剩余**:worker thread 解析(现主线程同步,`stream_budget` 分摊)、建筑 `_lod_*` 距离切换、 > 全活动图层/精确 UV/源 mip 修复后再测、真机(一加 13 / iPhone 16)基线。 实现: - 玩家所在区块 + 周围 3×3 区块常驻; - 更远区块只保留低成本远景或卸载; - 文件读取、文本解析、height/splat 生成可在 worker thread; - Godot Resource/Node 创建和场景树修改在允许的主线程阶段完成; - Mesh、Texture、Material、Property 和解析结果分别缓存; - 静态建筑批次、树木 MultiMesh、LOD、可见距离和遮挡裁剪; - DDS 完整 mip 链和平台压缩纹理; - 加载有每帧预算,移动时不能出现长时间主线程停顿。 建议 R2 性能门槛: | 平台 | 分辨率 | 目标 | 说明 | |---|---:|---:|---| | Mac 开发机 | 1920×1080 | 60 FPS | 最终截图基线 | | iPhone 16 / 一加 13 | 设备原生或动态分辨率 | 稳定可玩(旗舰基线) | 自用参考,非发布门槛 | 性能报告必须同时记录 CPU frame、GPU frame、draw calls、三角形、纹理显存、活跃对象和区块加载峰值,不能只写平均 FPS。 ## 10. 材质兼容要求 世界场景会放大当前启发式材质的问题。R1 前至少完成: - 从 GR2 暴露材质实际承载的纹理/双面等数据,并按原客户端源码分支复现 blend、alpha test、cull、sphere-map 和必要的多贴图状态; - 支持 `D3DCULL_NONE`,尤其是屋檐、围栏、叶片和薄片; - 支持顶点色; - alpha-test 阈值来自材质或兼容配置,不再只看表面名; - 区分 sRGB 颜色纹理与线性 mask/权重纹理; - 恢复 DDS mip 链; - 同材质参数生成稳定 cache key; - 透明和 additive 设置明确的排序/优先级。 完整固定功能材质分支可以渐进实现,但最终机位内出现的错误材质必须在 R1 前修复,不能以“已列入 BACKLOG”为通过理由。 ## 11. 测试方案 ### 11.1 格式单元测试 放入 `formats/tests`,不启动 Godot: - map setting 正常/缺字段/非法值; - 131×131 height 数据及截断输入; - attr/water magic、宽高和 payload 校验; - tile layer 越界; - TextureSet 的 index 0 规则; - `YPRT`、CRC、五种 PropertyType; - AreaData 的 YPR、heightBias、portal ids; - Windows 路径、大小写、空格和重复 pack 冲突。 ### 11.2 数值测试 > 地图数据没有 `.gr2` 那种外部 oracle(Granny DLL)。这里的「对拍」= 把 `Terrain.cpp` / `Area.cpp` > 的公式移植过来做自洽测试 + 与 `minimap.dds` 目视核对;正确性上限取决于移植是否忠实。 - 高度采样与原客户端三角插值公式对拍; - 法线与中心差分公式对拍; - 区块目录 id 和原点换算; - Metin2 → Godot 坐标和 YPR(AreaData `position` = 地图全局 cm,见 §5.6); - `attr.atr` 位标记; - water layer 和高度; - Property CRC 命中率和对象分类计数。 ### 11.3 Godot 冒烟测试 无窗口或固定窗口运行: - 加载单个 `000000`; - 加载 2×2 区块检查接缝; - 加载 A1 3×3 活动区域; - 实例化已知建筑 CRC; - 同时实例化玩家和 NPC; - 缺失单一资源时继续加载; - 反复 load/unload 后无节点和纹理持续增长。 ### 11.4 Golden 截图 至少维护以下固定相机: | Golden | 内容 | 主要检查 | |---|---|---| | `world-a1-terrain-top.png` | 俯视地形 | splat 分布、区块接缝 | | `world-a1-objects.png` | 村庄静态对象 | CRC、位置、旋转、贴图 | | `world-a1-lighting.png` | 无 HUD 场景 | 天空、雾、阴影、色调 | | `world-a1-r1.png` | 玩家第三人称 | R1 完整构图 | | `world-a1-r2.png` | 最终 HUD 画面 | 最终交付 | Godot 与原 Direct3D 渲染器不要求逐像素一致。门禁分两层: 1. 硬门禁:加载成功、对象计数、NaN、缺资源、空 mesh、接缝和崩溃; 2. 视觉门禁:SSIM/边缘结构、区域直方图与人工签核结合。 视觉比较前必须锁定:Godot 版本、renderer、窗口尺寸、相机 transform/FOV、时间、动画帧、随机种子、曝光、天气和资源索引优先级。 > **原客户端参考帧要「受控」采集,不能只拿一张随手截图。** `ingame-shinsoo.png` 只用于早期 > 目视对齐。正式视觉门禁需要:在原 D3D 客户端里到一个**已知全局坐标 + 已知 yaw/pitch + 已知 > FOV + 关 HUD**(或裁掉 HUD 区)的机位截图,把这组参数记进 `test/maps/` 的机位 preset, > Godot 侧用 §6 的坐标转换复现同一机位。原客户端若无法脚本化摆机位,则退而用「同一地标、 > 人工尽量对齐 + 只比 ROI 结构/直方图、不比全图 SSIM」,并在报告里注明是人工对齐。 ## 12. 里程碑与依赖 ```text W0 格式/路径 ├─> W1 地形几何 ─> W2 splat/阴影 ─┐ ├─> W3 静态对象 ─> W4 树木 ──────┼─> W5 环境/光照 └─> 角色资源路径 ─> W6 玩家/相机 ─┘ └─> R1 R1 ─> W7 水/必要特效/HUD ─> R2 R1 ─> W8 流送/性能,可与 W7 并行推进 ``` > W0 和 §9-W4 的 `.spt` 调研都无 Godot 依赖,可在 Phase 2 的 bring-up 期间并行提前做;W1 及以后建议 > 等 bring-up 有结果(见开头「定位」)。上图是 bring-up 之后的阶段依赖,不是日历排期。 阶段完成定义: | 里程碑 | 完成条件 | |---|---| | M-W0 | A1 全部地图、Property 和依赖路径有结构化解析报告 | | M-W1 | 2×2 地形无高度/法线接缝,高度采样测试通过 | | M-W2 | 道路/草地与 minimap 对齐,shadowmap 正确 | | M-W3 | 最终机位内静态对象自动摆放且材质基本正确 | | M-W4 | 树木位置正确;proxy 的真实 bark/composite atlas、叶片透明、风摆和 MultiMesh 通过;最终 parity 另需 exporter 的真实几何/LOD | | M-W5 | 天空、雾、太阳、曝光和阴影达到参考风格 | | M-W6 | 玩家/NPC/相机组成 R1 golden | | M-W7 | 水、必要特效和 HUD 组成 R2 golden | | M-W8 | 3×3 流送稳定,并有 CPU/GPU/显存报告 | ## 13. 与 BACKLOG 的映射 | 本文阶段 | BACKLOG ID | 处理方式 | |---|---|---| | W0 | `E11`、`E12`、`G7`、`I5`、`H7`(+ `G1`/`G2`/`G3` 的前置) | Map formats、AssetResolver、PropertyRegistry、坐标模块、地图回归 | | W1/W2 | `E8` | 将 E8 拆为 geometry、splat、attr、shadow 四项 | | W3 | `E13`(+ `E12` 的 AreaData 部分) | AreaData/Property 对象实例化 | | W4 | `E10` | 当前运行时使用真实纹理 proxy;最终 parity 走可授权的 Windows x64 离线转换 | | W5 | E1/E2/E3/E4/E5、B1–B11 | 以最终机位出现的问题作为 R1 门禁 | | W6 | A1/A2、C2/C5、D1/D2、I4 | 完成角色装配和游戏相机 | | W7 | E7/E9、UI | `.mse` 只先做最终场景必要子集;UI 纳入 R2 | | W8 | C9、G2/G3/G5/G6、F4 | 正式流送、LOD 和平台性能 | 以下新 ID 已加入 [`BACKLOG.md`](./BACKLOG.md)(E / G / H / I 段): - E11:MapSetting/TextureSet/Environment 解析; - E12:区块二进制 + AreaData/AreaAmbienceData 解析; - E13:Property CRC 注册与五类对象实例化; - G7:跨 pack 虚拟路径索引与覆盖优先级; - H7:A1 地图格式/数值/golden 回归; - I5:地图坐标、全局坐标和 Godot 坐标统一转换。 上表左列引用的其余 ID(`E1`–`E10` / `B1`–`B11` / `A1`–`A2` / `C2`–`C9` / `D1`–`D2` / `G1`–`G6` / `F4` / `I4`)以 `BACKLOG.md` 的当前定义为准 —— W0 开工时先对一遍,该文件近期改动频繁,ID 含义可能已漂移。 ## 14. 风险与处理 | 风险 | 影响 | 处理 | |---|---|---| | pack 目录中存在大小写或重复虚拟路径 | 加载到错误资源 | 确定性索引、冲突报告、优先级快照 | | SpeedTree 格式/运行时授权不可用 | 无法获得原始几何/LOD | 保留当前真实纹理 proxy 或使用授权清晰的替代 Mesh;不在运行时分发专有库 | | GR2 材质数据仍不完整 | 建筑/叶片观感错误 | B1 前置,最终机位逐材质审计 | | 一图层一 pass draw call 高 | 地形 GPU 开销大 | 先保真,再以 RGBA/array 合批并对比 golden | | 所有静态模型走现有角色节点 | 内存和加载时间爆炸 | 静态资源/实例分离、跨实例缓存 | | 坐标/旋转符号错误 | 建筑镜像、道路错位 | 单一转换模块 + 已知对象数值测试 | | DDS 只解 level 0 | 远景闪烁、显存高 | 恢复完整 mip,平台格式转 BC/ASTC | | UI 抢占世界渲染进度 | R1 长期不可验收 | R1/R2 分级,HUD 不阻塞 R1 | | 原资源许可不明确 | 无法分发 | 代码与资产分离,发布前完成许可审查 | ## 15. 最终验收清单 ### R1:世界场景 - [ ] 直接读取 `assets/`,没有手工重建村庄布局; - [ ] A1 地形数据(`MapSize`,示例 4×5)可解析,最终机位周围至少加载 3×3; - [ ] 地形高度、法线和区块边界无明显错误; - [ ] 道路/草地/石地分布与 minimap 和参考图一致; - [ ] 地形烘焙阴影正确; - [ ] 建筑、围栏、灯柱等由 AreaData + Property 自动实例化; - [x] 树木按 AreaData 位置放置,使用真实 bark/composite atlas、alpha-scissor、风摆和 MultiMesh; - [ ] 代表树种经离线 exporter 获得真实 branch/frond/leaf、尺寸、LOD/impostor,并通过最终机位轮廓验收; - [ ] 玩家和 NPC 的比例、贴图、动画和阴影正常; - [ ] 第三人称机位与参考图相近且可确定性复现; - [ ] 天空、雾、阳光、曝光和饱和度达到签核标准; - [ ] 固定机位 `world-a1-r1.png` 通过结构化对比和人工评审。 ### R2:最终画面 - [ ] 可见水域正确; - [ ] 最终机位中必要 `.mse` 环境效果正常; - [ ] 小地图、状态栏、快捷栏、背包和头顶文字完成; - [ ] 1920×1080 UI 布局不遮挡错误区域; - [ ] 场景可持续移动浏览,无明显区块加载卡顿; - [ ] `world-a1-r2.png` 达到最终视觉签核; - [ ] macOS 性能报告、缺失资源报告和测试结果归档; - [ ] 发布物不意外包含无授权的原始资产。 ## 16. 开发起点 > **W0 + §9-W4 的 `.spt` 调研可以在 Phase 2 的 bring-up 期间并行做**(纯调研 / 无 Godot 依赖); > **W1 及以后建议等 bring-up 有结果**(见开头「定位」)。 第一批代码只做 W0(无 Godot 依赖),不应立刻编写水 shader 或 HUD: 1. 增加 `AssetResolver`; 2. 增加六个无 Godot 依赖的地图格式 parser; 3. 为真实 A1 文件建立单元测试和 load report; 4. 固化坐标转换(`mtgodot::coord`,见 I5)。 W0 的 parser 报告和坐标测试通过后,第一个 W1 切片才是「在 `Metin2World` 中只加载 `000000`、输出灰色地形」。 灰色地形 + W0 快照记录的对象数 + 已知 CRC 都通过测试后,再进入 W2 的 splat shader。这个顺序能最早暴露 路径、格式、坐标和索引错误,并避免在错误的数据基础上反复调整画面。