extends Node3D ## mtgodot-poc harness — M1 (static render) + M2 (skeletal animation). ## ## Loads a Metin2 .gr2 via the Metin2Model GDExtension node, renders it with ## Godot's Forward+ renderer, and (if an anim .gr2 is given) drives it with ## Metin2AnimPlayer. ## ## Asset paths default to the sibling m2dev-client-main checkout; override with ## MTGODOT_MODEL / MTGODOT_ANIM / MTGODOT_TEXDIR env vars. ## ## Keys: drag = orbit wheel = zoom Space = play/pause B = bind pose ## [ / ] = scrub R = reload F2 = screenshot const ORBIT_SPEED := 0.01 const ZOOM_STEP := 0.15 const PITCH_LIMIT := 1.45 var _assets_root := "" var _cam: Camera3D var _model: Node3D # Metin2Model var _anim: Node3D # Metin2AnimPlayer var _yaw := 0.7 var _pitch := 0.25 var _dist := 3.0 var _target := Vector3(0.0, 1.0, 0.0) var _dragging := false func _ready() -> void: _assets_root = ProjectSettings.globalize_path("res://../../m2dev-client-main/assets") _build_environment() _build_sun() _build_camera() var stress := OS.get_environment("MTGODOT_STRESS") if stress != "": await _run_stress(int(stress)) return _load_content() _frame_model() print("[mtgodot] harness ready") var autoshot := OS.get_environment("MTGODOT_AUTOSHOT") if autoshot != "": # let a few frames render (and the anim settle) before capturing for i in 8: await get_tree().process_frame var img := get_viewport().get_texture().get_image() img.save_png(autoshot) print("[mtgodot] autoshot -> ", autoshot) get_tree().quit() func _build_environment() -> void: var we := WorldEnvironment.new() var env := Environment.new() var sky := Sky.new() sky.sky_material = ProceduralSkyMaterial.new() env.background_mode = Environment.BG_SKY env.sky = sky env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY env.ambient_light_energy = 0.6 env.tonemap_mode = Environment.TONE_MAPPER_FILMIC # T2.5.4 — distance fog via the engine (not per-shader). if OS.get_environment("MTGODOT_FOG") == "1": env.fog_enabled = true env.fog_mode = Environment.FOG_MODE_DEPTH env.fog_light_color = Color(0.55, 0.55, 0.62) env.fog_sun_scatter = 0.1 env.fog_density = 0.0 env.fog_depth_begin = 3.0 env.fog_depth_end = 12.0 we.environment = env add_child(we) func _build_sun() -> void: var sun := DirectionalLight3D.new() sun.rotation_degrees = Vector3(-50.0, -40.0, 0.0) sun.shadow_enabled = true sun.light_energy = 1.1 add_child(sun) func _build_camera() -> void: _cam = Camera3D.new() _cam.current = true _cam.fov = 45.0 _cam.near = 0.01 add_child(_cam) _update_camera() func _env(name: String, fallback: String) -> String: var v := OS.get_environment(name) return v if v != "" else fallback func _load_content() -> void: if not ClassDB.class_exists("Metin2Model"): push_error("[mtgodot] Metin2Model not registered — GDExtension failed to load") return var model_path := _env("MTGODOT_MODEL", _assets_root.path_join("PC/ymir work/pc/warrior/warrior_cheongrin.gr2")) var tex_dir := _env("MTGODOT_TEXDIR", _assets_root.path_join("PC/ymir work/pc/warrior")) # Default to an idle: dance_1 (and other emotes) hit a libgr2 curve-decode # bug that collapses the head/neck — reproduced in xrender-poc's bgfx demo # too, i.e. upstream, not a Godot-route issue. See docs/MIDREVIEW.md. var anim_path := _env("MTGODOT_ANIM", _assets_root.path_join("PC/ymir work/pc/warrior/general/wait.gr2")) _model = ClassDB.instantiate("Metin2Model") _model.name = "Metin2Model" _model.set("texture_dir", tex_dir) _model.set("gr2_path", model_path) add_child(_model) if _model.has_method("get_info"): print("[mtgodot] model: ", _model.call("get_info")) if anim_path != "-" and FileAccess.file_exists(anim_path) and ClassDB.class_exists("Metin2AnimPlayer"): _anim = ClassDB.instantiate("Metin2AnimPlayer") _anim.name = "Metin2AnimPlayer" _anim.set("model_path", _anim.get_path_to(_model) if false else NodePath("../Metin2Model")) _anim.set("anim_path", anim_path) add_child(_anim) if _anim.has_method("get_info"): print("[mtgodot] anim: ", _anim.call("get_info")) if _anim.has_method("selfcheck"): print("[mtgodot] ", _anim.call("selfcheck", 24)) _anim.call("reload") var fixed_t := OS.get_environment("MTGODOT_ANIM_T") if fixed_t != "": _anim.set("playing", false) _anim.call("set_time", float(fixed_t)) print("[mtgodot] fixed t=", _anim.call("get_time"), " dur=", _anim.call("get_duration")) func _frame_model() -> void: # Place the orbit target/dist from the model's converted world-space AABB. var mi := _model.get_node_or_null("MeshInstance3D") if _model else null if mi and mi is VisualInstance3D: var aabb: AABB = _model.transform * (mi as VisualInstance3D).get_aabb() var h := maxf(aabb.size.y, 0.5) _target = Vector3(aabb.get_center().x, aabb.position.y + h * 0.5, aabb.get_center().z) _dist = h * 3.2 _pitch = 0.12 var yenv := OS.get_environment("MTGODOT_YAW") if yenv != "": _yaw = float(yenv) print("[mtgodot] frame: aabb=", aabb, " target=", _target, " dist=", _dist) _update_camera() func _update_camera() -> void: var offset := Vector3( cos(_pitch) * sin(_yaw), sin(_pitch), cos(_pitch) * cos(_yaw)) * _dist _cam.position = _target + offset _cam.look_at(_target, Vector3.UP) func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseButton: var mb := event as InputEventMouseButton match mb.button_index: MOUSE_BUTTON_LEFT: _dragging = mb.pressed MOUSE_BUTTON_WHEEL_UP: _dist = maxf(0.2, _dist * (1.0 - ZOOM_STEP)) _update_camera() MOUSE_BUTTON_WHEEL_DOWN: _dist = minf(200.0, _dist * (1.0 + ZOOM_STEP)) _update_camera() elif event is InputEventMouseMotion and _dragging: var mm := event as InputEventMouseMotion _yaw -= mm.relative.x * ORBIT_SPEED _pitch = clampf(_pitch + mm.relative.y * ORBIT_SPEED, -PITCH_LIMIT, PITCH_LIMIT) _update_camera() elif event is InputEventKey and event.pressed and not event.echo: _on_key(event.keycode) func _on_key(kc: int) -> void: match kc: KEY_F2: _screenshot() KEY_SPACE: if _anim: _anim.set("playing", not _anim.get("playing")) KEY_B: if _anim: _anim.set("playing", false) _anim.call("set_time", 0.0) KEY_R: if _model: _model.call("reload") if _anim: _anim.call("reload") _frame_model() KEY_BRACKETLEFT: if _anim: _anim.call("set_time", _anim.call("get_time") - 0.05) KEY_BRACKETRIGHT: if _anim: _anim.call("set_time", _anim.call("get_time") + 0.05) ## MTGODOT_STRESS=N : spawn N animated warriors in a grid, measure frame time ## over ~180 frames, write test/godot-macos-stress.json, quit. ## Feeds the Phase-2 animation A/B (per-frame set_bone_global_pose vs baked). func _run_stress(n: int) -> void: DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) Engine.max_fps = 0 var model_path := _assets_root.path_join("PC/ymir work/pc/warrior/warrior_cheongrin.gr2") var tex_dir := _assets_root.path_join("PC/ymir work/pc/warrior") var anim_path := _assets_root.path_join("PC/ymir work/pc/warrior/action/dance_1.gr2") var cols := int(ceil(sqrt(float(n)))) var t_build_start := Time.get_ticks_usec() for i in n: var m: Node3D = ClassDB.instantiate("Metin2Model") m.name = "M%d" % i m.set("texture_dir", tex_dir) m.set("gr2_path", model_path) m.position = Vector3((i % cols) * 1.2 - cols * 0.6, 0, (i / cols) * 1.2) add_child(m) var a: Node3D = ClassDB.instantiate("Metin2AnimPlayer") a.set("model_path", NodePath("../M%d" % i)) a.set("anim_path", anim_path) a.set("time_scale", 1.0 + 0.01 * i) # desync add_child(a) a.call("reload") a.call("set_time", 0.13 * i) var build_ms := (Time.get_ticks_usec() - t_build_start) / 1000.0 _target = Vector3(0, 1.0, cols * 0.6) _dist = cols * 2.2 _pitch = 0.35 _update_camera() # warm-up for i in 40: await get_tree().process_frame var total: Array[float] = [] var proc: Array[float] = [] for i in 240: await get_tree().process_frame total.append(1000.0 / maxf(Engine.get_frames_per_second(), 1.0)) proc.append(Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0) total.sort() proc.sort() var tsum := 0.0 for s in total: tsum += s var psum := 0.0 for s in proc: psum += s var report := { "characters": n, "build_ms_total": build_ms, "build_ms_per_char": build_ms / n, "frames": total.size(), "frame_ms_avg": tsum / total.size(), "frame_ms_p95": total[int(total.size() * 0.95)], "frame_ms_max": total[-1], "fps_avg": 1000.0 / (tsum / total.size()), "cpu_process_ms_avg": psum / proc.size(), "cpu_process_ms_p95": proc[int(proc.size() * 0.95)], "renderer": RenderingServer.get_video_adapter_name(), "method": "per-frame set_bone_global_pose (B)", } var out := OS.get_environment("MTGODOT_STRESS_OUT") if out == "": out = ProjectSettings.globalize_path("res://../test/godot-macos-stress.json") var f := FileAccess.open(out, FileAccess.WRITE) if f: f.store_string(JSON.stringify(report, " ")) f.close() print("[mtgodot] stress ", n, " -> ", report) get_tree().quit() func _screenshot() -> void: var img := get_viewport().get_texture().get_image() var path := "user://shot_%d.png" % Time.get_ticks_msec() img.save_png(path) print("[mtgodot] screenshot -> ", ProjectSettings.globalize_path(path))