saumwacht-godot/tools/compare_stage.gd
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

211 lines
7.7 KiB
GDScript

extends Node3D
## Godot/Unreal comparison stage: identical portable assets, cameras and lighting.
## Captures close-up + game-camera stills, a walk cycle, and 1/30/90 load measurements.
const CAPTURES := "res://docs/screenshots/compare"
var viewport: SubViewport
var stage: Node3D
var camera: Camera3D
var figures: Array[Node3D] = []
var results: Array = []
func _ready() -> void:
call_deferred("run")
func _load_glb(path: String) -> Node3D:
var packed := load(path) as PackedScene
if packed == null:
push_error("Missing compare asset: " + path)
return Node3D.new()
return packed.instantiate() as Node3D
func _shot(name: String) -> void:
for n: int in range(8):
await get_tree().process_frame
await RenderingServer.frame_post_draw
viewport.get_texture().get_image().save_png(CAPTURES + "/" + name + ".png")
print("SHOT: ", name)
func _aim(pos: Vector3, target: Vector3, size: float) -> void:
camera.size = size
camera.position = pos
camera.look_at(target, Vector3.UP)
await get_tree().process_frame
await get_tree().process_frame
func run() -> void:
Soundscape.enabled = false
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
get_window().size = Vector2i(960, 540)
viewport = SubViewport.new()
viewport.own_world_3d = true
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
viewport.msaa_3d = Viewport.MSAA_4X
viewport.size = Vector2i(1920, 1080)
add_child(viewport)
stage = Node3D.new()
viewport.add_child(stage)
var display := TextureRect.new()
display.texture = viewport.get_texture()
display.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
display.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
add_child(display)
var env := WorldEnvironment.new()
env.environment = Environment.new()
preload("res://scripts/presentation/lighting.gd").configure(env.environment, true)
stage.add_child(env)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-48, -36, 0)
light.light_color = Color("ffd4a0")
light.light_energy = 1.7
light.shadow_enabled = true
stage.add_child(light)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-35, 150, 0)
fill.light_color = Color("a8c4e8")
fill.light_energy = .55
stage.add_child(fill)
# Solid unshaded stage floor so studio light cannot blow it out.
var floor := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(24, 24)
floor.mesh = plane
var floor_mat := StandardMaterial3D.new()
floor_mat.albedo_color = Color("243038")
floor_mat.roughness = 1.0
floor_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
floor.material_override = floor_mat
stage.add_child(floor)
# Arena tile as a modest platform under the figures.
var tile: Node3D = _load_glb("res://assets/compare/compare-arena-tile.glb")
tile.position = Vector3(0, .01, .35)
tile.scale = Vector3(0.85, .35, 0.7)
stage.add_child(tile)
var rock: Node3D = _load_glb("res://assets/compare/compare-rock.glb")
rock.position = Vector3(-2.8, 0, -1.0)
rock.rotation.y = .45
rock.scale = Vector3.ONE * 1.0
stage.add_child(rock)
var tree: Node3D = _load_glb("res://assets/compare/compare-tree.glb")
tree.position = Vector3(3.0, 0, -1.5)
tree.rotation.y = -.35
tree.scale = Vector3.ONE * .9
stage.add_child(tree)
var ring: Node3D = AssetLibrary.instantiate_fighter("w04")
ring.position = Vector3(-0.6, 0, 0.55)
ring.rotation.y = .2
stage.add_child(ring)
figures.append(ring)
var worker: Node3D = AssetLibrary.instantiate_fighter("worker")
worker.position = Vector3(0.9, 0, 0.7)
worker.rotation.y = -.45
stage.add_child(worker)
figures.append(worker)
var core: Node3D = AssetLibrary.instantiate_fighter("core_team0")
core.position = Vector3(0.1, 0, -1.8)
stage.add_child(core)
camera = Camera3D.new()
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
camera.size = 2.4
stage.add_child(camera)
await get_tree().process_frame
# 1) Close-up of pattern figure + worker.
await _aim(Vector3(1.6, 1.6, 2.6), Vector3(0.1, 1.0, 0.2), 2.4)
print("CAM closeup size=", camera.size, " pos=", camera.position)
await _shot("closeup")
# 2) Game camera: high and wide so rock, tree, core and figures all read.
await _aim(Vector3(2.0, 14.0, 12.5), Vector3(0, 0.4, -0.2), 10.0)
print("CAM game size=", camera.size, " pos=", camera.position)
await _shot("game-camera")
# 3) Movement pass on the game camera.
for i in range(30):
var t: float = i / 10.0
if ring.has_method("preview"):
ring.preview("walk", fmod(t, ring.clip_length("walk")))
if worker.has_method("update_work"):
worker.update_work(1.0 / 10.0, 0.0)
await get_tree().process_frame
if i in [0, 12, 24]:
await RenderingServer.frame_post_draw
viewport.get_texture().get_image().save_png(CAPTURES + "/move-%02d.png" % i)
print("SHOT: move-", i)
await _shot("move-end")
# Load measurements 1 / 30 / 90 at 1080p and 1440p.
for resolution: Vector2i in [Vector2i(1920, 1080), Vector2i(2560, 1440)]:
viewport.size = resolution
for count: int in [1, 30, 90]:
for figure: Node3D in figures:
if is_instance_valid(figure):
figure.free()
figures.clear()
var columns: int = ceili(sqrt(count))
for i: int in range(count):
var figure: Node3D = AssetLibrary.instantiate_fighter("w04" if i % 2 == 0 else "worker")
figure.position = Vector3((i % columns - columns * .5) * 2.2, 0, (i / float(columns) - columns * .5) * 2.2 + 2.5)
stage.add_child(figure)
figures.append(figure)
await _aim(Vector3(6, 16, 20), Vector3(0, 0.8, 1.5), maxf(4, columns * 2.5))
var times: Array[float] = []
var started: int = Time.get_ticks_usec()
var previous: int = started
while Time.get_ticks_usec() - started < 8000000:
var elapsed: float = (Time.get_ticks_usec() - started) / 1000000.0
for i: int in range(figures.size()):
var figure: Node3D = figures[i]
if figure.has_method("preview"):
figure.preview("walk", fmod(elapsed + i * .05, 1.4))
elif figure.has_method("update_work"):
figure.update_work(1.0 / 60.0, i * .1)
await get_tree().process_frame
var now: int = Time.get_ticks_usec()
if elapsed > 2:
times.append((now - previous) / 1000.0)
previous = now
var total: float = 0
for t: float in times:
total += t
times.sort()
var record := {
"resolution": str(resolution),
"instances": count,
"mean_frame_ms": total / maxf(1, times.size()),
"p95_frame_ms": times[int(times.size() * .95)] if not times.is_empty() else 0.0,
"fps": 1000.0 / maxf(0.01, total / maxf(1, times.size())),
"draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
"video_memory_bytes": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED),
}
results.append(record)
print("COMPARE_BENCH: ", record)
var report := {
"engine": "Godot " + Engine.get_version_info().get("string", ""),
"device": RenderingServer.get_video_adapter_name(),
"renderer": RenderingServer.get_current_rendering_method(),
"vsync": false,
"msaa": "4x",
"warmup_seconds": 2,
"sample_seconds": 6,
"scope": "Comparison stage: pattern figure, worker, core, rock, tree, arena tile. Not a full match.",
"camera": {
"projection": "orthogonal",
"closeup": {"position": [1.4, 1.5, 2.4], "look_at": [0.1, 0.95, 0.2], "size": 2.4},
"game": {"position": [1.5, 10.5, 9.5], "look_at": [0, 0.5, 0], "size": 7.0, "yaw": 0.2},
},
"lighting": {
"key": {"rotation_degrees": [-48, -36, 0], "color": "#ffd4a0", "energy": 1.7},
"fill": {"rotation_degrees": [-35, 150, 0], "color": "#a8c4e8", "energy": 0.55},
"tonemap": "ACES",
},
"captures": ["closeup", "game-camera", "move-00", "move-12", "move-24", "move-end"],
"results": results,
}
FileAccess.open("res://docs/compare-godot.json", FileAccess.WRITE).store_string(JSON.stringify(report, "\t"))
print("COMPARE_STAGE_PASS")
await Soundscape.quit_game()