301 lines
12 KiB
GDScript
301 lines
12 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)
|
|
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
|
|
|
|
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
|