feat: complete native client rendering parity updates

This commit is contained in:
shen
2026-09-27 19:44:41 -07:00
parent 9a9dc6d224
commit 25dd65f2ce
99 changed files with 21121 additions and 921 deletions
+126 -11
View File
@@ -2,6 +2,7 @@
# 登录 → 选人 → 进游戏,把 RenderGame 的绘制命令(python_3d_surface.gd)画出来并截图。
#
# godot --path project --script res://python_game_render_test.gd
# MT_FAKE_MOB_COUNT=24 MT_PROFILE_FRAME=1 MT_PROFILE_COMBAT=1 script/python_game_render_test.sh
#
# 服务器与账号只从环境变量读(MT_LIVE_HOST / MT_LIVE_LOGIN / MT_LIVE_PASSWORD,可选 MT_LIVE_AUTH_PORT
# 11000、MT_LIVE_GAME_PORT 13000、MT_LIVE_SLOT 0);Python 侧也用 os.environ 取,不进源码文本。
@@ -72,8 +73,12 @@ func run() -> void:
var output := OS.get_environment("MT_RENDER_OUTPUT")
if output.is_empty():
output = ProjectSettings.globalize_path("res://../build/rendering/python-game-%d" % Time.get_unix_time_from_system())
elif not output.is_absolute_path():
output = ProjectSettings.globalize_path("res://../" + output)
DirAccess.make_dir_recursive_absolute(output)
var report := {"output": output, "headless": DisplayServer.get_name() == "headless"}
if OS.has_environment("MT_FAKE_MOB_COUNT"):
report["fake_mob_count"] = int(OS.get_environment("MT_FAKE_MOB_COUNT"))
var host := OS.get_environment("MT_LIVE_HOST")
if host.is_empty() or OS.get_environment("MT_LIVE_LOGIN").is_empty() or OS.get_environment("MT_LIVE_PASSWORD").is_empty():
@@ -132,9 +137,63 @@ func run() -> void:
# The main character's draws reach the 3D surface.
var drawn := in_game and await _pump_until(10, func(): return world.draw_count > 0)
_check(drawn, "RenderGame draws shown (%d)" % world.draw_count)
# Let the WAIT motion run a little before the picture.
for i in 30:
# Let the WAIT motion run a little before the picture, and let GamePhase (3 packets/frame)
# finish draining the entergame burst + GC_PING when MT_FAKE_MOB_COUNT is large.
var warmup_frames := maxi(30, int(report.get("fake_mob_count", 1)) + 20)
for i in warmup_frames:
await process_frame
if OS.has_environment("MT_PROFILE_FRAME"):
var frame_ms: Array[float] = []
var ui_update_ms: Array[float] = []
var ui_draw_ms: Array[float] = []
var world_update_ms: Array[float] = []
var previous := Time.get_ticks_usec()
for i in 240:
await process_frame
var current := Time.get_ticks_usec()
frame_ms.append(float(current - previous) / 1000.0)
ui_update_ms.append(ui.profile_ui_update_ms)
ui_draw_ms.append(ui.profile_draw_ms)
world_update_ms.append(world.profile_update_ms)
previous = current
frame_ms.sort()
ui_update_ms.sort()
ui_draw_ms.sort()
world_update_ms.sort()
var total := 0.0
for sample in frame_ms:
total += sample
report["frame_profile"] = {
"samples": frame_ms.size(),
"mean_ms": total / frame_ms.size(),
"p50_ms": frame_ms[119],
"p95_ms": frame_ms[227],
"ui_update_p50_ms": ui_update_ms[119],
"ui_draw_p50_ms": ui_draw_ms[119],
"world_update_p50_ms": world_update_ms[119],
}
var command_kinds := {}
var image_texts := {}
for command in Metin2PythonHost.ui_render_commands():
var kind: String = str(command.get("kind", "unknown"))
command_kinds[kind] = int(command_kinds.get(kind, 0)) + 1
if kind == "image":
var image_name: String = str(command.get("text", ""))
image_texts[image_name] = int(image_texts.get(image_name, 0)) + 1
report["frame_profile"]["ui_commands"] = command_kinds
report["frame_profile"]["image_texts"] = image_texts
var batched_commands := 0
var glyph_batches := 0
var batched_glyphs := 0
for command in Metin2PythonHost.ui_render_commands_batched():
batched_commands += 1
if command.get("kind", "") == "glyph_batch":
glyph_batches += 1
batched_glyphs += int(command.get("glyph_count", 0))
report["frame_profile"]["batched_commands"] = batched_commands
report["frame_profile"]["glyph_batches"] = glyph_batches
report["frame_profile"]["batched_glyphs"] = batched_glyphs
print("FRAME_PROFILE ", report["frame_profile"])
report["draw_count"] = world.draw_count
report["unlit_stand_in_count"] = world.unlit_stand_in_count
report["terrain"] = world.terrain_report
@@ -170,14 +229,25 @@ func run() -> void:
var draws: Array = Metin2PythonHost.render3d_draws()
var native_lit_draws := 0
var view_finite := true
var actor_foot_cm := INF
var foot_xy := Vector2.ZERO
# Identify character, monster and NPC draws (excluding map objects, buildings, trees and roofs).
var actor_draws: Array = []
for draw in draws:
var tex: String = String(draw.get("texture0", "")).to_lower().replace("\\", "/")
for k in ["pc/", "pc2/", "monster/", "npc/"]:
if k in tex:
actor_draws.append(draw)
break
for draw in draws:
if draw["light0"]:
native_lit_draws += 1
for value in draw["view"]:
if not is_finite(value):
view_finite = false
var actor_foot_cm := INF
var foot_xy := Vector2.ZERO
for draw in actor_draws:
var world_xform: Transform3D = world.d3d_transform(draw["world"])
for vertex in draw["positions"]:
var p: Vector3 = world_xform * vertex
@@ -187,14 +257,14 @@ func run() -> void:
# Every actor, not only the main one, stands on the map. An actor's parts (body, head, hair, teeth)
# share its world matrix; the lowest vertex over those parts meets sample_height.
var actor_low := {}
for draw in draws:
for draw in actor_draws:
var draw_xform: Transform3D = world.d3d_transform(draw["world"])
var key := str(draw["world"])
for vertex in draw["positions"]:
var p: Vector3 = draw_xform * vertex
if not actor_low.has(key) or p.z < actor_low[key].z:
actor_low[key] = p
if world.terrain:
if world.terrain and not actor_low.is_empty():
var worst_actor_gap_cm := 0.0
for low in actor_low.values():
var ground := float(world.terrain.call("sample_height", low.x / 100.0, -low.y / 100.0)) * 100.0
@@ -244,7 +314,7 @@ func run() -> void:
var distinct_textures := {}
for instance in world.find_children("Draw*", "MeshInstance3D", false, false):
if instance.visible and instance.mesh:
var material: StandardMaterial3D = instance.mesh.surface_get_material(0)
var material: StandardMaterial3D = instance.get_active_material(0)
if material and material.albedo_texture:
surfaces_textured += 1
distinct_textures[material.albedo_texture] = true
@@ -353,7 +423,7 @@ func run() -> void:
for frame in 600:
await process_frame
now = dog_screen.call()
if now.distance_to(last) < 0.5:
if now.x >= 0.0 and now.distance_to(last) < 0.5:
break
last = now
return now
@@ -377,15 +447,60 @@ func run() -> void:
await process_frame
button.call(true, at)
var mid_shot := false
var dead := await _pump_until(20, func():
var combat_condition := func():
var now: Vector2 = dog_screen.call()
if now.x >= 0.0:
at = now
move.call(at)
if not mid_shot and Metin2PythonHost.evaluate("33 in _target_hp") == "True" and DisplayServer.get_name() != "headless":
if not mid_shot and Metin2PythonHost.evaluate("33 in _target_hp") == "True" and DisplayServer.get_name() != "headless" and not OS.has_environment("MT_PROFILE_COMBAT"):
mid_shot = true
root.get_texture().get_image().save_png(output.path_join("combat_mid.png"))
return Metin2PythonHost.evaluate("_target_hp[-1:] == [0]") == "True")
return Metin2PythonHost.evaluate("_target_hp[-1:] == [0]") == "True"
var dead := false
if OS.has_environment("MT_PROFILE_COMBAT"):
var combat_frames: Array[float] = []
var combat_ui_update: Array[float] = []
var combat_ui_draw: Array[float] = []
var combat_world_update: Array[float] = []
var combat_draw_counts: Array[int] = []
var deadline := Time.get_ticks_msec() + 20000
var previous := Time.get_ticks_usec()
while Time.get_ticks_msec() < deadline:
if combat_condition.call():
dead = true
break
await process_frame
var current := Time.get_ticks_usec()
combat_frames.append(float(current - previous) / 1000.0)
combat_ui_update.append(ui.profile_ui_update_ms)
combat_ui_draw.append(ui.profile_draw_ms)
combat_world_update.append(world.profile_update_ms)
combat_draw_counts.append(world.draw_count)
previous = current
combat_frames.sort()
combat_ui_update.sort()
combat_ui_draw.sort()
combat_world_update.sort()
combat_draw_counts.sort()
if not combat_frames.is_empty():
var n := combat_frames.size()
report["combat_profile"] = {
"samples": n,
"frame_p50_ms": combat_frames[n / 2],
"frame_p95_ms": combat_frames[mini(n - 1, int(n * 0.95))],
"frame_max_ms": combat_frames[n - 1],
"ui_update_p50_ms": combat_ui_update[n / 2],
"ui_update_p95_ms": combat_ui_update[mini(n - 1, int(n * 0.95))],
"ui_draw_p50_ms": combat_ui_draw[n / 2],
"ui_draw_p95_ms": combat_ui_draw[mini(n - 1, int(n * 0.95))],
"world_update_p50_ms": combat_world_update[n / 2],
"world_update_p95_ms": combat_world_update[mini(n - 1, int(n * 0.95))],
"draw_count_p50": combat_draw_counts[n / 2],
"draw_count_max": combat_draw_counts[n - 1],
}
print("COMBAT_PROFILE ", report["combat_profile"])
else:
dead = await _pump_until(20, combat_condition)
button.call(false, at)
if DisplayServer.get_name() != "headless":
root.get_texture().get_image().save_png(output.path_join("combat.png"))