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

55 KiB
Raw Blame History

神兽国 A1 场景最终渲染开发文档

状态:实施规格(Implementation Specification
目标工程:metin2-client
资源来源:仓库内 assets/gitignoredMT_ASSETS 可覆盖)
原客户端行为参考:同级仓库 m2dev-client-src-main/src
目标参考图:ingame-shinsoo.png,1920×1080 的神兽国村庄第三人称画面

定位(先读)

这不是 Phase 1 / POC 收尾,是「正式移植」的一个独立子项目。 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 26 周
W5W6(环境/光照 + 玩家/NPC/相机) 34 周
R1 合计 ≈ 3.55.5 月
W7(水 + 必要 .mse + HUD 36 周
W8(流送 + 缓存 + 移动端性能) 48 周(含一加 13 / iPhone 16 实测)
R2 合计(在 R1 之上) ≈ +23.5 月

叠加 Phase 2 的 bring-up。多人并行可压缩 W1/W3/W4/W6,但 W0 和坐标模块是串行硬前置。

距参考图「目视等价」的保真化剩余量,见 PARITY-GAP.md(本文管「怎么渲出来」, 那文管「怎么做到参考图那样」)。

1. 文档目的

本文定义在「Phase 2 三设备 bring-up 完成」之后,从当前“单个 .gr2 角色展示 POC”推进到“能够直接加载原始客户端资源并渲染神兽国 A1 村庄画面”的完整开发方案(前置见上方「定位」)。

本文是实现和验收依据,解决以下问题:

  1. 哪些数据直接来自 assets/
  2. 每种地图资源如何解析并转换为 Godot 运行时对象;
  3. 新增模块与现有 Metin2Modellibgr2、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 回归能力。

对应实现主要位于:

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 中 E8、E9、E10、E7 的判断正确,但它们不足以覆盖地图渲染的全部前置:“地图文本格式”“区块二进制 + AreaData”“Property CRC 注册表”“虚拟资产解析器”“坐标统一”“地图回归”是独立工作项,已在 BACKLOG.md 补为 E11E13 / G7 / H7 / I5,不是 E8 内部可以忽略的小步骤。

4. 资源使用原则

4.1 直接读取原始资源

运行时默认直接读取:

assets/

禁止为了构建一张演示图而手工重新摆放整座村庄。地形和对象位置必须由原地图数据驱动。

允许生成派生缓存,例如:

  • 解码后的 DDS GPU 纹理缓存;
  • tile.raw 生成的 splat alpha/权重纹理;
  • GR2 转换后的静态 ArrayMesh
  • 树木离线转换出的普通 Mesh
  • 地图区块碰撞和导航缓存。

派生缓存必须可删除并从原始 assets 重建,不能成为新的唯一数据源。

4.2 虚拟路径不能简单拼接

资源内部经常使用:

d:\ymir work\zone\...
d:/ymir work/environment/...

真实散文件按原 eterpack 名称分散在多个一级目录,例如:

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 地图数据规格

目标地图位于:

assets/OutdoorA1/metin2_map_a1

5.1 全局设置

setting.txt 的当前数据为:

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 区块目录编号

区块目录编号采用:

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 uint1634322 B 含一圈边界样本的高度图
tile.raw 258×258uint866564 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.hTerrain.cpp 为真值来源。

5.4 TextureSet

A1 使用:

assets/textureset/textureset/metin2_a1.txt

当前文件含 17 个图层。每个图层记录:

texture_path
u_scale
v_scale
u_offset
v_offset
splat_enabled
height_begin
height_end

索引 0 是原客户端内部的空图层;资源文件的 Texture001 对应运行时索引 1。不得产生一位偏移错误。

5.5 Environment

A1 环境资源可由虚拟路径解析到:

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 Gradientformats/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 每个对象按以下字段解析:

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.cpp001003 的全局断言。

所有 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.cppPropertyRegistry 已实现,A1 areadata 的 136 个 CRC 全部命中。

支持的原始对象类型:

PropertyType 扩展名 关键资源字段 R1 处理
Tree .prt TreeFileTreeSizeTreeVariance 转换树木并实例化
Building .prb BuildingFileShadowFlag 用 GR2 静态渲染器实例化
Effect .pre EffectFile R1 可占位,R2 必须实现必要效果
Ambience .pra 声音与播放参数 不影响 R1 画面
DungeonBlock .prd 模型和属性文件 按静态建筑处理

解析和实例化行为以 Area.cppProperty.cppMapType.cpp 为依据。

6. 坐标系统

Metin2 资源使用厘米和 Z-upGodot 使用米和 Y-up。全项目只能有一套转换:

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 中复制不同公式。

建议新增:

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. 目标运行时架构

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)

数据流:

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 建议新增源码布局

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 节点:

Metin2World : Node3D

建议暴露:

@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

信号:

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_tokvecLoadMultipleTextData 等价)、m2_coordI5:坐标 / 单位 / 区块编号)
  • map_settingsetting.txt)、texture_set17 层)、area_dataareadata / ambience / property
  • terrain_filesheight/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_godotYPR→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.txtareaambiencedata.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_a1minimap.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.gdA1 20/20 区块建成 MeshInstance3D 165ms),sample_height(128,128)=175.7m(与 areadata z≈178m 一致),地图外返回 0RESULT PASS。 打磨(2026-08-29:每区块 StaticBody3D + HeightMapShape3D129²,缩放承载格距)已加。 剩余: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_GenerateSplattile.raw → 每图层 258² alpha + 1px 羽化)。 extension/src/terrain_splat.{h,cpp} = build_chunk_terrain_material():当前把图层 alpha 打进 ≤2 张 RGBA8 权重贴图;图层色 DDS(dxt.cpp)解码 + 强制 resize 512² + 运行时 mipmap 后堆成 Texture2DArray ShaderMaterialSRC_TERRAIN)逐图层按权重 mixUV 按 TextureLayer.u_scale/v_scale/offset 平铺,shadowmap.dds 作 albedo 乘法项(有则),光照交给 Godot DirectionalLight窗口渲染验证project/world_shot.gdtest/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_bridgebuild_parts / build_mesh,逐 surface 从 gr2 material bindingMesh::material_textures)取贴图,走 AssetResolver 定位 + dxt.cpp 解码,按真实路径缓存、跨实例共享同一 ArrayMesh,不建 Skeleton3D。 Metin2World::place_objects():遍历各区块 areadata → PropertyRegistry 查 CRC → Buildingbuildingfile / DungeonBlockdungeonblockfile → 实例化 MeshInstance3D。 A1 实测:975 个 areadata → 601 个 Building/DungeonBlock 实例,119 个共享 mesh0 缺模型 6 个跳过(Effect/Ambience368 个 Tree 走 W4)。 W3 尾巴已补:位置改用地图全局 cm(W0 误判为区块本地,见 §5.6 修正 —— 视觉核对时发现 建筑飘到地图外才抓到);ShadowFlag=0/空SHADOW_CASTING_OFFportal_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-upC·R·C⁻¹, C=rotate(-90°,X))—— 修了之前漏轴转换的 bugroll=朝向被当成绕 Godot Z 翻滚,影响 66% A1 物体)。 CTest 钉了 identity / roll=90→绕 Godot+Y / 正交性。 B1 材质数据(2026-08-29libgr2 MaterialInfomap_count / alpha_blendName~"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. BuildingDungeonBlock 复用 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 的 CRC569394331 是 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。

TreeSizeTreeVariance 必须参与实例尺度,随机结果以 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_objectB1(见下)的 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_diffuseenergy 按亮度,shadow=on
  • SkyBox.Gradient10 行)→ ProceduralSkyMaterial 三段色(zenith / horizon / ground
  • Material.AmbientAMBIENT_SOURCE_SKY energy + ambient_light_color
  • Fogfoglevel 6)→ 深度雾 begin≈225m / end≈925mfoglevel 越大起雾越近;首版误用 指数密度把全图刷白,已改深度雾)
  • 色调 = Filmic + adjustmentsaturation 1.12 / contrast 1.05

Metin2World load 时解析 A1.msenvresolver 定位)→ 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.gdSceneTree 脚本):Metin2World + 玩家(Metin2Model warrior + Metin2AnimPlayer+ NPCshamanwait.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-29NPC Label3D 名字牌;玩家发型挂接Metin2Model.hair_gr2,骨骼按名重映射 到 base skeleton,并入 CPU 蒙皮,alpha_scissorwarrior 74/74 骨匹配)—— 角色不再是秃头。 剩余:鼠标转视角交互壳、相机遮挡淡出、武器挂点、GPU 路径的 hair、确定性机位 preset。

实现:

  1. 复用 Metin2ModelMetin2AnimPlayer
  2. 正式补齐头发、武器和共享骨架附件;
  3. 玩家和 NPC 默认使用 GPU 自定义蒙皮;
  4. 玩家 Y 位置实时贴合 sample_height()
  5. attr.atrATTRIBUTE_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.wtrformats 已解析)逐层按 texel 行合并 span 生成掩膜网格,Y = heights[layer]·HeightScale·0.01;同层/区块为一个 mesh surface。 SRC_WATER 仍是程序化波纹法线 + 菲涅耳 alpha + 深/浅色 mix + 高 SPECULAR 太阳光斑。 A1 = 28 片水面 / 19 区块
  • HUDproject/hud.gdCanvasLayer)—— 小地图 = 4×5 张 minimap.ddsMetin2World.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.dds30.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.cppmesh effect 见 EffectMesh.cpplight 见 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 保留「一次全建」。
  • 缓存PropertyRegistry1330 CRC)、AssetResolver5.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=13×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. 里程碑与依赖

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 E11E12G7I5H7+ 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.mdE / G / H / I 段):

  • E11MapSetting/TextureSet/Environment 解析;
  • E12:区块二进制 + AreaData/AreaAmbienceData 解析;
  • E13Property CRC 注册与五类对象实例化;
  • G7:跨 pack 虚拟路径索引与覆盖优先级;
  • H7A1 地图格式/数值/golden 回归;
  • I5:地图坐标、全局坐标和 Godot 坐标统一转换。

上表左列引用的其余 IDE1E10 / B1B11 / A1A2 / C2C9 / D1D2 / G1G6 / 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 自动实例化;
  • 树木按 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。这个顺序能最早暴露 路径、格式、坐标和索引错误,并避免在错误的数据基础上反复调整画面。