Fix the material cache key: bools given to %d failed to format, so every draw shared the first material (the face texture). The render test now checks one texture per texture name. Offline and live (192.168.21.203) show the dressed main character; live also shows the nearby mobs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
8.7 KiB
GDScript
210 lines
8.7 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.
|
|
#
|
|
# 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)
|
|
# 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
|