2V2-e.2: Godot consumer of the 3D draw commands, offline screenshot
python_3d_surface.gd turns RenderGame's recorded draws into meshes under a camera read back from SetPerspective; textures come from the 40250 packs. GetFarClip and the CMapManager ctor ported verbatim. Until the environment is ported, lit draws with no light/ambient are shown unshaded (stand-in). script/python_game_render_test.sh runs system.py login -> select -> game against port_fake_login_server (or --live via MT_LIVE_*) and screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5bfa13c5cc
commit
1fe8a4b643
@@ -0,0 +1,208 @@
|
||||
# 3D adapter for the ported game render (docs/PORT-PLAN.md 2V2-e): every frame it turns the draw calls
|
||||
# CPythonApplication::RenderGame issued through CStateManager (Metin2PythonHost.render3d_draws, recorded
|
||||
# by the platform IDirect3DDevice8) into meshes under a camera.
|
||||
#
|
||||
# The draws carry 40250's own D3D matrices. CGraphicBase::SetPerspective builds a right-handed
|
||||
# projection (D3DXMatrixPerspectiveFovRH), so 40250's view space already is Godot's camera space
|
||||
# (x right, y up, looking down -z): each mesh gets world * view as its transform and the Camera3D stays
|
||||
# at the origin with the fov/near/far read back from the projection. No axis conversion is involved.
|
||||
# D3D's default front face (clockwise on screen, D3DCULL_CCW culls the rest) is also Godot's.
|
||||
#
|
||||
# PORT: until the map is ported, CMapManager has no environment data (LoadMap → SetEnvironmentData),
|
||||
# so CPythonBackground::SetCharacterDirLight sets no light and D3DRS_AMBIENT stays 0: D3D would draw
|
||||
# every lit mesh black. Such draws (lighting on, no light, no ambient, no emissive) are shown unshaded,
|
||||
# texture only, and counted in unlit_stand_in_count. Delete this with the environment port.
|
||||
extends Node3D
|
||||
|
||||
const UiAssets = preload("res://ui/ui_assets.gd")
|
||||
|
||||
# D3DCULL / D3DBLEND / D3DCMPFUNC values used below (D3D8Types.h).
|
||||
const D3DCULL_NONE := 1
|
||||
const D3DCULL_CW := 2
|
||||
const D3DBLEND_ONE := 2
|
||||
const D3DBLEND_SRCALPHA := 5
|
||||
const D3DBLEND_INVSRCALPHA := 6
|
||||
|
||||
var camera: Camera3D
|
||||
var light: DirectionalLight3D
|
||||
var environment: WorldEnvironment
|
||||
var _meshes: Array[MeshInstance3D] = []
|
||||
# Materials keyed by texture and state, so an unchanged draw keeps its material from frame to frame.
|
||||
var _materials := {}
|
||||
|
||||
# The draws the last update_frame() showed (for tests and reports).
|
||||
var draw_count := 0
|
||||
# How many of them used the no-environment stand-in (see the note at the top).
|
||||
var unlit_stand_in_count := 0
|
||||
|
||||
func _ready() -> void:
|
||||
camera = Camera3D.new()
|
||||
camera.name = "GameCamera"
|
||||
camera.keep_aspect = Camera3D.KEEP_HEIGHT
|
||||
camera.current = true
|
||||
add_child(camera)
|
||||
# 40250's directional light (CPythonBackground::SetCharacterDirLight) arrives with the draws in
|
||||
# world space; it is placed in camera space each frame like the meshes.
|
||||
light = DirectionalLight3D.new()
|
||||
light.name = "Light0"
|
||||
light.visible = false
|
||||
add_child(light)
|
||||
environment = WorldEnvironment.new()
|
||||
environment.environment = Environment.new()
|
||||
environment.environment.background_mode = Environment.BG_COLOR
|
||||
environment.environment.background_color = Color.BLACK
|
||||
environment.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
environment.environment.ambient_light_color = Color.BLACK
|
||||
environment.environment.tonemap_mode = Environment.TONE_MAPPER_LINEAR
|
||||
add_child(environment)
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if Metin2PythonHost.is_running():
|
||||
update_frame()
|
||||
|
||||
# D3D row-vector matrix (v' = v * M) as a Godot Transform3D (v' = T * v): the rows of M are the basis
|
||||
# columns, row 3 is the origin.
|
||||
static func d3d_transform(m: PackedFloat32Array) -> Transform3D:
|
||||
return Transform3D(Basis(Vector3(m[0], m[1], m[2]), Vector3(m[4], m[5], m[6]), Vector3(m[8], m[9], m[10])),
|
||||
Vector3(m[12], m[13], m[14]))
|
||||
|
||||
static func multiply(a: PackedFloat32Array, b: PackedFloat32Array) -> PackedFloat32Array:
|
||||
var out := PackedFloat32Array()
|
||||
out.resize(16)
|
||||
for r in 4:
|
||||
for c in 4:
|
||||
var sum := 0.0
|
||||
for k in 4:
|
||||
sum += a[r * 4 + k] * b[k * 4 + c]
|
||||
out[r * 4 + c] = sum
|
||||
return out
|
||||
|
||||
func update_frame() -> void:
|
||||
var draws: Array = Metin2PythonHost.render3d_draws()
|
||||
var shown := 0
|
||||
var stand_ins := 0
|
||||
var camera_set := false
|
||||
var light_set := false
|
||||
for draw in draws:
|
||||
# XYZRHW (screen-space) draws belong to the 2D pass; lines are debug geometry.
|
||||
if draw["pretransformed"] or draw["lines"] or draw["indices"].is_empty():
|
||||
continue
|
||||
var proj: PackedFloat32Array = draw["proj"]
|
||||
# _34 == -1: a perspective projection (the game pass). Ortho draws are UI-space.
|
||||
if proj[11] != -1.0:
|
||||
continue
|
||||
if not camera_set:
|
||||
_apply_projection(proj)
|
||||
camera_set = true
|
||||
var view: PackedFloat32Array = draw["view"]
|
||||
if not light_set and draw["light0"]:
|
||||
_apply_light(draw, view)
|
||||
light_set = true
|
||||
if _environment_missing(draw):
|
||||
stand_ins += 1
|
||||
var instance := _mesh_instance(shown)
|
||||
instance.transform = d3d_transform(multiply(draw["world"], view))
|
||||
instance.mesh = _build_mesh(draw)
|
||||
instance.visible = true
|
||||
shown += 1
|
||||
for i in range(shown, _meshes.size()):
|
||||
_meshes[i].visible = false
|
||||
_meshes[i].mesh = null
|
||||
if not light_set:
|
||||
light.visible = false
|
||||
draw_count = shown
|
||||
unlit_stand_in_count = stand_ins
|
||||
|
||||
func _apply_projection(proj: PackedFloat32Array) -> void:
|
||||
# D3DXMatrixPerspectiveFovRH: _22 = cot(fovy / 2), _33 = zf / (zn - zf), _43 = zn * zf / (zn - zf).
|
||||
camera.fov = rad_to_deg(2.0 * atan(1.0 / proj[5]))
|
||||
camera.near = proj[14] / proj[10]
|
||||
camera.far = proj[14] / (proj[10] + 1.0)
|
||||
|
||||
func _apply_light(draw: Dictionary, view: PackedFloat32Array) -> void:
|
||||
var view_basis := d3d_transform(view).basis
|
||||
var direction: Vector3 = view_basis * draw["light0_direction"]
|
||||
if direction.length_squared() < 1e-8:
|
||||
light.visible = false
|
||||
return
|
||||
light.visible = true
|
||||
# DirectionalLight3D shines along its -Z.
|
||||
light.transform = Transform3D(Basis.looking_at(direction.normalized(),
|
||||
Vector3.UP if absf(direction.normalized().y) < 0.99 else Vector3.RIGHT), Vector3.ZERO)
|
||||
var diffuse: Color = draw["light0_diffuse"]
|
||||
light.light_color = Color(diffuse.r, diffuse.g, diffuse.b)
|
||||
light.light_energy = 1.0
|
||||
# D3D lighting: ambient = D3DRS_AMBIENT + light ambient, both times the material ambient.
|
||||
var ambient: Color = draw["ambient"]
|
||||
var light_ambient: Color = draw["light0_ambient"]
|
||||
environment.environment.ambient_light_color = Color(minf(ambient.r + light_ambient.r, 1.0),
|
||||
minf(ambient.g + light_ambient.g, 1.0), minf(ambient.b + light_ambient.b, 1.0))
|
||||
|
||||
static func _environment_missing(draw: Dictionary) -> bool:
|
||||
var ambient: Color = draw["ambient"]
|
||||
var emissive: Color = draw["material_emissive"]
|
||||
return draw["lighting"] and not draw["light0"] and ambient.r + ambient.g + ambient.b == 0.0 \
|
||||
and emissive.r + emissive.g + emissive.b == 0.0
|
||||
|
||||
func _mesh_instance(index: int) -> MeshInstance3D:
|
||||
while _meshes.size() <= index:
|
||||
var instance := MeshInstance3D.new()
|
||||
instance.name = "Draw%d" % _meshes.size()
|
||||
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(instance)
|
||||
_meshes.append(instance)
|
||||
return _meshes[index]
|
||||
|
||||
func _build_mesh(draw: Dictionary) -> ArrayMesh:
|
||||
var arrays := []
|
||||
arrays.resize(Mesh.ARRAY_MAX)
|
||||
arrays[Mesh.ARRAY_VERTEX] = draw["positions"]
|
||||
if draw.has("normals"):
|
||||
arrays[Mesh.ARRAY_NORMAL] = draw["normals"]
|
||||
if draw.has("uv0"):
|
||||
arrays[Mesh.ARRAY_TEX_UV] = draw["uv0"]
|
||||
if draw.has("diffuse"):
|
||||
arrays[Mesh.ARRAY_COLOR] = draw["diffuse"]
|
||||
arrays[Mesh.ARRAY_INDEX] = draw["indices"]
|
||||
var mesh := ArrayMesh.new()
|
||||
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
||||
mesh.surface_set_material(0, _material(draw))
|
||||
return mesh
|
||||
|
||||
func _material(draw: Dictionary) -> StandardMaterial3D:
|
||||
var lit: bool = draw["lighting"] and not _environment_missing(draw)
|
||||
var key := "%s|%d|%d|%d|%d|%d|%d|%d|%s|%s" % [draw["texture0"], draw["alpha_blend"], draw["src_blend"],
|
||||
draw["dest_blend"], draw["alpha_test"], draw["alpha_ref"], draw["cull_mode"], lit,
|
||||
draw["material_diffuse"], draw["material_emissive"]]
|
||||
if _materials.has(key):
|
||||
return _materials[key]
|
||||
var material := StandardMaterial3D.new()
|
||||
var texture_name: String = draw["texture0"]
|
||||
if not texture_name.is_empty():
|
||||
# The ported client reads its models from 40250's packs; the texture must come from there too.
|
||||
material.albedo_texture = UiAssets.load_pack_tex(texture_name)
|
||||
material.albedo_color = draw["material_diffuse"] if lit else Color.WHITE
|
||||
material.emission_enabled = lit and draw["material_emissive"] != Color(0, 0, 0, 0)
|
||||
if material.emission_enabled:
|
||||
material.emission = draw["material_emissive"]
|
||||
material.shading_mode = BaseMaterial3D.SHADING_MODE_PER_VERTEX if lit \
|
||||
else BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
material.specular_mode = BaseMaterial3D.SPECULAR_DISABLED
|
||||
material.roughness = 1.0
|
||||
material.vertex_color_use_as_albedo = draw.has("diffuse")
|
||||
match int(draw["cull_mode"]):
|
||||
D3DCULL_NONE:
|
||||
material.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
D3DCULL_CW:
|
||||
material.cull_mode = BaseMaterial3D.CULL_FRONT
|
||||
_:
|
||||
material.cull_mode = BaseMaterial3D.CULL_BACK
|
||||
if draw["alpha_blend"]:
|
||||
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
if int(draw["dest_blend"]) == D3DBLEND_ONE:
|
||||
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
|
||||
elif draw["alpha_test"]:
|
||||
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
||||
material.alpha_scissor_threshold = float(draw["alpha_ref"]) / 255.0
|
||||
_materials[key] = material
|
||||
return material
|
||||
@@ -0,0 +1,172 @@
|
||||
# python_game_render_test — 2V2-e:Godot 里跑 40250 的 system.py,按 port_login_flow_test 的同一条路径
|
||||
# 登录 → 选人 → 进游戏,把 RenderGame 的绘制命令(python_3d_surface.gd)画出来并截图。
|
||||
#
|
||||
# godot --path project --script res://python_game_render_test.gd
|
||||
#
|
||||
# 服务器与账号只从环境变量读(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 取,不进源码文本。
|
||||
# 离线由 script/python_game_render_test.sh 起 port_fake_login_server 并把这些变量指向它。
|
||||
# 截图与 report.json 写到 MT_RENDER_OUTPUT(默认 build/rendering/python-game-<时间>)。
|
||||
extends SceneTree
|
||||
|
||||
const PythonUISurface = preload("res://python_ui_surface.gd")
|
||||
const Python3DSurface = preload("res://python_3d_surface.gd")
|
||||
|
||||
var failed := false
|
||||
var ui: Control
|
||||
var world: Node3D
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func _check(ok: bool, message: String) -> void:
|
||||
if ok:
|
||||
print("ok: " + message)
|
||||
else:
|
||||
failed = true
|
||||
printerr("FAIL: " + message)
|
||||
|
||||
func _py(source: String) -> bool:
|
||||
return Metin2PythonHost.run_line(source) == ""
|
||||
|
||||
func _pump_until(seconds: float, condition: Callable) -> bool:
|
||||
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
if condition.call():
|
||||
return true
|
||||
await process_frame
|
||||
return condition.call()
|
||||
|
||||
func _finish(output: String, report: Dictionary) -> void:
|
||||
report["passed"] = not failed
|
||||
var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(JSON.stringify(report, " "))
|
||||
file.close()
|
||||
print("python_game_render_test: %s (%s)" % ["FAIL" if failed else "PASS", output])
|
||||
ui.queue_free()
|
||||
await process_frame
|
||||
quit(1 if failed else 0)
|
||||
|
||||
func run() -> void:
|
||||
root.size = Vector2i(1024, 768)
|
||||
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())
|
||||
DirAccess.make_dir_recursive_absolute(output)
|
||||
var report := {"output": output, "headless": DisplayServer.get_name() == "headless"}
|
||||
|
||||
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():
|
||||
printerr("python_game_render_test: needs MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD")
|
||||
quit(2)
|
||||
return
|
||||
|
||||
world = Python3DSurface.new()
|
||||
world.name = "Python3DSurface"
|
||||
ui = PythonUISurface.new()
|
||||
ui.name = "PythonUISurface"
|
||||
# The UI surface runs CPythonApplication::Process in its _process; the 3D surface reads the draws
|
||||
# of that frame after it, so it comes second in the tree.
|
||||
root.add_child(ui)
|
||||
root.add_child(world)
|
||||
var err: String = ui.run_app()
|
||||
_check(err == "", "system.py reached app.Loop() (%s)" % err)
|
||||
if err != "":
|
||||
await _finish(output, report)
|
||||
return
|
||||
|
||||
_check(_py("import __main__, os, networkModule, introLogin, introSelect, introLoading, game\n"
|
||||
+ "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"),
|
||||
"found networkModule.MainStream")
|
||||
var at_login := await _pump_until(20, func(): return _py(
|
||||
"assert isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"))
|
||||
_check(at_login, "login window")
|
||||
|
||||
# LoginWindow.Connect with the account from the environment.
|
||||
_check(_py("_env = os.environ\n"
|
||||
+ "_slot = int(_env.get('MT_LIVE_SLOT', '0'))\n"
|
||||
+ "_stream.SetConnectInfo(_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_GAME_PORT', '13000')), "
|
||||
+ "_env['MT_LIVE_HOST'], int(_env.get('MT_LIVE_AUTH_PORT', '11000')))\n"
|
||||
+ "_stream.curPhaseWindow.Connect(_env['MT_LIVE_LOGIN'], _env['MT_LIVE_PASSWORD'])\n"
|
||||
+ "del _env"), "LoginWindow.Connect")
|
||||
var selecting := await _pump_until(30, func(): return _py(
|
||||
"assert isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"))
|
||||
_check(selecting, "character select")
|
||||
if selecting:
|
||||
_check(_py("_stream.curPhaseWindow.SelectSlot(_slot)\n_stream.curPhaseWindow.StartGame()"), "StartGame")
|
||||
var in_game := selecting and await _pump_until(60, func(): return _py(
|
||||
"assert isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"))
|
||||
_check(in_game, "game window")
|
||||
|
||||
# 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:
|
||||
await process_frame
|
||||
report["draw_count"] = world.draw_count
|
||||
report["unlit_stand_in_count"] = world.unlit_stand_in_count
|
||||
report["camera_fov"] = world.camera.fov
|
||||
report["camera_near"] = world.camera.near
|
||||
report["camera_far"] = world.camera.far
|
||||
var textures := []
|
||||
var draws: Array = Metin2PythonHost.render3d_draws()
|
||||
if not draws.is_empty():
|
||||
report["proj"] = Array(draws[0]["proj"])
|
||||
report["view"] = Array(draws[0]["view"])
|
||||
# Each draw's render state (everything but the arrays and matrices).
|
||||
var states := []
|
||||
for draw in draws:
|
||||
var state := {}
|
||||
for key in draw:
|
||||
if typeof(draw[key]) in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_COLOR, TYPE_VECTOR3]:
|
||||
state[key] = str(draw[key])
|
||||
state["vertices"] = draw["positions"].size()
|
||||
if draw.has("uv0"):
|
||||
var lo := Vector2(INF, INF)
|
||||
var hi := -lo
|
||||
for uv in draw["uv0"]:
|
||||
lo = lo.min(uv)
|
||||
hi = hi.max(uv)
|
||||
state["uv0_range"] = str([lo, hi])
|
||||
state["indices"] = draw["indices"].size()
|
||||
states.append(state)
|
||||
report["draw_states"] = states
|
||||
for draw in draws:
|
||||
if not draw["texture0"].is_empty() and not textures.has(draw["texture0"]):
|
||||
textures.append(draw["texture0"])
|
||||
report["textures"] = textures
|
||||
var surfaces_textured := 0
|
||||
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)
|
||||
if material and material.albedo_texture:
|
||||
surfaces_textured += 1
|
||||
report["surfaces_textured"] = surfaces_textured
|
||||
var bounds := []
|
||||
for instance in world.find_children("Draw*", "MeshInstance3D", false, false):
|
||||
if instance.visible and instance.mesh:
|
||||
bounds.append(str(instance.transform * instance.mesh.get_aabb()))
|
||||
report["camera_space_bounds"] = bounds
|
||||
_check(not drawn or surfaces_textured > 0, "character surfaces have their diffuse textures (%d)" % surfaces_textured)
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await RenderingServer.frame_post_draw
|
||||
var image := root.get_texture().get_image()
|
||||
_check(image.save_png(output.path_join("game.png")) == OK, "screenshot saved")
|
||||
# The character is centred by __UpdateCamera: the middle of the picture is not background.
|
||||
var centre := Rect2i(image.get_width() * 2 / 5, image.get_height() * 3 / 10,
|
||||
image.get_width() / 5, image.get_height() * 2 / 5)
|
||||
var lit := 0
|
||||
for y in range(centre.position.y, centre.end.y, 4):
|
||||
for x in range(centre.position.x, centre.end.x, 4):
|
||||
if image.get_pixel(x, y).get_luminance() > 0.05:
|
||||
lit += 1
|
||||
report["centre_lit_samples"] = lit
|
||||
_check(lit > 50, "character pixels in the middle of the screen (%d samples)" % lit)
|
||||
# The 3D pass alone, for looking at the character without the UI.
|
||||
ui.visible = false
|
||||
await RenderingServer.frame_post_draw
|
||||
root.get_texture().get_image().save_png(output.path_join("game_3d.png"))
|
||||
ui.visible = true
|
||||
await _finish(output, report)
|
||||
@@ -37,6 +37,19 @@ static func load_tex(assets_root: String, vpath: String) -> Texture2D:
|
||||
_cache[key] = tex
|
||||
return tex
|
||||
|
||||
# 只从 40250 Client/pack(Metin2Pack)取,不看 assets/ 散文件:移植的客户端从包里读模型,
|
||||
# 贴图也必须是同一份(assets/ 里同名的 fork 贴图 UV 布局可能不同)。
|
||||
static func load_pack_tex(vpath: String) -> Texture2D:
|
||||
if vpath == "":
|
||||
return null
|
||||
var key := "pack|" + vpath
|
||||
if _cache.has(key):
|
||||
return _cache[key]
|
||||
# 40250 的包名带盘符(d:/ymir work/...),原样交给 CEterPackManager。
|
||||
var tex: Texture2D = _load_pack_image(vpath.replace("\\", "/"))
|
||||
_cache[key] = tex
|
||||
return tex
|
||||
|
||||
static func _strip_drive(p: String) -> String:
|
||||
var s := p.replace("\\", "/")
|
||||
if s.length() >= 2 and s[1] == ":":
|
||||
|
||||
Reference in New Issue
Block a user