# 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 --hair --skin # # 目的是把「发型挡脸」拆成几何问题和材质/深度问题:同一机位分别渲染 # 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