Files
mtgodot-poc/project/python_3d_surface.gd
T
shenleiandClaude Opus 5.5 157f7c3bb4 2V2-e.5c: live smoke on c1, show the map behind the actor
The live run passed its checks, but the screenshot showed wolves floating over a
ground that ended at the actor's feet. Native heights were right (every actor
meets sample_height); two Metin2World settings written for metre units culled
the terrain in this cm-scaled scene: the .msenv depth fog (120-520) and the
terrain patch visibility_range_end (3500). The 3D surface now turns the legacy
fog off (fog belongs to the recorded D3DRS_FOG* state, off until
SetEnvironmentData is ported) and builds one mesh per tile.

The render test checks that every actor (draws grouped by world matrix) stands
on the map and that the map behind the actor shows texture. mouse.cfg, written
by 40250 uitaskbar.py at runtime, is ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:18:20 +09:00

310 lines
13 KiB
GDScript

# 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.
#
# Terrain and lighting come from the same 40250 map pack as the actor textures. The map is placed
# in the recorded D3D camera's coordinate frame, and its .msenv supplies the character light.
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 terrain: Node3D
var terrain_report := {}
var map_path := "metin2_map_a1"
var _atlas: Array = []
var _terrain_attempted := false
var _last_terrain_focus := Vector2(INF, INF)
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
var unlit_stand_in_count := 0 # Compatibility report field; real lighting keeps this at zero.
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
light.light_cull_mask = 1 << 1
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 native_map: String = Metin2PythonHost.current_map_name()
if terrain != null and not native_map.is_empty() and native_map != map_path:
terrain.queue_free()
terrain = null
terrain_report = {}
_terrain_attempted = false
_last_terrain_focus = Vector2(INF, INF)
var shown := 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 terrain == null and not _terrain_attempted:
_load_terrain(draw)
if terrain != null:
_place_terrain(view)
if not light_set and draw["light0"]:
_apply_light(draw, view)
light_set = true
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 = 0
func _load_terrain(draw: Dictionary) -> void:
if not ClassDB.class_exists("Metin2World"):
return
var camera_world := d3d_transform(draw["view"]).affine_inverse().origin
var focus := Vector2(camera_world.x, -camera_world.y)
if focus.distance_to(_last_terrain_focus) < 1000.0:
return
_last_terrain_focus = focus
var native_map: String = Metin2PythonHost.current_map_name()
map_path = native_map if not native_map.is_empty() else _map_for_position(focus)
terrain = ClassDB.instantiate("Metin2World")
terrain.name = "PackTerrain"
terrain.set("auto_load", false)
terrain.set("assets_root", "pack://")
terrain.set("map_path", map_path)
terrain.set("load_radius_tiles", 1)
# One mesh per 256 m tile: split patches carry a visibility_range_end meant for metres, which in
# this cm scene culled every patch beyond 35 m (the map behind the actor vanished).
terrain.set("terrain_patches", 1)
terrain.set("objects_enabled", false)
terrain.set("water_enabled", false)
terrain.call("set_collision_enabled", false)
# A skinned mesh's world matrix can be local to a bone. The inverse view
# locates the camera beside the actor and selects the correct 3x3 map tiles.
# Native RenderGame coordinates are map-local; Metin2World's initial_focus_cm
# expects global AtlasInfo coordinates, so set the local tile directly.
terrain.call("set_focus_tile", Vector2i(int(floor(focus.x / 25600.0)), int(floor(focus.y / 25600.0))))
add_child(terrain)
if not terrain.call("load_map"):
terrain_report = terrain.call("get_load_report")
push_error("40250 terrain failed: %s" % terrain_report.get("last_error", ""))
terrain.queue_free()
terrain = null
return
terrain_report = terrain.call("get_load_report")
if int(terrain_report.get("chunks_built", 0)) == 0:
# Loading's first frame can still contain the previous camera. Retry when
# __UpdateCamera moves to the main actor's map position.
terrain.queue_free()
terrain = null
terrain_report = {}
return
_terrain_attempted = true
# The map's own character light uses legacy Godot coordinates; the recorded game pass
# needs one light in camera space, derived from the same .msenv values below.
var map_character_light := terrain.get_node_or_null("CharacterLight") as DirectionalLight3D
if map_character_light:
map_character_light.visible = false
# WorldEnv from .msenv replaces the black environment created before map load.
if terrain_report.get("env_ok", false):
environment.environment = null
# Its depth fog is in legacy metres, but this scene is in cm, so it fogged everything past
# ~5 m and hid the terrain behind the actor. Fog belongs to the recorded D3DRS_FOG* state,
# which stays off until CPythonBackground::SetEnvironmentData is ported.
var map_environment := terrain.get_node_or_null("WorldEnv") as WorldEnvironment
if map_environment and map_environment.environment:
map_environment.environment.fog_enabled = false
func _map_for_position(global_cm: Vector2) -> String:
if _atlas.is_empty():
for path in ["locale/en/AtlasInfo.txt", "AtlasInfo.txt", "root/atlasinfo.txt"]:
var bytes: PackedByteArray = Metin2Pack.get_bytes(path)
if bytes.is_empty():
continue
for line in bytes.get_string_from_utf8().split("\n"):
var fields := line.strip_edges().replace("\t", " ").split(" ", false)
if fields.size() < 5:
continue
_atlas.append({"name": fields[0].to_lower(), "base": Vector2(float(fields[1]), float(fields[2])),
"size": Vector2(float(fields[3]), float(fields[4])) * 25600.0})
break
for entry in _atlas:
var local: Vector2 = global_cm - entry["base"]
if local.x >= 0.0 and local.y >= 0.0 and local.x < entry["size"].x \
and local.y < entry["size"].y and Metin2Pack.exists(entry["name"] + "/setting.txt"):
return entry["name"]
return map_path
func _place_terrain(view: PackedFloat32Array) -> void:
var camera_world := d3d_transform(view).affine_inverse().origin
var local_x := camera_world.x / 100.0
var local_y := -camera_world.y / 100.0
terrain.call("set_focus_position", local_x, local_y)
var from_map := Transform3D(Basis(Vector3(100, 0, 0), Vector3(0, 0, 100),
Vector3(0, -100, 0)), Vector3.ZERO)
terrain.transform = d3d_transform(view) * from_map
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"]
var active_environment: Environment = environment.environment
if terrain:
var map_environment := terrain.get_node_or_null("WorldEnv") as WorldEnvironment
if map_environment:
active_environment = map_environment.environment
if active_environment:
active_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))
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
instance.layers = 1 << 1
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"]
# alpha_blend / alpha_test / lit are bools: %d needs them as ints.
var key := "%s|%d|%d|%d|%d|%d|%d|%d|%s|%s" % [draw["texture0"], int(draw["alpha_blend"]), draw["src_blend"],
draw["dest_blend"], int(draw["alpha_test"]), draw["alpha_ref"], draw["cull_mode"], int(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