实机故障:进入游戏后整屏灰紫,主角还是蓝色胶囊占位体,2D UI 与小地图正常。 根因(已实证,非推测)。`EntityStore::mut_despawn()` 在 despawn 的 vid 等于 主角 vid 时把 `m_main_vid` 清零。40250 服务器在 spawn-in 头几秒会对玩家自己的 vid 反复下发 GC_CHARACTER_DEL + GC_CHARACTER_ADD(视野重算,实测约 10 次)。 第一次 DEL 就把玩家永久去主角化:`get_main_vid()` 返回 0 → `_sync_main_character()` 直接 return → 建模代码从未跑过(main_attempts=0)→ 占位胶囊留在 y=0 → 相机跟着它钻到 C1 地形底下 → 满屏灰紫。地图其实一直是好的 (chunks_built 20/20、objects_placed 565)。 参考端怎么做(40250 ClientVS22)。主角身份属于 `CNetworkActorManager::m_dwMainVID`, 只由 `SetMainActorVID()`(GC_MAIN_CHARACTER)写,只在管理器析构时清; `RemoveActor()` 删 actor 行和角色实例,但绝不碰它;重新加入时 `__AppendCharacterManagerActor()` 用 `kCreateData.m_isMain = __IsMainActorVID(dwVID)` 重新推导,并再跑一次 `SetMainCharacterIndex(dwVID)`。 修法(entity_store.cpp): - `mut_despawn()` 只删行、只发 Despawn,不动 m_main_vid;m2dev 分支的 GC_CHARACTER_DEL 同规则。 - `mut_spawn_full()` 在 `e.vid == m_main_vid` 时把 `is_main` 置回 true, 并重新广播 MainSet,对齐参考端的重新推导。 顺带落地的渲染修复(同一次实机排查中定位): - libgr2 `sample_pose()` 增加 `root_offset` 形参;`Metin2AnimPlayer::apply_pose()` 显式传单位阵而不是模型的 InitialPlacement。Metin2 动画 gr2 的根骨轨道自带骨盆 高度,再乘一次 IP 会把整个人抬高一个骨盆高(武 99.4cm / 刺 92.6 / 巫 103.7 / 萨 95.5,正好各自的 IP.z)——即此前的「人物悬空」。`bind_pose()` 语义不变。 - `Metin2Model::_load_hair()` 改用与身体/武器同一条 `make_material()` (specular_disabled)而不是 roughness=1.0 的 StandardMaterial3D,头发不再被 PBR 高光洗成奶白色块。 取证与回归工具: - `project/net_trace.gd`(新):主角 vid 生命周期取证。挂在 AppFlow 之后逐帧读 `get_main_vid()`,归零时打印 `NETTRACE: MAIN_LOST was=.. now=0 reason=..` 加最近 24 条环形事件;`reason` 区分 despawn 与 silent(reset_for_map_change 清空 m_changes,是静默清零)。`MT_NETTRACE=1` 打开逐事件日志。 - `game_scene.gd`:诊断 tick 支持重复采样(分辨主角装配是「没跑」还是「没跑完」), 新增 main_sync_ready / main_loading / main_view_key / main_map_vid / main_retry_in_ms 字段;`MT_DIAG_SHOT=<png>` 让游戏自己存一帧实机画面 (screencapture 拍到的是终端窗口,没用)。 - `project/model_render_test.gd` + `test/rendering/model_baseline.json`(新): 八个种族的模型渲染基线(--bless 重新落盘)。 - `project/char_bench_test.gd`(新):无网络无地图的单角色实验台,五变体 × 六机位出证据图,只产图不做阈值判定。 - `project/package_render_test.gd`(新):导出包内的渲染自检入口 (导出模板不接受 --script,只能走 MT_TEST_MODE)。 - `/project/build/` 加入 .gitignore(渲染用例的落图输出)。 实机复验(真实服务器,非模拟): - 修前 `LIVE_SMOKE FAIL: 主角色 VID 有效`; `NETTRACE: MAIN_LOST was=25910 now=0 reason=despawn t=+7737ms`, 环里紧跟 `spawn vid=25910 is_main=false`。 - 修后 `LIVE_SMOKE RESULT: PASS`,main_vid=25933、entities=19; 40s 实机跑 16 次诊断采样全部 model_built=True / main_attempts=1 / main_sync_ready=True,player_pos 落在 C1 地形上而非 y=0; MT_DIAG_SHOT 存下的帧里地形、建筑、NPC、树木、真实角色模型、HUD、小地图俱全。 回归:gamescene_test / equip_model_test / gamescene_visual_test(feet_gap 0.001373)/ model_render_test(8 races)/ char_select_visual_test / 包内 MT_TEST_MODE=render 全绿;ctest 22/23,唯一失败 net.classic_session 已用 「还原本次改动 → 重跑 → diff 失败集合」确认与本次修改无关(失败集合完全相同)。 注:画面整体偏暗属于光照/色调差距,受 PARITY-GAP.md §0 门禁约束(对照帧未落盘前 不做相机/光照/色调/材质调参),本次不碰。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJugvEJwz3FK4hw9ti3SRb
244 lines
9.3 KiB
GDScript
244 lines
9.3 KiB
GDScript
# R2-01 角色实验台 —— 无网络、无地图,只装配一个角色并从固定机位取近景。
|
|
#
|
|
# godot --path project --script char_bench_test.gd -- --race 0
|
|
# godot --path project --script char_bench_test.gd -- --race 0 --variant no_hair
|
|
# godot --path project --script char_bench_test.gd -- --body <gr2> --hair <gr2> --skin <dds>
|
|
#
|
|
# 目的是把「发型挡脸」拆成几何问题和材质/深度问题:同一机位分别渲染
|
|
# full 身体+发型(生产路径)
|
|
# no_hair 只有身体(脸是否本来就正确)
|
|
# hair_only 只有发型(发型网格实际落在哪、包围盒多大)
|
|
# unlit 身体+发型,禁用光照/阴影的诊断材质(排除光照与阴影影响)
|
|
# wire 身体+发型,线框(几何形状判定)
|
|
# 每个变体输出 front/side/back/face/feet 五个机位,附 report.json 记录
|
|
# 表面数、包围盒、材质模式和资源哈希。它不做像素阈值判定,只产证据。
|
|
extends SceneTree
|
|
|
|
const PlayerView = preload("res://ui/player_view.gd")
|
|
|
|
var _out := ""
|
|
var _report := {}
|
|
# 生产材质就挂在 surface override 上(cpu_skin 每帧重挂)。冻结动画后 cpu_skin
|
|
# 不再跑,所以变体切换必须把它们存下来再还原 —— 早先用 null "还原" 等于把贴图
|
|
# 材质丢掉,五个变体全渲成白模。
|
|
var _base_mats: Array = []
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("run")
|
|
|
|
func _arg(name: String, dflt := "") -> String:
|
|
var args := OS.get_cmdline_user_args()
|
|
var i := args.find("--" + name)
|
|
return args[i + 1] if i >= 0 and i + 1 < args.size() else dflt
|
|
|
|
func _flag(name: String) -> bool:
|
|
return ("--" + name) in OS.get_cmdline_user_args()
|
|
|
|
func run() -> void:
|
|
root.size = Vector2i(720, 720)
|
|
_out = OS.get_environment("MT_RENDER_OUTPUT")
|
|
if _out.is_empty():
|
|
_out = ProjectSettings.globalize_path("res://../build/rendering/charbench-%d"
|
|
% Time.get_unix_time_from_system())
|
|
DirAccess.make_dir_recursive_absolute(_out)
|
|
|
|
var race := int(_arg("race", "0"))
|
|
var assets := AssetRoot.path()
|
|
var pv := PlayerView.new()
|
|
root.add_child(pv)
|
|
if not pv.build(assets, race):
|
|
printerr("CHARBENCH FAIL: build(race=%d) returned false" % race)
|
|
quit(1)
|
|
return
|
|
|
|
# 允许覆盖具体身体/发型/贴图,以便复现用户出错的那一套。
|
|
var body := _arg("body")
|
|
if not body.is_empty(): pv.set("gr2_path", body)
|
|
var hair := _arg("hair")
|
|
if not hair.is_empty(): pv.set("hair_gr2", hair)
|
|
var skin := _arg("skin")
|
|
if not skin.is_empty(): pv.set("hair_skin", skin)
|
|
|
|
var anim := _arg("anim", "wait")
|
|
pv.set_anim_state(anim)
|
|
var sample := float(_arg("time", "0.5"))
|
|
await process_frame
|
|
await process_frame
|
|
if pv.anim:
|
|
pv.anim.call("set_time", sample)
|
|
pv.anim.set_process(false)
|
|
pv.rotation = Vector3.ZERO
|
|
|
|
_light()
|
|
var cam := Camera3D.new()
|
|
cam.fov = 40.0
|
|
cam.near = 0.02
|
|
root.add_child(cam)
|
|
cam.make_current()
|
|
|
|
_report = {
|
|
"race": race, "anim": anim, "sample_seconds": sample,
|
|
"body_gr2": String(pv.model.get("gr2_path")),
|
|
"hair_gr2": String(pv.model.get("hair_gr2")),
|
|
"hair_skin": String(pv.model.get("hair_skin")),
|
|
"ground_offset": pv.model.position.y,
|
|
"model_info": String(pv.model.call("get_info")),
|
|
"engine": Engine.get_version_info(),
|
|
"viewport": str(root.size),
|
|
"scope": "isolated bench: no map, no network, no 40250 pixel comparison",
|
|
"variants": {},
|
|
}
|
|
|
|
var variants := PackedStringArray(["full", "no_hair", "hair_only", "unlit", "wire"])
|
|
var only := _arg("variant")
|
|
if not only.is_empty():
|
|
variants = PackedStringArray([only])
|
|
for v in variants:
|
|
await _shoot(pv, cam, v)
|
|
|
|
_report["sha256"] = _hashes(pv)
|
|
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
|
|
f.store_string(JSON.stringify(_report, "\t"))
|
|
f.close()
|
|
print("CHARBENCH: ", _out)
|
|
print(JSON.stringify(_report))
|
|
quit(0)
|
|
|
|
func _light() -> void:
|
|
var sun := DirectionalLight3D.new()
|
|
sun.rotation_degrees = Vector3(-42, 155, 0)
|
|
sun.light_energy = 1.1
|
|
sun.set_layer_mask(0xFFFFF) # CharacterLight 层:模型 mi 用 layer 2
|
|
root.add_child(sun)
|
|
var env := WorldEnvironment.new()
|
|
var e := Environment.new()
|
|
e.background_mode = Environment.BG_COLOR
|
|
e.background_color = Color(0.15, 0.16, 0.2)
|
|
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
|
e.ambient_light_color = Color(0.6, 0.62, 0.68)
|
|
e.ambient_light_energy = 0.8
|
|
env.environment = e
|
|
root.add_child(env)
|
|
|
|
# 每个变体:改可见性/材质,然后跑五个机位。
|
|
func _shoot(pv: Node3D, cam: Camera3D, variant: String) -> void:
|
|
var meshes := _meshes(pv)
|
|
var body_mi: MeshInstance3D = null
|
|
for mi in meshes:
|
|
if mi.name == "MeshInstance3D": body_mi = mi
|
|
if body_mi == null:
|
|
printerr("CHARBENCH: no body MeshInstance3D for variant ", variant)
|
|
return
|
|
# 身体 surface 数(发型折进同一个 mesh 的尾部 surface)。
|
|
var total := body_mi.mesh.get_surface_count()
|
|
if _base_mats.size() != total:
|
|
_base_mats.clear()
|
|
for s in total:
|
|
_base_mats.append(body_mi.get_surface_override_material(s))
|
|
var hair_first := int(_report.get("hair_surface_first", -1))
|
|
if hair_first < 0:
|
|
hair_first = _hair_surface_first(pv, body_mi)
|
|
_report["hair_surface_first"] = hair_first
|
|
|
|
_restore(body_mi, total)
|
|
var is_hair := func(s: int) -> bool: return hair_first >= 0 and s >= hair_first
|
|
match variant:
|
|
"no_hair":
|
|
for s in total:
|
|
if is_hair.call(s): body_mi.set_surface_override_material(s, _hidden())
|
|
"hair_only":
|
|
for s in total:
|
|
if not is_hair.call(s): body_mi.set_surface_override_material(s, _hidden())
|
|
"unlit":
|
|
for s in total:
|
|
body_mi.set_surface_override_material(s,
|
|
_diag(Color(1, 0.35, 0.2) if is_hair.call(s) else Color(0.35, 0.6, 1.0), false))
|
|
"wire":
|
|
for s in total:
|
|
body_mi.set_surface_override_material(s,
|
|
_diag(Color(1, 0.35, 0.2) if is_hair.call(s) else Color(0.35, 0.6, 1.0), true))
|
|
|
|
var aabb: AABB = body_mi.get_aabb()
|
|
var vr := {"surfaces": total, "hair_surface_first": hair_first,
|
|
"local_aabb": str(aabb), "shots": {}}
|
|
for shot in _shots():
|
|
cam.global_transform = Transform3D(Basis(), shot["eye"] as Vector3) \
|
|
.looking_at(shot["at"] as Vector3, Vector3.UP)
|
|
cam.fov = float(shot["fov"])
|
|
await RenderingServer.frame_post_draw
|
|
await RenderingServer.frame_post_draw
|
|
var name := "%s-%s.png" % [variant, shot["name"]]
|
|
var err := root.get_texture().get_image().save_png(_out.path_join(name))
|
|
vr["shots"][String(shot["name"])] = name if err == OK else "SAVE_FAILED"
|
|
_report["variants"][variant] = vr
|
|
_restore(body_mi, total)
|
|
|
|
func _restore(body_mi: MeshInstance3D, total: int) -> void:
|
|
for s in total:
|
|
body_mi.set_surface_override_material(s, _base_mats[s] if s < _base_mats.size() else null)
|
|
|
|
# 头顶约 1.74 m;机位单位是米,正面在 +Z。
|
|
func _shots() -> Array:
|
|
return [
|
|
{"name": "front", "eye": Vector3(0, 1.0, 3.1), "at": Vector3(0, 0.9, 0), "fov": 40},
|
|
{"name": "side", "eye": Vector3(3.1, 1.0, 0), "at": Vector3(0, 0.9, 0), "fov": 40},
|
|
{"name": "back", "eye": Vector3(0, 1.0, -3.1), "at": Vector3(0, 0.9, 0), "fov": 40},
|
|
{"name": "face", "eye": Vector3(0, 1.68, 0.75), "at": Vector3(0, 1.62, 0), "fov": 32},
|
|
{"name": "face_side", "eye": Vector3(0.75, 1.68, 0.05), "at": Vector3(0, 1.62, 0), "fov": 32},
|
|
{"name": "feet", "eye": Vector3(0.35, 0.30, 0.75), "at": Vector3(0, 0.06, 0), "fov": 32},
|
|
]
|
|
|
|
func _meshes(pv: Node3D) -> Array:
|
|
return pv.find_children("*", "MeshInstance3D", true, false)
|
|
|
|
# 发型被折进 body mesh 的末尾若干 surface。清 hair_gr2 再数一遍是不行的:
|
|
# surface 数要等下一次 cpu_skin 才变,读回来还是折叠后的值(旧实现因此恒返回
|
|
# -1,所有变体渲染成同一张图)。get_info() 报的 surfaces= 是身体 gr2 自己的
|
|
# surface 数,与折叠无关,直接拿它当分界。
|
|
func _hair_surface_first(pv: Node3D, body_mi: MeshInstance3D) -> int:
|
|
if String(pv.model.get("hair_gr2")).is_empty():
|
|
return -1
|
|
var total := body_mi.mesh.get_surface_count()
|
|
var info := String(pv.model.call("get_info"))
|
|
var body_surfaces := -1
|
|
for token in info.split(" ", false):
|
|
if token.begins_with("surfaces="):
|
|
body_surfaces = int(token.substr(9))
|
|
if body_surfaces <= 0 or body_surfaces >= total:
|
|
printerr("CHARBENCH: cannot split hair surfaces (info=%s, mesh surfaces=%d)" % [info, total])
|
|
return -1
|
|
return body_surfaces
|
|
|
|
func _hidden() -> StandardMaterial3D:
|
|
var m := StandardMaterial3D.new()
|
|
m.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
|
|
m.no_depth_test = false
|
|
m.render_priority = 0
|
|
m.albedo_color = Color(0, 0, 0, 0)
|
|
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
m.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
|
return m
|
|
|
|
func _diag(c: Color, wire: bool) -> StandardMaterial3D:
|
|
var m := StandardMaterial3D.new()
|
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
m.albedo_color = c
|
|
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
if wire:
|
|
m.albedo_color = Color(c.r, c.g, c.b, 0.35)
|
|
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
m.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
|
return m
|
|
|
|
func _hashes(pv: Node3D) -> Dictionary:
|
|
var h := {}
|
|
for p in ["res://ui/player_view.gd", "res://char_bench_test.gd",
|
|
"res://bin/libmtgodot.macos.template_debug.dylib"]:
|
|
h[p] = FileAccess.get_sha256(p)
|
|
for prop in ["gr2_path", "hair_gr2", "hair_skin"]:
|
|
var path := String(pv.model.get(prop))
|
|
if not path.is_empty() and FileAccess.file_exists(path):
|
|
h[path] = FileAccess.get_sha256(path)
|
|
return h
|