Files
mtgodot-poc/project/main.gd
T
Claude f4917a2b3b Fix cull mode + isolate head-collapse to a libgr2 curve bug
Rendering fixes (real):
- m2_material shaders: cull_disabled -> cull_back (inside-of-mesh faces were
  z-winning and darkening/hiding the head under the ShaderMaterial path).
- drop `* COLOR` from the shader (meshes carry no ARRAY_COLOR).

Animation: convert gr2 global poses to Godot *local* bone poses and set via
set_bone_pose (was set_bone_global_pose). MTGODOT_VERIFY=1 confirms
get_bone_global_pose(i) == conv(world[i]) for all 75 bones (<=2.6e-5).

Head/upper-armor collapse on dance_1 is NOT a Godot-route bug:
- reproduced identically in xrender-poc's bgfx demo at the same anim/t
- reproduced by the new MTGODOT_CPUSKIN=1 reference path (same LBS math as
  xrender's validated skin_mesh)
- `general/wait` / `run` render correctly in all three
=> upstream libgr2 Curve::eval (degree-2 quaternion B-spline) vs Granny.
Demo default animation switched to general/wait; documented in MIDREVIEW §4
and README. Debug scaffolding gated behind MTGODOT_VERIFY / MTGODOT_CPUSKIN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaHYEY9rwLWt21PULiYjeJ
2026-08-29 10:47:01 +09:00

291 lines
9.1 KiB
GDScript

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