Files
mtgodot-poc/docs/SHINSOO-WORLD-RENDERING.md
T
shenleiandClaude Opus 5 fb4d2d222b docs: 文档审计——修正失效链接与过期结论
按 docs/ 目录逐篇核对,只改与事实/路径不符的部分,不动尚未验证的计划条目:

- `docs/steps/**` 在早前整理时已迁到 `docs/reference/steps/**`,全仓的旧路径
  引用(PLAN.md / README.md / M0-gr2-reader.md 及 libgr2 的头注释)一并改正。
- CLIENT-GAP.md / CLIENT-GAP-FIX.md / BACKLOG.md / MIDREVIEW.md:把已经落地的
  条目从「待办」改为已完成,删掉与代码现状矛盾的描述。
- ANDROID-TESTING.md / CLIENT-PORT.md / CLIENT-ROADMAP.md / GODOT-POC-PLAN.md /
  SHINSOO-WORLD-RENDERING.md / PARITY-GAP.md:同上,另补当前实际的构建/测试入口。
- THIRD-PARTY.md:补齐实际在用的第三方来源与许可说明。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJugvEJwz3FK4hw9ti3SRb
2026-09-08 17:15:17 +09:00

1104 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 神兽国 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`(原客户端里数千行)。
>
> | 阶段 | 单人粗估 |
> |---|---|
> | W0AssetResolver + 6 个格式 parser + 坐标 + 单测) | 23 周 |
> | W1W2(地形几何 + splat + 烘焙阴影) | 35 周 |
> | W3Property/AreaData 静态对象) | 23 周 |
> | W4(树木;含 `.spt` 工具链未知,见 §9 W4 | 2–6 周 |
> | W5W6(环境/光照 + 玩家/NPC/相机) | 34 周 |
> | R1 合计 | **≈ 3.55.5 月** |
> | W7(水 + 必要 `.mse` + HUD | 36 周 |
> | W8(流送 + 缓存 + 移动端性能) | 4–8 周(含一加 13 / iPhone 16 实测) |
> | R2 合计(在 R1 之上) | **≈ +23.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×131little-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 RGB565131072 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 <n>`(非老版
> `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+ = `<key>\t"<value>"``propertyname` / `propertytype` +
类型专属键)。A1 扫出 1330 CRCBuilding 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-upGodot 使用米和 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 rendererVulkan / 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-8m2dev 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/watermagic 2634/5426 + 尺寸校验,真值取自 `Terrain.cpp`
> - `environment``.msenv` Group/List 文本树;A1 `foglevel 6` + 10 行 Gradient
> - `property``.prb/.prt/.pre/.prd/.pra` + CRC 注册表:A1 扫出 1330 CRCBuilding 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 一致),地图外返回 0RESULT 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 主线程同步构建。** W1W7 的区块解析 + 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 W3Property 驱动的静态场景
目标:不用手工摆放即可恢复村庄建筑和摆件。
> **进度(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 个共享 mesh0 缺模型**
> 6 个跳过(Effect/Ambience368 个 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` 的每条 AreaDataW0 实测 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 阴影 + 收紧 cascade500m+ bias 调;
> `adjustment` 饱和 1.22 / 对比 1.10;开轻 glowHDR 溢出)+ 轻 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/speedlens flareR2);烘焙 `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`+ NPCshaman`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/<lang>/ui/` 读取;
- 小地图由各区块 `minimap.dds` 拼接并按玩家全局坐标定位;先保持 north-up、旋转玩家 heading 箭头,
只有原客户端配置/受控帧证明整图旋转时才旋转地图;
- 背包和快捷栏只要求展示与基础开关,不要求服务端物品逻辑;
- UI 在 1920×1080 下先做像素对齐,再补缩放策略;
- 字体、描边、层级和透明度需接近参考图。
验收:最终帧包含参考图中相同类别的 HUD 元素,并且不改变三维场景机位和曝光。
### Phase W8:流式加载、缓存和性能
目标:从固定截图样板变成可稳定游览的 A1 地图。
> **进度(2026-08-29)—— W8 首版**。`Metin2World` 重构成**逐区块自包含**:每个区块一个
> `Chunk_<id>` 根节点,挂 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 → ArrayMesh119 份)、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),稳定 30fpsheadless 上限),
> ~34ms/帧,draw 450550,显存 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` 那种外部 oracleGranny DLL)。这里的「对拍」= 把 `Terrain.cpp` / `Area.cpp`
> 的公式移植过来做自洽测试 + 与 `minimap.dds` 目视核对;正确性上限取决于移植是否忠实。
- 高度采样与原客户端三角插值公式对拍;
- 法线与中心差分公式对拍;
- 区块目录 id 和原点换算;
- Metin2 → Godot 坐标和 YPRAreaData `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、B1B11 | 以最终机位出现的问题作为 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 段):
- E11MapSetting/TextureSet/Environment 解析;
- E12:区块二进制 + AreaData/AreaAmbienceData 解析;
- E13Property CRC 注册与五类对象实例化;
- G7:跨 pack 虚拟路径索引与覆盖优先级;
- H7A1 地图格式/数值/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。这个顺序能最早暴露
路径、格式、坐标和索引错误,并避免在错误的数据基础上反复调整画面。