# 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 AssetRoot = preload("res://asset_root.gd") 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 D3DCULL_CCW := 3 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] = [] var _geometry_cache := {} var _geometry_frame := 0 var _bg_mesh: MeshInstance3D var _bg_mat: StandardMaterial3D var _bg_quad: QuadMesh # 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: get_viewport().transparent_bg = true 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_CLEAR_COLOR 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(): var profile_start := Time.get_ticks_usec() if _profile_frame else 0 update_frame() if profile_start != 0: profile_update_ms = float(Time.get_ticks_usec() - profile_start) / 1000.0 var _profile_frame := OS.has_environment("MT_PROFILE_FRAME") var profile_update_ms := 0.0 # 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])) func update_frame() -> void: _geometry_frame += 1 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 var terrain_view := PackedFloat32Array() 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, draw) camera_set = true var view: PackedFloat32Array = draw["view"] if terrain == null and not _terrain_attempted: _load_terrain(draw) if terrain != null: terrain_view = view if not light_set and draw["light0"]: _apply_light(draw, view) light_set = true var instance := _mesh_instance(shown) instance.transform = d3d_transform(view) * d3d_transform(draw["world"]) instance.mesh = _mesh_for_draw(draw) instance.material_override = _material(draw) instance.sorting_offset = float(shown) * 0.01 instance.visible = true shown += 1 if terrain != null and not terrain_view.is_empty(): _place_terrain(terrain_view) 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 _update_background() if _geometry_frame % 120 == 0: for key in _geometry_cache.keys(): if _geometry_frame - int(_geometry_cache[key][2]) > 120: _geometry_cache.erase(key) func _update_background() -> void: var bg_cmd: Dictionary = {} for cmd in Metin2PythonHost.ui_render_commands_batched(): if cmd.get("behind_3d", false) and cmd.get("kind", "") == "image": bg_cmd = cmd break if bg_cmd.is_empty(): if _bg_mesh != null: _bg_mesh.visible = false return if _bg_mesh == null: _bg_mesh = MeshInstance3D.new() _bg_mesh.name = "BackgroundQuad" _bg_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF _bg_quad = QuadMesh.new() _bg_mesh.mesh = _bg_quad _bg_mat = StandardMaterial3D.new() _bg_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED _bg_mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_ALWAYS _bg_mat.cull_mode = BaseMaterial3D.CULL_DISABLED _bg_mesh.material_override = _bg_mat add_child(_bg_mesh) var bg_tex_name: String = bg_cmd.get("text", "") var tex := UiAssets.load_tex(AssetRoot.path(), bg_tex_name) if tex is AtlasTexture: var at: AtlasTexture = tex _bg_mat.albedo_texture = at.atlas var asize: Vector2 = at.atlas.get_size() var r: Rect2 = at.region _bg_mat.uv1_scale = Vector3(r.size.x / asize.x, r.size.y / asize.y, 1.0) _bg_mat.uv1_offset = Vector3(r.position.x / asize.x, r.position.y / asize.y, 0.0) else: _bg_mat.albedo_texture = tex if bg_cmd.has("uv"): var uvs: PackedVector2Array = bg_cmd["uv"] _bg_mat.uv1_scale = Vector3(uvs[3].x - uvs[0].x, uvs[3].y - uvs[0].y, 1.0) _bg_mat.uv1_offset = Vector3(uvs[0].x, uvs[0].y, 0.0) else: _bg_mat.uv1_scale = Vector3(1.0, 1.0, 1.0) _bg_mat.uv1_offset = Vector3(0.0, 0.0, 0.0) var vp_size := get_viewport().get_visible_rect().size var dist := 2800.0 var v_size := 2.0 * dist * tan(deg_to_rad(camera.fov) / 2.0) var h_size := v_size * (float(vp_size.x) / float(vp_size.y)) _bg_quad.size = Vector2(h_size, v_size) _bg_mesh.transform = Transform3D(Basis(), Vector3(0.0, 0.0, -dist)) _bg_mesh.visible = true func _load_terrain(draw: Dictionary) -> void: if not ClassDB.class_exists("Metin2World"): return var native_map: String = Metin2PythonHost.current_map_name() if native_map.is_empty(): 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 map_path = native_map 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, draw: Dictionary = {}) -> void: # D3DXMatrixPerspectiveFovRH: _22 = cot(fovy / 2), _33 = zf / (zn - zf), _43 = zn * zf / (zn - zf). var fov_deg := rad_to_deg(2.0 * atan(1.0 / proj[5])) var zn: float = proj[14] / proj[10] var zf: float = proj[14] / (proj[10] + 1.0) camera.fov = fov_deg camera.near = zn camera.far = zf var vp_offset := Vector2.ZERO if draw.has("viewport"): var vp: PackedFloat32Array = draw["viewport"] var vp_x: float = vp[0] var vp_y: float = vp[1] var vp_w: float = vp[2] var vp_h: float = vp[3] var vp_size := get_viewport().get_visible_rect().size if vp_w > 0.0 and vp_h > 0.0 and (vp_w < vp_size.x or vp_h < vp_size.y or vp_x > 0.0 or vp_y > 0.0): var offset_px_x: float = (vp_x + vp_w / 2.0) - (vp_size.x / 2.0) var offset_px_y: float = (vp_y + vp_h / 2.0) - (vp_size.y / 2.0) if absf(offset_px_x) > 0.5 or absf(offset_px_y) > 0.5: var v_size_near := 2.0 * zn * tan(deg_to_rad(fov_deg) / 2.0) var h_size_near := v_size_near * (vp_size.x / vp_size.y) var off_near_x := (offset_px_x / vp_size.x) * h_size_near var off_near_y := -(offset_px_y / vp_size.y) * v_size_near vp_offset = Vector2(-off_near_x, off_near_y) var v_size_near := 2.0 * zn * tan(deg_to_rad(fov_deg) / 2.0) if vp_offset != Vector2.ZERO: camera.set_frustum(v_size_near, vp_offset, zn, zf) else: camera.set_perspective(fov_deg, zn, zf) 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 _mesh_for_draw(draw: Dictionary) -> ArrayMesh: var key: int = draw.get("geometry_key", 0) if key == 0: return _build_mesh(draw) var revision: int = draw.get("geometry_revision", 0) var cached: Array = _geometry_cache.get(key, []) if not cached.is_empty() and int(cached[0]) == revision: cached[2] = _geometry_frame return cached[1] var mesh := _build_mesh(draw) _geometry_cache[key] = [revision, mesh, _geometry_frame] return mesh 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) return mesh func _material(draw: Dictionary) -> StandardMaterial3D: var lit: bool = draw["lighting"] var z_write: int = draw.get("z_write", 1) var tf: int = int(draw.get("texture_factor", 0xFFFFFFFF)) var tf_a: float = float((tf >> 24) & 0xFF) / 255.0 var tf_r: float = float((tf >> 16) & 0xFF) / 255.0 var tf_g: float = float((tf >> 8) & 0xFF) / 255.0 var tf_b: float = float(tf & 0xFF) / 255.0 var factor_color := Color(tf_r, tf_g, tf_b, tf_a) var uses_tf: bool = bool(draw.get("uses_tf", false)) var has_tf: bool = uses_tf and (tf != 0xFFFFFFFF and tf != -1) var tf_key := "%02x%02x%02x%02x" % [int(tf_r * 63.0), int(tf_g * 63.0), int(tf_b * 63.0), int(tf_a * 63.0)] if has_tf else "" var has_diffuse: bool = draw.has("diffuse") var is_alpha: bool = draw["alpha_blend"] or (has_tf and factor_color.a < 0.99) # alpha_blend / alpha_test / lit are bools: %d needs them as ints. var key := "%s|%d|%d|%d|%d|%d|%d|%d|%d|%d|%s|%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), int(has_diffuse), z_write, draw["material_diffuse"], draw["material_emissive"], tf_key] 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) var base_diffuse: Color = draw["material_diffuse"] if lit else Color.WHITE if has_tf: material.albedo_color = Color(base_diffuse.r * factor_color.r, base_diffuse.g * factor_color.g, base_diffuse.b * factor_color.b, base_diffuse.a * factor_color.a) else: material.albedo_color = base_diffuse material.emission_enabled = lit and draw["material_emissive"] != Color(0, 0, 0, 0) if material.emission_enabled: material.emission = draw["material_emissive"] # D3D adds the emissive term to the lit vertex colour, and D3DTOP_MODULATE then multiplies that by the # texture; Godot adds emission after the texture, so a black additive glow (click_glow_select copy.jpg) # came out as a grey quad. if material.albedo_texture: material.emission_texture = material.albedo_texture material.emission_operator = BaseMaterial3D.EMISSION_OP_MULTIPLY 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 = has_diffuse match int(draw["cull_mode"]): D3DCULL_NONE: material.cull_mode = BaseMaterial3D.CULL_DISABLED D3DCULL_CW: material.cull_mode = BaseMaterial3D.CULL_BACK D3DCULL_CCW: material.cull_mode = BaseMaterial3D.CULL_FRONT _: material.cull_mode = BaseMaterial3D.CULL_BACK if z_write == 0: material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED elif is_alpha: material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_ALWAYS else: material.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY if is_alpha: material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA if int(draw["dest_blend"]) == D3DBLEND_ONE: material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD else: material.blend_mode = BaseMaterial3D.BLEND_MODE_MIX 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