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>
310 lines
13 KiB
GDScript
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
|