# 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()