2V2-e.4: render 40250 pack terrain with map lighting
This commit is contained in:
+128
-19
@@ -8,10 +8,8 @@
|
||||
# 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.
|
||||
# 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")
|
||||
@@ -26,14 +24,20 @@ 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 _ground_offset_cm := NAN
|
||||
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
|
||||
var unlit_stand_in_count := 0 # Compatibility report field; real lighting keeps this at zero.
|
||||
|
||||
func _ready() -> void:
|
||||
camera = Camera3D.new()
|
||||
@@ -46,6 +50,7 @@ func _ready() -> void:
|
||||
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()
|
||||
@@ -80,7 +85,6 @@ static func multiply(a: PackedFloat32Array, b: PackedFloat32Array) -> PackedFloa
|
||||
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:
|
||||
@@ -95,11 +99,16 @@ func update_frame() -> void:
|
||||
_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
|
||||
if _environment_missing(draw):
|
||||
stand_ins += 1
|
||||
elif not light_set and not terrain_report.is_empty():
|
||||
_apply_map_light(view)
|
||||
light_set = light.visible
|
||||
var instance := _mesh_instance(shown)
|
||||
instance.transform = d3d_transform(multiply(draw["world"], view))
|
||||
instance.mesh = _build_mesh(draw)
|
||||
@@ -111,7 +120,106 @@ func update_frame() -> void:
|
||||
if not light_set:
|
||||
light.visible = false
|
||||
draw_count = shown
|
||||
unlit_stand_in_count = stand_ins
|
||||
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
|
||||
map_path = _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.
|
||||
terrain.call("set_initial_focus_cm", focus)
|
||||
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
|
||||
_ground_offset_cm = NAN
|
||||
# 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 base: Vector2 = terrain.call("get_map_base_cm")
|
||||
var camera_world := d3d_transform(view).affine_inverse().origin
|
||||
var local_x := (camera_world.x - base.x) / 100.0
|
||||
var local_y := (-camera_world.y - base.y) / 100.0
|
||||
terrain.call("set_focus_position", local_x, local_y)
|
||||
# Until the native CMapManager::GetHeight path is ported, the actor enters at
|
||||
# z=0. Align the map once to its actual sampled starting elevation.
|
||||
if is_nan(_ground_offset_cm):
|
||||
_ground_offset_cm = float(terrain.call("sample_height", local_x, local_y)) * 100.0
|
||||
var from_map := Transform3D(Basis(Vector3(100, 0, 0), Vector3(0, 0, 100),
|
||||
Vector3(0, -100, 0)), Vector3(base.x, -base.y, -_ground_offset_cm))
|
||||
terrain.transform = d3d_transform(view) * from_map
|
||||
|
||||
func _apply_map_light(view: PackedFloat32Array) -> void:
|
||||
if not terrain_report.get("env_ok", false) or not terrain_report.get("character_light_enabled", false):
|
||||
light.visible = false
|
||||
return
|
||||
var direction: Vector3 = d3d_transform(view).basis * terrain_report["character_light_direction"]
|
||||
if direction.length_squared() < 1e-8:
|
||||
light.visible = false
|
||||
return
|
||||
light.visible = true
|
||||
light.transform = Transform3D(Basis.looking_at(direction.normalized(),
|
||||
Vector3.UP if absf(direction.normalized().y) < 0.99 else Vector3.RIGHT), Vector3.ZERO)
|
||||
light.light_color = terrain_report["character_light_diffuse"]
|
||||
light.light_energy = 1.0
|
||||
var world_env := terrain.get_node_or_null("WorldEnv") as WorldEnvironment
|
||||
if world_env and world_env.environment:
|
||||
world_env.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
world_env.environment.ambient_light_color = terrain_report["character_light_ambient"]
|
||||
|
||||
func _apply_projection(proj: PackedFloat32Array) -> void:
|
||||
# D3DXMatrixPerspectiveFovRH: _22 = cot(fovy / 2), _33 = zf / (zn - zf), _43 = zn * zf / (zn - zf).
|
||||
@@ -135,20 +243,21 @@ func _apply_light(draw: Dictionary, view: PackedFloat32Array) -> void:
|
||||
# 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
|
||||
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]
|
||||
@@ -170,7 +279,7 @@ func _build_mesh(draw: Dictionary) -> ArrayMesh:
|
||||
return mesh
|
||||
|
||||
func _material(draw: Dictionary) -> StandardMaterial3D:
|
||||
var lit: bool = draw["lighting"] and not _environment_missing(draw)
|
||||
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),
|
||||
|
||||
@@ -107,6 +107,24 @@ func run() -> void:
|
||||
await process_frame
|
||||
report["draw_count"] = world.draw_count
|
||||
report["unlit_stand_in_count"] = world.unlit_stand_in_count
|
||||
report["terrain"] = world.terrain_report
|
||||
report["atlas_entries"] = world._atlas.size()
|
||||
_check(world._atlas.size() > 0, "40250 AtlasInfo chooses the current map")
|
||||
if world.terrain:
|
||||
report["terrain_base_cm"] = str(world.terrain.call("get_map_base_cm"))
|
||||
var tile_bounds := []
|
||||
for tile in world.terrain.find_children("Terrain*", "MeshInstance3D", true, false):
|
||||
if tile_bounds.size() >= 4:
|
||||
break
|
||||
tile_bounds.append(str(tile.global_transform * tile.mesh.get_aabb()))
|
||||
report["terrain_camera_bounds"] = tile_bounds
|
||||
_check(world.terrain != null and int(world.terrain_report.get("chunks_built", 0)) > 0,
|
||||
"40250 terrain height tiles loaded")
|
||||
_check(int(world.terrain_report.get("chunks_splatted", 0)) > 0,
|
||||
"40250 terrain textures applied")
|
||||
_check(bool(world.terrain_report.get("env_ok", false)) and world.light.visible,
|
||||
"map .msenv lights the character")
|
||||
_check(world.unlit_stand_in_count == 0, "unlit stand-in removed")
|
||||
report["camera_fov"] = world.camera.fov
|
||||
report["camera_near"] = world.camera.near
|
||||
report["camera_far"] = world.camera.far
|
||||
@@ -138,6 +156,7 @@ func run() -> void:
|
||||
textures.append(draw["texture0"])
|
||||
report["textures"] = textures
|
||||
var surfaces_textured := 0
|
||||
var lit_surfaces := 0
|
||||
var distinct_textures := {}
|
||||
for instance in world.find_children("Draw*", "MeshInstance3D", false, false):
|
||||
if instance.visible and instance.mesh:
|
||||
@@ -145,7 +164,10 @@ func run() -> void:
|
||||
if material and material.albedo_texture:
|
||||
surfaces_textured += 1
|
||||
distinct_textures[material.albedo_texture] = true
|
||||
if material and material.shading_mode == BaseMaterial3D.SHADING_MODE_PER_VERTEX:
|
||||
lit_surfaces += 1
|
||||
report["surfaces_textured"] = surfaces_textured
|
||||
report["lit_surfaces"] = lit_surfaces
|
||||
report["distinct_textures"] = distinct_textures.size()
|
||||
# Every texture name gets its own texture (face, body, hair are not one material).
|
||||
_check(distinct_textures.size() == textures.size(),
|
||||
@@ -156,6 +178,7 @@ func run() -> void:
|
||||
bounds.append(str(instance.transform * instance.mesh.get_aabb()))
|
||||
report["camera_space_bounds"] = bounds
|
||||
_check(not drawn or surfaces_textured > 0, "character surfaces have their diffuse textures (%d)" % surfaces_textured)
|
||||
_check(lit_surfaces > 0, "character surfaces use directional lighting (%d)" % lit_surfaces)
|
||||
if DisplayServer.get_name() != "headless":
|
||||
await RenderingServer.frame_post_draw
|
||||
var image := root.get_texture().get_image()
|
||||
@@ -170,6 +193,15 @@ func run() -> void:
|
||||
lit += 1
|
||||
report["centre_lit_samples"] = lit
|
||||
_check(lit > 50, "character pixels in the middle of the screen (%d samples)" % lit)
|
||||
var ground_low := 1.0
|
||||
var ground_high := 0.0
|
||||
for y in range(image.get_height() * 3 / 4, image.get_height() - 20, 17):
|
||||
for x in range(30, image.get_width() - 30, 23):
|
||||
var luminance := image.get_pixel(x, y).get_luminance()
|
||||
ground_low = minf(ground_low, luminance)
|
||||
ground_high = maxf(ground_high, luminance)
|
||||
report["ground_luminance_range"] = ground_high - ground_low
|
||||
_check(ground_high - ground_low > 0.08, "textured ground visible in screenshot")
|
||||
# The 3D pass alone, for looking at the character without the UI.
|
||||
ui.visible = false
|
||||
await RenderingServer.frame_post_draw
|
||||
|
||||
Reference in New Issue
Block a user