Merge branch 'main' of http://192.168.21.200:8418/4v5u09Z5a4Yuc/metin2-client
# Conflicts: # docs/RENDERING-REPAIR-PLAN.md
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
# 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
|
||||
@@ -0,0 +1 @@
|
||||
uid://yu8c7genhjae
|
||||
@@ -7,8 +7,18 @@ extends Node
|
||||
|
||||
const AppFlow = preload("res://app_flow.gd")
|
||||
const LiveSmokeTest = preload("res://live_smoke_test.gd")
|
||||
const PackageRenderTest = preload("res://package_render_test.gd")
|
||||
const NetTrace = preload("res://net_trace.gd")
|
||||
|
||||
func _ready() -> void:
|
||||
# MT_TEST_MODE=render:包内离线渲染自检,不起 AppFlow、不连服务器。
|
||||
# 导出模板不接受 --script,这是导出包里唯一能跑离线渲染回归的入口。
|
||||
if OS.get_environment("MT_TEST_MODE") == "render":
|
||||
AssetPack.ensure()
|
||||
var t := PackageRenderTest.new()
|
||||
t.name = "PackageRenderTest"
|
||||
add_child(t)
|
||||
return
|
||||
# 打包 app 从 Finder/open 启动时没有 shell 环境;40250 服务器使用 classic
|
||||
# DH2/CTR 协议,因此默认固定到 classic,同时保留 MT_PROTOCOL 覆盖能力。
|
||||
if OS.get_environment("MT_PROTOCOL").is_empty():
|
||||
@@ -22,6 +32,13 @@ func _ready() -> void:
|
||||
af.name = "AppFlow"
|
||||
add_child(af)
|
||||
af.start(root)
|
||||
# 主角 vid 生命周期取证(见 net_trace.gd):排在 AppFlow 之后,保证每帧
|
||||
# 在 client 派发完信号之后再读 main_vid。
|
||||
if af.client != null:
|
||||
var trace := NetTrace.new()
|
||||
trace.name = "NetTrace"
|
||||
add_child(trace)
|
||||
trace.setup(af.client)
|
||||
if OS.get_environment("MT_TEST_MODE") == "smoke":
|
||||
var smoke := LiveSmokeTest.new()
|
||||
smoke.name = "LiveSmokeTest"
|
||||
|
||||
@@ -130,6 +130,19 @@ var _main_retry_at := 0
|
||||
var _main_placed_vid := 0
|
||||
var _main_map_vid := 0
|
||||
const MAIN_MODEL_MAX_ATTEMPTS := 3
|
||||
|
||||
# 实机「灰紫虚空 + 蓝胶囊」的取证钩子。离线自检(package_render_test.gd)在导出包里
|
||||
# 是全绿的,所以这个故障只出现在服务器驱动的进场路径上,而实机唯一的产物是一张截图。
|
||||
# 进游戏 DIAG_AFTER_MS 后如果地图或主角模型任一没落地,就把决定性的几个值打一次到
|
||||
# godot.log —— 让下一次实机复现直接给出原因,而不是再来一张截图。
|
||||
# MT_DIAG=1 时无论成败都打。
|
||||
const DIAG_AFTER_MS := 8000
|
||||
const DIAG_REPEAT_MS := 2000 # MT_DIAG=1 时连打几次,看主角装配是"没跑"还是"没跑完"
|
||||
const DIAG_REPEATS := 6
|
||||
var _diag_at := 0
|
||||
var _diag_done := false
|
||||
var _diag_left := 1
|
||||
var _diag_started := false
|
||||
const MAIN_MODEL_RETRY_MS := 1000
|
||||
var _audio: Node
|
||||
var _bgm_director: Node
|
||||
@@ -153,6 +166,8 @@ func setup(m2client: Node, assets_root: String,
|
||||
initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void:
|
||||
client = m2client
|
||||
_assets = assets_root
|
||||
var diag_after := int(OS.get_environment("MT_DIAG_AFTER_MS")) if OS.get_environment("MT_DIAG_AFTER_MS") != "" else DIAG_AFTER_MS
|
||||
_diag_at = Time.get_ticks_msec() + diag_after
|
||||
_ui_mobile = UiProfile.resolve() == UiProfile.Mode.MOBILE
|
||||
# GC_MAIN_CHARACTER only carries the server-space coordinates, not the map
|
||||
# folder. The old fixed A1 fallback puts characters from A2/A3 outside the
|
||||
@@ -658,6 +673,7 @@ func _process(_dt: float) -> void:
|
||||
# Without it the local player stays as the blue CapsuleMesh forever and the
|
||||
# camera follows an ungrounded placeholder, which makes the map look empty.
|
||||
_sync_main_character()
|
||||
_diag_tick()
|
||||
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
|
||||
# node keeps its listener at the origin and rebases active 3D instances.
|
||||
if _audio == null or player == null or not is_instance_valid(player):
|
||||
@@ -667,6 +683,54 @@ func _process(_dt: float) -> void:
|
||||
if world and world.has_method("get_ambience_sources") and _audio.has_method("update_ambience_sources"):
|
||||
_audio.update_ambience_sources(world.call("get_ambience_sources"))
|
||||
|
||||
# 进游戏若干秒后的一次性取证。只在「地图或主角没落地」时输出,正常局内不产生噪音。
|
||||
# 打印的每个值都对应 _build_world / _sync_main_character 里的一个具体分支,读日志的人
|
||||
# 不用再猜是资源没找到、扩展没注册、race 越界还是坐标落在了别的地图上。
|
||||
func _diag_tick() -> void:
|
||||
if _diag_done or _diag_at == 0 or Time.get_ticks_msec() < _diag_at:
|
||||
return
|
||||
var forced := OS.get_environment("MT_DIAG") == "1"
|
||||
if forced and not _diag_started:
|
||||
_diag_started = true
|
||||
_diag_left = DIAG_REPEATS
|
||||
_diag_left -= 1
|
||||
if _diag_left <= 0:
|
||||
_diag_done = true
|
||||
else:
|
||||
_diag_at = Time.get_ticks_msec() + DIAG_REPEAT_MS
|
||||
var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game()
|
||||
if not forced and (not in_game or (_model_built and _map_loaded())):
|
||||
_diag_done = true
|
||||
return
|
||||
var vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0
|
||||
var e: Dictionary = client.get_entity(vid) if vid != 0 and client.has_method("get_entity") else {}
|
||||
var d := {
|
||||
"in_game": in_game, "model_built": _model_built, "map_loaded": _map_loaded(),
|
||||
"map_path": map_path, "assets": _assets, "assets_exists": DirAccess.dir_exists_absolute(_assets),
|
||||
"main_vid": vid, "race": e.get("race", null), "pos": str(e.get("pos", "")),
|
||||
"pos_cm": str(e.get("pos_cm", "")), "entity_empty": e.is_empty(),
|
||||
"main_attempts": _main_attempts, "main_model_attempted": _main_model_attempted,
|
||||
"main_sync_ready": _main_sync_ready, "main_loading": _main_loading,
|
||||
"main_view_key": _main_view_key, "main_map_vid": _main_map_vid,
|
||||
"main_retry_in_ms": _main_retry_at - Time.get_ticks_msec(),
|
||||
"t_ms": Time.get_ticks_msec(),
|
||||
"classes": {"Metin2Model": ClassDB.class_exists("Metin2Model"),
|
||||
"Metin2World": ClassDB.class_exists("Metin2World")},
|
||||
"world_null": world == null,
|
||||
"map_report": world.call("get_load_report") if world != null else {},
|
||||
"player_pos": str(player.position) if player != null else "",
|
||||
"map_base_set": MapCoord.has_base(),
|
||||
"map_base_cm": str(MapCoord.to_server_cm(Vector3.ZERO)),
|
||||
}
|
||||
printerr("GAMESCENE_DIAG: ", JSON.stringify(d))
|
||||
# MT_DIAG_SHOT=<png>:最后一次 tick 存一张实机帧,用于人工核对渲染结果。
|
||||
var shot := OS.get_environment("MT_DIAG_SHOT")
|
||||
if not shot.is_empty() and _diag_done:
|
||||
await RenderingServer.frame_post_draw
|
||||
var img := get_viewport().get_texture().get_image()
|
||||
if img != null:
|
||||
img.save_png(shot)
|
||||
|
||||
# 会徽上传图源:优先 res://ui/default_guild_mark.png,没有就现造一张 16×12 占位。
|
||||
func _guild_mark_upload_image() -> Image:
|
||||
var p := "res://ui/default_guild_mark.png"
|
||||
|
||||
@@ -5,6 +5,10 @@ const GameScene = preload("res://game_scene.gd")
|
||||
const Fixtures = preload("res://gamescene_test.gd")
|
||||
var failed := false
|
||||
|
||||
# wait.gr2 @0.5s keeps the warrior planted; measured lowest vertex is +1.5 mm.
|
||||
# 3 cm leaves room for terrain interpolation without hiding a real float.
|
||||
const FEET_GAP_MAX := 0.03
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
@@ -39,13 +43,24 @@ func run() -> void:
|
||||
if scene._model_built and scene.player.anim:
|
||||
scene.player.anim.set_time(0.5)
|
||||
scene.player.anim.set_process(false)
|
||||
await process_frame # let the frozen pose reach cpu_skin before measuring
|
||||
# R2-01 grounding: the lowest skinned vertex must sit on the terrain, not
|
||||
# above it. Before the Offset4x4 fix the whole actor hovered one pelvis
|
||||
# height (warrior 0.994 m) up; this is the numeric guard for that.
|
||||
var feet_y := INF
|
||||
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh == null: continue
|
||||
feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y)
|
||||
var feet_gap := feet_y - expected.y
|
||||
failed = failed or not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX
|
||||
scene.cam.snap_to_target()
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await RenderingServer.frame_post_draw
|
||||
failed = failed or root.get_texture().get_image().save_png(output.path_join("game.png")) != OK
|
||||
var report := {"passed": not failed, "map": scene.map_path, "server_cm": str(server),
|
||||
"world_position": str(scene.player.position), "expected": str(expected),
|
||||
"surfaces": surfaces, "map_report": scene.world.get_load_report(),
|
||||
"surfaces": surfaces, "feet_y": feet_y, "feet_gap": feet_gap,
|
||||
"feet_gap_max": FEET_GAP_MAX, "map_report": scene.world.get_load_report(),
|
||||
"engine": Engine.get_version_info(), "viewport": str(root.size),
|
||||
"camera_transform": str(scene.cam.global_transform), "fov": scene.cam.fov,
|
||||
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bii82ax05bwuj
|
||||
@@ -0,0 +1 @@
|
||||
uid://dp333tqyl0dcg
|
||||
@@ -0,0 +1,423 @@
|
||||
# model_render_test —— 不启动游戏、不连服务器的模型渲染自动回归。
|
||||
#
|
||||
# godot --path project --script model_render_test.gd
|
||||
# godot --path project --script model_render_test.gd -- --race 0
|
||||
# godot --path project --script model_render_test.gd -- --bless # 重建基线
|
||||
#
|
||||
# 与 char_bench_test.gd 的区别:bench 只产图、判定靠人眼;这里对渲染结果算数值
|
||||
# 指标并断言,退出码即结论。覆盖范围仅限「离线装配的单角色」——没有地图、没有
|
||||
# 光照环境的真实动态、没有 40250 像素比对,因此它不能替代 gamescene_visual_test
|
||||
# 和导出包验收,只用来挡住模型/发型/材质改动引入的回归。
|
||||
#
|
||||
# 指标(每个 race):
|
||||
# feet_gap_m 最低蒙皮顶点到 y=0 的距离(悬空/陷地)
|
||||
# silhouette_px 正面剪影像素数(模型是否真的画出来了)
|
||||
# hair_mean_lum 只画发型时,发型像素的平均亮度(多余高光会把它抬上去)
|
||||
# hair_mean_lum_ctrl 同上,但换回旧的 StandardMaterial3D(roughness=1) —— 负对照,
|
||||
# 用来证明这个指标现在仍然分得开好坏,而不是恒过
|
||||
# head_cover full 与 no_hair 在近景机位的差异像素占比(发型遮挡量)
|
||||
#
|
||||
# 绝对断言不依赖基线;head_cover / silhouette_px 这类因模型而异的量对比
|
||||
# test/rendering/model_baseline.json。改模型或改光照后用 --bless 重建基线,
|
||||
# 并在计划书执行记录里写清为什么重建。
|
||||
extends SceneTree
|
||||
|
||||
const PlayerView = preload("res://ui/player_view.gd")
|
||||
const BASELINE := "res://../test/rendering/model_baseline.json"
|
||||
|
||||
# 渲染尺寸:指标在 GDScript 里逐像素统计,512 是精度和耗时的折中。
|
||||
const SIZE := 512
|
||||
const BG := Color(1, 0, 1) # 洋红背景,模型不会出现这个颜色
|
||||
|
||||
# --- 绝对阈值(不依赖基线)------------------------------------------------
|
||||
# 脚底:bench 无地形,模型自带 ground_offset,实测四职业 |gap| <= 0.003 m。
|
||||
const FEET_GAP_MAX := 0.03
|
||||
# 剪影:正面 512x512 里角色至少占这么多像素,防止「什么都没画」也算过。
|
||||
const SILHOUETTE_MIN := 3000
|
||||
# 发型高光用「发型像素的平均亮度」度量。发色本身明暗差别很大(武士近黑、
|
||||
# 修罗银白),所以没有跨职业通用的绝对上限:绝对判定只用下面的负对照差,
|
||||
# 具体数值按职业记进基线。旧材质相对新材质的实测抬升是 +0.011 ~ +0.037
|
||||
# (八个职业,见 test/rendering/README.md)。
|
||||
const LUM_BRIGHT := 0.20 # hair_lum_p99 只作参考量,不参与判定
|
||||
# 负对照必须比生产材质亮这么多,否则说明这条指标已经不分好坏了(例如有人把
|
||||
# 灯光调暗,两边都测不出高光)。八职业最小实测差 0.0109。
|
||||
const HAIR_CTRL_MARGIN := 0.008
|
||||
|
||||
# --- 基线容差 -------------------------------------------------------------
|
||||
const TOL_HEAD_COVER := 0.03 # 绝对
|
||||
const TOL_SILHOUETTE := 0.03 # 相对
|
||||
const TOL_HAIR_LUM := 0.006 # 绝对;最小的「旧材质」信号是 0.0109
|
||||
|
||||
var _out := ""
|
||||
var _bless := false
|
||||
var _fail: Array[String] = []
|
||||
|
||||
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 run() -> void:
|
||||
root.size = Vector2i(SIZE, SIZE)
|
||||
_bless = "--bless" in OS.get_cmdline_user_args()
|
||||
_out = OS.get_environment("MT_RENDER_OUTPUT")
|
||||
if _out.is_empty():
|
||||
_out = ProjectSettings.globalize_path("res://../build/rendering/modeltest-%d"
|
||||
% Time.get_unix_time_from_system())
|
||||
DirAccess.make_dir_recursive_absolute(_out)
|
||||
|
||||
var races: Array = []
|
||||
var only := _arg("race")
|
||||
if only.is_empty():
|
||||
races = [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
else:
|
||||
races = [int(only)]
|
||||
|
||||
var baseline := _load_baseline()
|
||||
var results := {}
|
||||
_light()
|
||||
var cam := Camera3D.new()
|
||||
cam.near = 0.02
|
||||
root.add_child(cam)
|
||||
cam.make_current()
|
||||
|
||||
for race in races:
|
||||
var m := await _measure(int(race), cam)
|
||||
if m.is_empty():
|
||||
_fail.append("race %d: build failed" % race)
|
||||
continue
|
||||
results[str(race)] = m
|
||||
_check_absolute(int(race), m)
|
||||
if not _bless:
|
||||
_check_baseline(int(race), m, baseline)
|
||||
|
||||
var report := {
|
||||
"passed": _fail.is_empty(),
|
||||
"failures": _fail,
|
||||
"blessed": _bless,
|
||||
"races": results,
|
||||
"thresholds": {
|
||||
"feet_gap_max": FEET_GAP_MAX, "silhouette_min": SILHOUETTE_MIN,
|
||||
"hair_ctrl_margin": HAIR_CTRL_MARGIN, "tol_hair_lum": TOL_HAIR_LUM,
|
||||
"tol_head_cover": TOL_HEAD_COVER, "tol_silhouette": TOL_SILHOUETTE,
|
||||
},
|
||||
"engine": Engine.get_version_info(),
|
||||
"viewport": str(root.size),
|
||||
"assets": AssetRoot.path(),
|
||||
"headless": DisplayServer.get_name() == "headless",
|
||||
"scope": "offline single-actor assembly; no map, no network, no 40250 pixel parity",
|
||||
"sha256": {"res://bin/libmtgodot.macos.template_debug.dylib":
|
||||
FileAccess.get_sha256("res://bin/libmtgodot.macos.template_debug.dylib"),
|
||||
"res://model_render_test.gd": FileAccess.get_sha256("res://model_render_test.gd")},
|
||||
}
|
||||
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
|
||||
f.store_string(JSON.stringify(report, "\t"))
|
||||
f.close()
|
||||
if _bless:
|
||||
_write_baseline(results)
|
||||
print("MODELRENDER: baseline written -> ", ProjectSettings.globalize_path(BASELINE))
|
||||
print("MODELRENDER: ", _out)
|
||||
print(JSON.stringify(report))
|
||||
for msg in _fail:
|
||||
printerr("MODELRENDER FAIL: ", msg)
|
||||
print("MODELRENDER: ", "PASS" if _fail.is_empty() else "FAIL")
|
||||
quit(0 if _fail.is_empty() else 1)
|
||||
|
||||
# --- 单个 race 的测量 -----------------------------------------------------
|
||||
|
||||
func _measure(race: int, cam: Camera3D) -> Dictionary:
|
||||
var pv := PlayerView.new()
|
||||
root.add_child(pv)
|
||||
if not pv.build(AssetRoot.path(), race):
|
||||
pv.queue_free()
|
||||
return {}
|
||||
pv.set_anim_state("wait")
|
||||
await process_frame
|
||||
await process_frame
|
||||
if pv.anim:
|
||||
pv.anim.call("set_time", 0.5)
|
||||
pv.anim.set_process(false)
|
||||
pv.rotation = Vector3.ZERO
|
||||
await process_frame
|
||||
|
||||
var body: MeshInstance3D = null
|
||||
for mi in pv.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.name == "MeshInstance3D":
|
||||
body = mi
|
||||
if body == null or body.mesh == null:
|
||||
pv.queue_free()
|
||||
return {}
|
||||
var total := body.mesh.get_surface_count()
|
||||
var base_mats: Array = []
|
||||
for s in total:
|
||||
base_mats.append(body.get_surface_override_material(s))
|
||||
var hair_first := _hair_surface_first(pv, body, total)
|
||||
|
||||
# 脚底:最低蒙皮顶点(bench 里地面就是 y=0)
|
||||
var feet_y := INF
|
||||
for mi in pv.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh:
|
||||
feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y)
|
||||
|
||||
var m := {
|
||||
"body_gr2": String(pv.model.get("gr2_path")),
|
||||
"hair_gr2": String(pv.model.get("hair_gr2")),
|
||||
"surfaces": total, "hair_surface_first": hair_first,
|
||||
"feet_gap_m": feet_y if is_finite(feet_y) else NAN,
|
||||
}
|
||||
|
||||
# 1) 正面剪影
|
||||
_aim(cam, Vector3(0, 1.0, 3.1), Vector3(0, 0.9, 0), 40.0)
|
||||
var front := await _grab("race%d-front" % race)
|
||||
m["silhouette_px"] = _count_fg(front)
|
||||
|
||||
# 2) 近景:full / no_hair 的差异 = 发型遮挡量
|
||||
_aim(cam, Vector3(0.75, 1.68, 0.05), Vector3(0, 1.62, 0), 32.0)
|
||||
var full := await _grab("race%d-face_side" % race)
|
||||
if hair_first >= 0:
|
||||
for s in range(hair_first, total):
|
||||
body.set_surface_override_material(s, _hidden())
|
||||
var nohair := await _grab("race%d-face_side-no_hair" % race)
|
||||
_restore(body, base_mats)
|
||||
var fg := _count_fg(nohair)
|
||||
m["head_cover"] = (float(_count_diff(full, nohair)) / float(maxi(fg, 1))) if hair_first >= 0 else 0.0
|
||||
m["head_px"] = fg
|
||||
|
||||
# 3) 发型亮度:只画发型,量 99 分位亮度;再用旧材质做负对照
|
||||
if hair_first >= 0:
|
||||
for s in range(0, hair_first):
|
||||
body.set_surface_override_material(s, _hidden())
|
||||
var hair_only := await _grab("race%d-hair_only" % race)
|
||||
var hs := _lum_stats(hair_only)
|
||||
m["hair_lum_p99"] = hs["p99"]
|
||||
m["hair_bright_frac"] = hs["bright_frac"]
|
||||
m["hair_mean_lum"] = hs["mean"]
|
||||
var legacy := _legacy_hair_material(base_mats[hair_first])
|
||||
for s in range(hair_first, total):
|
||||
body.set_surface_override_material(s, legacy)
|
||||
var ctrl := await _grab("race%d-hair_only-legacy_ctrl" % race)
|
||||
var cs := _lum_stats(ctrl)
|
||||
m["hair_lum_ctrl"] = cs["p99"]
|
||||
m["hair_bright_frac_ctrl"] = cs["bright_frac"]
|
||||
m["hair_mean_lum_ctrl"] = cs["mean"]
|
||||
_restore(body, base_mats)
|
||||
else:
|
||||
m["hair_lum_p99"] = 0.0
|
||||
m["hair_lum_ctrl"] = 0.0
|
||||
m["hair_bright_frac"] = 0.0
|
||||
m["hair_bright_frac_ctrl"] = 0.0
|
||||
m["hair_mean_lum"] = 0.0
|
||||
m["hair_mean_lum_ctrl"] = 0.0
|
||||
|
||||
pv.queue_free()
|
||||
await process_frame
|
||||
return m
|
||||
|
||||
# --- 断言 -----------------------------------------------------------------
|
||||
|
||||
func _check_absolute(race: int, m: Dictionary) -> void:
|
||||
var gap: float = m["feet_gap_m"]
|
||||
if not is_finite(gap) or absf(gap) > FEET_GAP_MAX:
|
||||
_fail.append("race %d: feet_gap_m=%s exceeds %.3f" % [race, gap, FEET_GAP_MAX])
|
||||
if int(m["silhouette_px"]) < SILHOUETTE_MIN:
|
||||
_fail.append("race %d: silhouette_px=%d below %d" % [race, int(m["silhouette_px"]), SILHOUETTE_MIN])
|
||||
if int(m["hair_surface_first"]) < 0:
|
||||
return
|
||||
var margin: float = float(m["hair_mean_lum_ctrl"]) - float(m["hair_mean_lum"])
|
||||
if margin < HAIR_CTRL_MARGIN:
|
||||
_fail.append(("race %d: legacy control only %.4f above production; " % [race, margin])
|
||||
+ "hair_mean_lum no longer discriminates")
|
||||
|
||||
func _check_baseline(race: int, m: Dictionary, baseline: Dictionary) -> void:
|
||||
var key := str(race)
|
||||
if not baseline.has(key):
|
||||
_fail.append("race %d: no baseline entry (run with --bless after reviewing the render)" % race)
|
||||
return
|
||||
var b: Dictionary = baseline[key]
|
||||
var cover_delta: float = absf(float(m["head_cover"]) - float(b.get("head_cover", 0.0)))
|
||||
if cover_delta > TOL_HEAD_COVER:
|
||||
_fail.append("race %d: head_cover %.4f vs baseline %.4f (delta %.4f > %.3f)"
|
||||
% [race, float(m["head_cover"]), float(b.get("head_cover", 0.0)), cover_delta, TOL_HEAD_COVER])
|
||||
var base_px: float = float(b.get("silhouette_px", 0.0))
|
||||
if base_px > 0.0:
|
||||
var rel: float = absf(float(m["silhouette_px"]) - base_px) / base_px
|
||||
if rel > TOL_SILHOUETTE:
|
||||
_fail.append("race %d: silhouette_px %d vs baseline %d (%.1f%% > %.1f%%)"
|
||||
% [race, int(m["silhouette_px"]), int(base_px), rel * 100.0, TOL_SILHOUETTE * 100.0])
|
||||
var lum_delta: float = absf(float(m["hair_mean_lum"]) - float(b.get("hair_mean_lum", 0.0)))
|
||||
if lum_delta > TOL_HAIR_LUM:
|
||||
_fail.append("race %d: hair_mean_lum %.4f vs baseline %.4f (delta %.4f > %.4f)"
|
||||
% [race, float(m["hair_mean_lum"]), float(b.get("hair_mean_lum", 0.0)),
|
||||
lum_delta, TOL_HAIR_LUM])
|
||||
|
||||
# --- 像素统计 -------------------------------------------------------------
|
||||
# get_pixel 逐点调用在 512x512 上太慢,直接读 RGBA8 字节。
|
||||
|
||||
func _fg_mask(img: Image) -> PackedByteArray:
|
||||
var d := img.get_data()
|
||||
var n := img.get_width() * img.get_height()
|
||||
var mask := PackedByteArray()
|
||||
mask.resize(n)
|
||||
for i in n:
|
||||
var o := i * 4
|
||||
# 背景是纯洋红;模型不会出现 R 高 / G 近 0 / B 高 的组合
|
||||
mask[i] = 0 if (d[o] > 240 and d[o + 1] < 24 and d[o + 2] > 240) else 1
|
||||
return mask
|
||||
|
||||
func _count_fg(img: Image) -> int:
|
||||
var mask := _fg_mask(img)
|
||||
var c := 0
|
||||
for v in mask:
|
||||
c += v
|
||||
return c
|
||||
|
||||
# 「发型盖住了脑袋多少」:只数 no_hair 里属于人体的像素中被 full 改写的那些,
|
||||
# 不含发型溢出到背景的部分(那属于轮廓,不是遮挡)。
|
||||
func _count_diff(a: Image, b: Image) -> int:
|
||||
var da := a.get_data()
|
||||
var db := b.get_data()
|
||||
var n := mini(da.size(), db.size()) / 4
|
||||
var c := 0
|
||||
for i in n:
|
||||
var o := i * 4
|
||||
var br := int(db[o]); var bg := int(db[o + 1]); var bb := int(db[o + 2])
|
||||
if br > 240 and bg < 24 and bb > 240:
|
||||
continue # no_hair 里是背景 —— 不算遮挡
|
||||
if absi(int(da[o]) - br) > 12 or absi(int(da[o + 1]) - bg) > 12 \
|
||||
or absi(int(da[o + 2]) - bb) > 12:
|
||||
c += 1
|
||||
return c
|
||||
|
||||
# 前景像素亮度统计(256 桶直方图)。返回 {p99, bright_frac, mean}。
|
||||
# 判定用 mean:它对「整片发型被额外高光抬亮」最敏感,且不受发色基准影响
|
||||
# (基准差异由按职业的基线吸收)。p99/bright_frac 只写进报告备查。
|
||||
func _lum_stats(img: Image) -> Dictionary:
|
||||
var d := img.get_data()
|
||||
var n := img.get_width() * img.get_height()
|
||||
var hist := PackedInt32Array()
|
||||
hist.resize(256)
|
||||
var total := 0
|
||||
for i in n:
|
||||
var o := i * 4
|
||||
var r := int(d[o]); var g := int(d[o + 1]); var bl := int(d[o + 2])
|
||||
if r > 240 and g < 24 and bl > 240:
|
||||
continue
|
||||
var lum := int((0.2126 * float(r) + 0.7152 * float(g) + 0.0722 * float(bl)))
|
||||
hist[clampi(lum, 0, 255)] += 1
|
||||
total += 1
|
||||
if total == 0:
|
||||
return {"p99": 0.0, "bright_frac": 0.0, "mean": 0.0}
|
||||
var cut := int(float(total) * 0.99)
|
||||
var acc := 0
|
||||
var p99 := 1.0
|
||||
for v in 256:
|
||||
acc += hist[v]
|
||||
if acc >= cut:
|
||||
p99 = float(v) / 255.0
|
||||
break
|
||||
var bright := 0
|
||||
var sum := 0
|
||||
for v in 256:
|
||||
sum += hist[v] * v
|
||||
if v >= int(LUM_BRIGHT * 255.0):
|
||||
bright += hist[v]
|
||||
return {"p99": p99, "bright_frac": float(bright) / float(total),
|
||||
"mean": float(sum) / float(total) / 255.0}
|
||||
|
||||
# --- 场景辅助 -------------------------------------------------------------
|
||||
|
||||
func _aim(cam: Camera3D, eye: Vector3, at: Vector3, fov: float) -> void:
|
||||
cam.global_transform = Transform3D(Basis(), eye).looking_at(at, Vector3.UP)
|
||||
cam.fov = fov
|
||||
|
||||
func _grab(name: String) -> Image:
|
||||
await RenderingServer.frame_post_draw
|
||||
await RenderingServer.frame_post_draw
|
||||
var img := root.get_texture().get_image()
|
||||
img.convert(Image.FORMAT_RGBA8)
|
||||
img.save_png(_out.path_join(name + ".png"))
|
||||
return img
|
||||
|
||||
func _light() -> void:
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-42, 155, 0)
|
||||
sun.light_energy = 1.1
|
||||
sun.set_layer_mask(0xFFFFF)
|
||||
root.add_child(sun)
|
||||
var env := WorldEnvironment.new()
|
||||
var e := Environment.new()
|
||||
e.background_mode = Environment.BG_COLOR
|
||||
e.background_color = BG
|
||||
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 _hidden() -> StandardMaterial3D:
|
||||
var m := StandardMaterial3D.new()
|
||||
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
m.albedo_color = Color(0, 0, 0, 0)
|
||||
m.no_depth_test = false
|
||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
return m
|
||||
|
||||
func _restore(body: MeshInstance3D, base_mats: Array) -> void:
|
||||
for s in base_mats.size():
|
||||
body.set_surface_override_material(s, base_mats[s])
|
||||
|
||||
# 负对照:R2-02 修复前 _load_hair() 用的那一套。
|
||||
func _legacy_hair_material(production: Material) -> StandardMaterial3D:
|
||||
var m := StandardMaterial3D.new()
|
||||
m.roughness = 1.0
|
||||
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
||||
m.alpha_scissor_threshold = 0.5
|
||||
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
if production is ShaderMaterial:
|
||||
var tex = (production as ShaderMaterial).get_shader_parameter("albedo_tex")
|
||||
if tex != null:
|
||||
m.albedo_texture = tex
|
||||
return m
|
||||
|
||||
# 发型折在 body mesh 尾部;get_info() 报的 surfaces= 是身体自己的 surface 数。
|
||||
func _hair_surface_first(pv: Node3D, body: MeshInstance3D, total: int) -> int:
|
||||
if String(pv.model.get("hair_gr2")).is_empty():
|
||||
return -1
|
||||
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:
|
||||
return -1
|
||||
return body_surfaces
|
||||
|
||||
# --- 基线读写 -------------------------------------------------------------
|
||||
|
||||
func _load_baseline() -> Dictionary:
|
||||
if not FileAccess.file_exists(BASELINE):
|
||||
return {}
|
||||
var txt := FileAccess.get_file_as_string(BASELINE)
|
||||
var parsed = JSON.parse_string(txt)
|
||||
return parsed.get("races", {}) if parsed is Dictionary else {}
|
||||
|
||||
func _write_baseline(results: Dictionary) -> void:
|
||||
var slim := {}
|
||||
for k in results:
|
||||
var m: Dictionary = results[k]
|
||||
slim[k] = {"body_gr2": String(m["body_gr2"]).get_file(),
|
||||
"hair_gr2": String(m["hair_gr2"]).get_file(),
|
||||
"head_cover": m["head_cover"], "silhouette_px": m["silhouette_px"],
|
||||
"head_px": m["head_px"], "hair_mean_lum": m["hair_mean_lum"],
|
||||
"hair_mean_lum_ctrl": m["hair_mean_lum_ctrl"],
|
||||
"hair_lum_p99": m["hair_lum_p99"], "hair_bright_frac": m["hair_bright_frac"]}
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(BASELINE).get_base_dir())
|
||||
var f := FileAccess.open(BASELINE, FileAccess.WRITE)
|
||||
f.store_string(JSON.stringify({
|
||||
"note": "godot --path project --script model_render_test.gd -- --bless",
|
||||
"engine": Engine.get_version_info()["string"],
|
||||
"races": slim}, "\t"))
|
||||
f.close()
|
||||
@@ -0,0 +1 @@
|
||||
uid://fb0dr5ulbc3g
|
||||
@@ -0,0 +1,101 @@
|
||||
# net_trace —— 主角 vid 生命周期取证。
|
||||
#
|
||||
# 实机进游戏后画面全灰、主角是蓝胶囊:GAMESCENE_DIAG 显示地图正常装载,
|
||||
# 但 main_vid=0、主角实体为空 —— 主角在 setup() 之后被从 EntityStore 里
|
||||
# 摘掉了。C++ 侧能把 m_main_vid 清零的路只有三条(mut_despawn /
|
||||
# m2dev GC_CHARACTER_DEL / reset_for_map_change),前两条会发
|
||||
# entity_despawned 信号,第三条会清空 m_changes、悄悄归零。
|
||||
# 这个节点每帧轮询 main_vid 并记录信号,于是「哪条路」直接可读:
|
||||
# MAIN_LOST reason=despawn → GC_CHARACTER_DEL 命中了主角
|
||||
# MAIN_LOST reason=silent → reset_for_map_change(warp)或别的静默清零
|
||||
#
|
||||
# 常驻但极廉价(一次 get_main_vid + 一个整数比较);只在事件发生时打印。
|
||||
extends Node
|
||||
|
||||
const RING := 24
|
||||
|
||||
var _client: Node = null
|
||||
var _main_vid := 0
|
||||
var _t0 := 0
|
||||
var _ring: Array[String] = []
|
||||
var _verbose := false
|
||||
|
||||
func setup(client: Node) -> void:
|
||||
_client = client
|
||||
_t0 = Time.get_ticks_msec()
|
||||
# 主角在进场那几秒会被服务器 DEL+ADD 若干次(参考端同样如此),逐条打太吵,
|
||||
# 默认只报异常;MT_NETTRACE=1 才打全量。
|
||||
_verbose = OS.get_environment("MT_NETTRACE") == "1"
|
||||
for sig in ["entity_main_set", "entity_despawned", "entity_spawned",
|
||||
"entered_game", "disconnected", "stage_changed", "phase_changed",
|
||||
"world_reset", "warp"]:
|
||||
if client.has_signal(sig):
|
||||
client.connect(sig, Callable(self, "_on_" + sig))
|
||||
|
||||
func _ms() -> int:
|
||||
return Time.get_ticks_msec() - _t0
|
||||
|
||||
func _note(line: String) -> void:
|
||||
var s := "+%dms %s" % [_ms(), line]
|
||||
_ring.append(s)
|
||||
if _ring.size() > RING:
|
||||
_ring.pop_front()
|
||||
|
||||
func _dump(head: String) -> void:
|
||||
printerr("NETTRACE: ", head)
|
||||
for s in _ring:
|
||||
printerr("NETTRACE: ", s)
|
||||
|
||||
func _on_entity_main_set(vid: int) -> void:
|
||||
_note("main_set vid=%d" % vid)
|
||||
if _verbose:
|
||||
printerr("NETTRACE: MAIN_SET vid=%d t=+%dms" % [vid, _ms()])
|
||||
|
||||
func _on_entity_spawned(entity: Dictionary) -> void:
|
||||
var vid := int(entity.get("vid", 0))
|
||||
if vid == _main_vid or bool(entity.get("is_main", false)):
|
||||
_note("spawn vid=%d race=%s is_main=%s" % [vid,
|
||||
str(entity.get("race", null)), str(entity.get("is_main", false))])
|
||||
|
||||
func _on_entity_despawned(vid: int) -> void:
|
||||
_note("despawn vid=%d" % vid)
|
||||
if vid == _main_vid:
|
||||
_note("*** despawn hit MAIN vid=%d" % vid)
|
||||
|
||||
func _on_entered_game() -> void:
|
||||
_note("entered_game")
|
||||
|
||||
func _on_disconnected(reason: String) -> void:
|
||||
_note("disconnected " + reason)
|
||||
printerr("NETTRACE: DISCONNECTED %s t=+%dms" % [reason, _ms()])
|
||||
|
||||
func _on_world_reset() -> void:
|
||||
_note("world_reset (reset_for_map_change)")
|
||||
|
||||
func _on_warp(pos: Vector3, _reconnect: bool) -> void:
|
||||
_note("warp " + str(pos))
|
||||
|
||||
func _on_stage_changed(stage: String) -> void:
|
||||
_note("stage=" + stage)
|
||||
|
||||
func _on_phase_changed(phase: String) -> void:
|
||||
_note("phase=" + phase)
|
||||
|
||||
func _process(_dt: float) -> void:
|
||||
if _client == null or not _client.has_method("get_main_vid"):
|
||||
return
|
||||
var vid := int(_client.get_main_vid())
|
||||
if vid == _main_vid:
|
||||
return
|
||||
if vid != 0 and _main_vid == 0:
|
||||
_main_vid = vid
|
||||
_note("main_vid 0 -> %d" % vid)
|
||||
return
|
||||
# 丢主角:把最近的事件环一起打出来,能读出是 despawn 还是静默清零。
|
||||
var was := _main_vid
|
||||
_main_vid = vid
|
||||
var reason := "silent"
|
||||
for s in _ring:
|
||||
if s.ends_with("despawn hit MAIN vid=%d" % was):
|
||||
reason = "despawn"
|
||||
_dump("MAIN_LOST was=%d now=%d reason=%s t=+%dms" % [was, vid, reason, _ms()])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bln1u7gitn4jw
|
||||
@@ -0,0 +1,165 @@
|
||||
# package_render_test —— 在**导出包内部**跑的渲染自检。
|
||||
#
|
||||
# MT_TEST_MODE=render build/export/mtgodot-poc.app/Contents/MacOS/mtgodot-poc
|
||||
#
|
||||
# 存在的理由:导出模板不接受 `--script`,所以 model_render_test.gd /
|
||||
# gamescene_visual_test.gd 这类 `extends SceneTree` 的离线回归**只能在编辑器里跑**。
|
||||
# 编辑器全绿而实机进游戏是「灰紫虚空 + 蓝胶囊」时,两边差异无从定位——包内没有
|
||||
# 任何不依赖服务器的入口。本脚本就是那个入口:由 client_main.gd 在
|
||||
# MT_TEST_MODE=render 时挂到主场景下,走真实 GameScene + 真实 Metin2World +
|
||||
# 真实 PlayerView,不连服务器、不写账号,跑完把报告和截图落到 user:// 并退出。
|
||||
#
|
||||
# 覆盖范围:包内的资源解析、GDExtension 注册、地图装载、角色装配、落地高度。
|
||||
# **不覆盖**:网络驱动的进场时序、40250 像素比对、性能。
|
||||
extends Node
|
||||
|
||||
const GameScene = preload("res://game_scene.gd")
|
||||
const Fixtures = preload("res://gamescene_test.gd")
|
||||
|
||||
const MAP := "OutdoorA1/metin2_map_a1"
|
||||
# 与 gamescene_visual_test.gd 同一组坐标和阈值,两边结论才可比。
|
||||
const SERVER_CM := Vector2(454600, 934000)
|
||||
const FEET_GAP_MAX := 0.03
|
||||
const SILHOUETTE_MIN := 3000
|
||||
|
||||
var _fail: Array[String] = []
|
||||
var _out := ""
|
||||
|
||||
func _ready() -> void:
|
||||
_out = OS.get_environment("MT_RENDER_OUTPUT")
|
||||
if _out.is_empty():
|
||||
_out = ProjectSettings.globalize_path("user://render-check")
|
||||
DirAccess.make_dir_recursive_absolute(_out)
|
||||
call_deferred("_run")
|
||||
|
||||
func _run() -> void:
|
||||
var report := {"env": _env(), "scope":
|
||||
"in-package offline check: real GameScene + Metin2World + PlayerView, no network"}
|
||||
|
||||
# 1) 扩展必须注册 —— 少了它 GameScene 会退回蓝胶囊 + 无地形灰底,
|
||||
# 而这两件事在画面上和「资源没找到」长得一模一样,必须先分开。
|
||||
for cls in ["Metin2Model", "Metin2World", "Metin2AnimPlayer"]:
|
||||
if not ClassDB.class_exists(cls):
|
||||
_fail.append("GDExtension 类未注册:%s" % cls)
|
||||
# 2) 资源根必须落在包内(或 MT_ASSETS 指定处)且真的有地图和角色
|
||||
var root: String = report["env"]["asset_root"]
|
||||
for probe in [MAP, "PC/ymir work/pc/warrior", "asset_index.txt"]:
|
||||
var p := root.path_join(probe)
|
||||
if not (FileAccess.file_exists(p) or DirAccess.dir_exists_absolute(p)):
|
||||
_fail.append("资源缺失:%s" % p)
|
||||
|
||||
if _fail.is_empty():
|
||||
report["scene"] = await _build_and_measure()
|
||||
else:
|
||||
report["scene"] = {"skipped": "前置检查已失败,不再装场景"}
|
||||
|
||||
report["passed"] = _fail.is_empty()
|
||||
report["failures"] = _fail
|
||||
var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
|
||||
f.store_string(JSON.stringify(report, "\t"))
|
||||
f.close()
|
||||
print("PKGRENDER: ", _out)
|
||||
print(JSON.stringify(report))
|
||||
for m in _fail:
|
||||
printerr("PKGRENDER FAIL: ", m)
|
||||
print("PKGRENDER: ", "PASS" if _fail.is_empty() else "FAIL")
|
||||
get_tree().quit(0 if _fail.is_empty() else 1)
|
||||
|
||||
# 包内和开发树的差异几乎都藏在这几个值里,先无条件记下来。
|
||||
func _env() -> Dictionary:
|
||||
var cands := {}
|
||||
for c in _asset_candidates():
|
||||
cands[c] = DirAccess.dir_exists_absolute(c)
|
||||
return {
|
||||
"engine": Engine.get_version_info()["string"],
|
||||
"executable": OS.get_executable_path(),
|
||||
"res_globalized": ProjectSettings.globalize_path("res://"),
|
||||
"user_dir": OS.get_user_data_dir(),
|
||||
"features": {"template": OS.has_feature("template"), "debug": OS.has_feature("debug"),
|
||||
"release": OS.has_feature("release"), "editor": OS.has_feature("editor")},
|
||||
"MT_ASSETS": OS.get_environment("MT_ASSETS"),
|
||||
"MT_PROTOCOL": OS.get_environment("MT_PROTOCOL"),
|
||||
"asset_candidates": cands,
|
||||
"asset_root": AssetRoot.path(),
|
||||
"asset_root_available": AssetRoot.available(),
|
||||
"classes": {"Metin2Model": ClassDB.class_exists("Metin2Model"),
|
||||
"Metin2World": ClassDB.class_exists("Metin2World"),
|
||||
"Metin2AnimPlayer": ClassDB.class_exists("Metin2AnimPlayer")},
|
||||
"rendering_driver": DisplayServer.get_name(),
|
||||
"viewport": str(get_window().size),
|
||||
}
|
||||
|
||||
# AssetRoot._candidates() 是私有的;这里复述一遍只为把每个候选的命中情况写进报告。
|
||||
func _asset_candidates() -> Array:
|
||||
var out: Array = []
|
||||
var env := OS.get_environment("MT_ASSETS")
|
||||
if env != "":
|
||||
out.append(env)
|
||||
if FileAccess.file_exists("res://assets/asset_index.txt"):
|
||||
out.append("res://assets")
|
||||
out.append(ProjectSettings.globalize_path("res://../assets"))
|
||||
var exe := OS.get_executable_path()
|
||||
if exe != "":
|
||||
var d := exe.get_base_dir()
|
||||
out.append(d.path_join("../Resources/assets").simplify_path())
|
||||
out.append(d.path_join("../../../assets").simplify_path())
|
||||
out.append(d.path_join("assets"))
|
||||
return out
|
||||
|
||||
func _build_and_measure() -> Dictionary:
|
||||
var fc := Fixtures.FakeClient.new()
|
||||
add_child(fc)
|
||||
var scene := GameScene.new()
|
||||
add_child(scene)
|
||||
await scene.setup(fc, AssetRoot.path(), MAP)
|
||||
fc.main = 1000
|
||||
fc.ents[1000] = {"vid": 1000, "name": "PkgRenderTest", "race": 0,
|
||||
"pos": Vector3(SERVER_CM.x * 0.01, 0, -SERVER_CM.y * 0.01),
|
||||
"pos_cm": SERVER_CM, "is_main": true, "angle_deg": 0.0,
|
||||
"func": 0, "moving": false, "hp": 100, "max_hp": 100, "dead": false}
|
||||
for i in 30:
|
||||
await get_tree().process_frame
|
||||
|
||||
var expected := MapCoord.to_world(fc.ents[1000].pos)
|
||||
expected.y = scene.world.sample_height(expected.x, expected.z) if scene.world else 0.0
|
||||
var surfaces := 0
|
||||
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh:
|
||||
surfaces += mi.mesh.get_surface_count()
|
||||
if scene._model_built and scene.player.anim:
|
||||
scene.player.anim.set_time(0.5)
|
||||
scene.player.anim.set_process(false)
|
||||
await get_tree().process_frame
|
||||
var feet_y := INF
|
||||
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh:
|
||||
feet_y = minf(feet_y, (mi.global_transform * mi.get_aabb()).position.y)
|
||||
var feet_gap := feet_y - expected.y
|
||||
|
||||
if not scene._model_built:
|
||||
_fail.append("主角模型没装出来(仍是 _make_placeholder_player 的蓝胶囊)")
|
||||
if not scene._map_loaded():
|
||||
_fail.append("地图没装出来(灰底虚空)")
|
||||
if surfaces == 0:
|
||||
_fail.append("玩家节点下 0 个 surface")
|
||||
if not is_finite(feet_gap) or absf(feet_gap) > FEET_GAP_MAX:
|
||||
_fail.append("feet_gap=%s 超出 %.3f" % [feet_gap, FEET_GAP_MAX])
|
||||
|
||||
var shot := "SKIPPED_HEADLESS"
|
||||
if DisplayServer.get_name() != "headless":
|
||||
scene.cam.snap_to_target()
|
||||
await RenderingServer.frame_post_draw
|
||||
await RenderingServer.frame_post_draw
|
||||
var img := get_window().get_texture().get_image()
|
||||
shot = "game.png" if img.save_png(_out.path_join("game.png")) == OK else "SAVE_FAILED"
|
||||
|
||||
var d := {"map": scene.map_path, "model_built": scene._model_built,
|
||||
"map_loaded": scene._map_loaded(), "surfaces": surfaces,
|
||||
"world_position": str(scene.player.position), "expected": str(expected),
|
||||
"feet_y": feet_y, "feet_gap": feet_gap, "feet_gap_max": FEET_GAP_MAX,
|
||||
"map_report": scene.world.get_load_report() if scene.world else {},
|
||||
"camera_transform": str(scene.cam.global_transform), "screenshot": shot}
|
||||
scene.queue_free()
|
||||
fc.queue_free()
|
||||
await get_tree().process_frame
|
||||
return d
|
||||
@@ -0,0 +1 @@
|
||||
uid://rm3i2avq75g
|
||||
Reference in New Issue
Block a user