实机故障:进入游戏后整屏灰紫,主角还是蓝色胶囊占位体,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
424 lines
16 KiB
GDScript
424 lines
16 KiB
GDScript
# 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()
|