Adopt the realism pass and refresh screenshots/docs
- add the v2 runtime picks: per-phase skyline panoramas, brick, sidewalk pavers, dry/wet asphalt, awning canvas, and their normal maps - add the realism tooling and evidence: tools/scene_concepts.py, capture_realism.gd, concept frames, reports/sheets, and tests - retune phase lighting/fog/exposure, add sun yaw, brighten the night glow, add the flashlight beam mesh, and rebuild the planter/tree assets - regenerate all eleven staged screenshots from the current build; compare is 11 good / 0 bad - finish the visual-alignment closeout in DONE/TODO, update architecture, blender-assets, PLAN, and ignore rules (editable Blender sources untracked) - keep the judge retry and document the verified default judge model Verification: headless 227/227; Python tooling 86/86; check_screenshots 11/11.
This commit is contained in:
1 parent
26ecb93fd4
commit
d7e523f63c
70 files changed
+4042
-101
No files matched your search
@@ -0,0 +1,231 @@
|
||||
extends SceneTree
|
||||
## Dev helper for the realism pass: capture a fixed shot list of every venue at
|
||||
## every shift phase, optionally with candidate textures swapped in at runtime.
|
||||
## No game code changes are needed to try a new skyline or surface tile.
|
||||
## Baseline and candidate runs share the same cameras, so
|
||||
## `tools/scene_concepts.py realism` can compare them against the concept targets.
|
||||
##
|
||||
## Usage (windowed; user args go after `--`):
|
||||
## godot --path . --script res://src/test/capture_realism.gd -- \
|
||||
## --out=res://docs/screens/realism/<run>/baseline
|
||||
## godot --path . --script res://src/test/capture_realism.gd -- \
|
||||
## --out=res://docs/screens/realism/<run>/candidate \
|
||||
## --swap=res://assets/textures/skyline.png=res://assets/textures/ai/game_ready/v2/skyline_day-v4.png \
|
||||
## --night-swap=res://assets/textures/skyline.png=res://assets/textures/ai/game_ready/v2/skyline_night-v4.png \
|
||||
## --tint=res://assets/textures/skyline.png=1,1,1 \
|
||||
## --normal=res://assets/textures/ai/game_ready/brick_facade.png=res://.../brick_facade_normal.png
|
||||
## # Or capture only some stages: --stages=day,late
|
||||
##
|
||||
## `--swap=OLD=NEW` replaces every StandardMaterial3D albedo texture loaded
|
||||
## from OLD. `--tint=OLD=r,g,b` sets those materials' albedo colour.
|
||||
## `--normal=OLD=MAP` enables a normal map on them. `--night-swap` applies to
|
||||
## the dusk/night stages. Swaps match the *original* path, so day and night
|
||||
## swaps can target the same material. Existing files in `--out` are never
|
||||
## overwritten (a `-vN` suffix is added).
|
||||
|
||||
## Guard position/rotation per shot, matching the README capture poses.
|
||||
const SHOTS_DAY := [
|
||||
{"name": "entrance_day", "pos": Vector3(0, 0.1, 10.5), "yaw": 0.0, "pitch": 2.0},
|
||||
{"name": "boulevard_day", "pos": Vector3(0, 0.1, 10.5), "yaw": -70.0, "pitch": 3.0},
|
||||
{"name": "street_day", "pos": Vector3(0, 0.1, 10.5), "yaw": 180.0, "pitch": 2.0},
|
||||
]
|
||||
const SHOTS_EVENING := [
|
||||
{"name": "boulevard_night", "pos": Vector3(0, 0.1, 10.5), "yaw": -70.0, "pitch": 3.0},
|
||||
{"name": "street_night", "pos": Vector3(0, 0.1, 10.5), "yaw": 180.0, "pitch": 2.0},
|
||||
]
|
||||
const SHOTS_LATE := [
|
||||
{"name": "entrance_late", "pos": Vector3(0, 0.1, 9.0), "yaw": 0.0, "pitch": 3.0},
|
||||
{"name": "street_late", "pos": Vector3(0, 0.1, 10.5), "yaw": 180.0, "pitch": 2.0},
|
||||
]
|
||||
const SHOTS_JOKE := [
|
||||
{"name": "joke_shop_door", "pos": Vector3(2.2, 0.1, 7.0), "yaw": -20.0, "pitch": 2.0},
|
||||
]
|
||||
const SHOTS_STUDIO := [
|
||||
{"name": "studio_door", "pos": Vector3(0, 0.1, 6.2), "yaw": 0.0, "pitch": 2.0},
|
||||
]
|
||||
## Stage list: shift time selects the phase, `venue` switches the scenario first.
|
||||
const STAGES := [
|
||||
{"name": "day", "elapsed": 0.0, "venue": "", "shots": SHOTS_DAY, "night": false},
|
||||
{"name": "evening", "elapsed": 620.0, "venue": "", "shots": SHOTS_EVENING, "night": true},
|
||||
{"name": "late", "elapsed": 760.0, "venue": "", "shots": SHOTS_LATE, "night": true},
|
||||
{"name": "joke_shop", "elapsed": 700.0, "venue": "joke_shop", "shots": SHOTS_JOKE, "night": true},
|
||||
{"name": "studio", "elapsed": 700.0, "venue": "tv_studio", "shots": SHOTS_STUDIO, "night": true},
|
||||
]
|
||||
const SETTLE_FRAMES := 45
|
||||
const MAX_FRAMES := 20000
|
||||
|
||||
var gm: Node = null
|
||||
var frames := 0
|
||||
var out_dir := "user://screenshots/realism/capture"
|
||||
var swaps := {}
|
||||
var night_swaps := {}
|
||||
var tints := {}
|
||||
var normals := {}
|
||||
## material -> original albedo path, captured before the first swap.
|
||||
var _origin := {}
|
||||
var _stage := -1
|
||||
## Optional `--stages=day,late` subset (fast iteration while tuning).
|
||||
var _only_stages := {}
|
||||
var _queue: Array = []
|
||||
var _shot_frames := 0
|
||||
var _phase := "boot"
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
_parse_args()
|
||||
var packed: PackedScene = load("res://scenes/main.tscn")
|
||||
root.add_child(packed.instantiate())
|
||||
|
||||
func _parse_args() -> void:
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
var text := str(arg)
|
||||
if text.begins_with("--out="):
|
||||
out_dir = text.substr(6)
|
||||
elif text.begins_with("--night-swap="):
|
||||
_pair(text.substr(13), night_swaps)
|
||||
elif text.begins_with("--swap="):
|
||||
_pair(text.substr(7), swaps)
|
||||
elif text.begins_with("--tint="):
|
||||
_pair(text.substr(7), tints)
|
||||
elif text.begins_with("--normal="):
|
||||
_pair(text.substr(9), normals)
|
||||
elif text.begins_with("--stages="):
|
||||
for name in text.substr(9).split(","):
|
||||
_only_stages[name.strip_edges()] = true
|
||||
|
||||
func _pair(text: String, into: Dictionary) -> void:
|
||||
var cut := text.find("=", 6) # skip the "res://" scheme
|
||||
if cut < 0:
|
||||
push_error("expected OLD=NEW, got %s" % text)
|
||||
return
|
||||
into[text.substr(0, cut)] = text.substr(cut + 1)
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frames += 1
|
||||
if frames > MAX_FRAMES:
|
||||
push_error("capture_realism timed out")
|
||||
quit(1)
|
||||
return true
|
||||
if gm == null:
|
||||
gm = root.find_child("GameManager", true, false)
|
||||
return false
|
||||
match _phase:
|
||||
"boot":
|
||||
var menu = gm.get("start_menu")
|
||||
if menu != null and menu.visible:
|
||||
gm.call("_on_venue_selected", "hollywood_building")
|
||||
_phase = "wait"
|
||||
_shot_frames = 0
|
||||
"wait":
|
||||
_shot_frames += 1
|
||||
if _shot_frames >= 20:
|
||||
_phase = "stage"
|
||||
"stage":
|
||||
_begin_stage()
|
||||
# `_begin_stage()` sets "done" after the last stage; do not clobber it.
|
||||
if _phase != "done":
|
||||
_phase = "shoot"
|
||||
"shoot":
|
||||
_run_queue()
|
||||
"done":
|
||||
print("capture_realism done -> %s" % out_dir)
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
func _begin_stage() -> void:
|
||||
_stage += 1
|
||||
while _stage < STAGES.size() and not _only_stages.is_empty() \
|
||||
and not _only_stages.has(str(STAGES[_stage]["name"])):
|
||||
_stage += 1
|
||||
if _stage >= STAGES.size():
|
||||
_phase = "done"
|
||||
return
|
||||
var stage: Dictionary = STAGES[_stage]
|
||||
if str(stage["venue"]) != "":
|
||||
gm.call("switch_scenario", str(stage["venue"]))
|
||||
gm.coordinator.elapsed_s = float(stage["elapsed"])
|
||||
_apply(swaps)
|
||||
if bool(stage["night"]):
|
||||
_apply(night_swaps)
|
||||
_queue = (stage["shots"] as Array).duplicate()
|
||||
_shot_frames = 0
|
||||
print("capture_realism: stage %s at %d ms" % [stage["name"], Time.get_ticks_msec()])
|
||||
|
||||
func _run_queue() -> void:
|
||||
if _queue.is_empty():
|
||||
_phase = "stage"
|
||||
return
|
||||
var shot: Dictionary = _queue[0]
|
||||
if _shot_frames == 0:
|
||||
gm.guard.global_position = shot["pos"]
|
||||
gm.guard.rotation.y = deg_to_rad(float(shot["yaw"]))
|
||||
gm.guard.head.rotation.x = deg_to_rad(float(shot["pitch"]))
|
||||
_shot_frames += 1
|
||||
if _shot_frames >= SETTLE_FRAMES:
|
||||
_shoot(str(shot["name"]))
|
||||
_queue.pop_front()
|
||||
_shot_frames = 0
|
||||
|
||||
## Apply swaps, tints and normals to every material whose original albedo path
|
||||
## matches. Called once per stage; materials are visited through the tree.
|
||||
func _apply(table: Dictionary) -> void:
|
||||
var touched := 0
|
||||
var by_path := {}
|
||||
for node in root.find_children("*", "GeometryInstance3D", true, false):
|
||||
for mat in _materials_of(node):
|
||||
var original := _original_path(mat)
|
||||
if original == "":
|
||||
continue
|
||||
if table.has(original):
|
||||
var tex := load(str(table[original])) as Texture2D
|
||||
if tex != null:
|
||||
mat.albedo_texture = tex
|
||||
touched += 1
|
||||
var file := original.get_file()
|
||||
by_path[file] = int(by_path.get(file, 0)) + 1
|
||||
if tints.has(original):
|
||||
var parts := str(tints[original]).split(",")
|
||||
if parts.size() >= 3:
|
||||
mat.albedo_color = Color(float(parts[0]), float(parts[1]), float(parts[2]))
|
||||
if normals.has(original):
|
||||
var normal_tex := load(str(normals[original])) as Texture2D
|
||||
if normal_tex != null:
|
||||
mat.normal_enabled = true
|
||||
mat.normal_texture = normal_tex
|
||||
if touched > 0:
|
||||
print("capture_realism: swapped %d material(s) %s" % [touched, str(by_path)])
|
||||
|
||||
func _original_path(mat: StandardMaterial3D) -> String:
|
||||
if not _origin.has(mat):
|
||||
_origin[mat] = mat.albedo_texture.resource_path if mat.albedo_texture else ""
|
||||
return str(_origin[mat])
|
||||
|
||||
func _materials_of(node: Node) -> Array[StandardMaterial3D]:
|
||||
var out: Array[StandardMaterial3D] = []
|
||||
var geo := node as GeometryInstance3D
|
||||
if geo.material_override is StandardMaterial3D:
|
||||
out.append(geo.material_override)
|
||||
var mesh_instance := node as MeshInstance3D
|
||||
if mesh_instance != null and mesh_instance.mesh != null:
|
||||
for i in mesh_instance.mesh.get_surface_count():
|
||||
var override := mesh_instance.get_surface_override_material(i)
|
||||
if override is StandardMaterial3D:
|
||||
out.append(override)
|
||||
var base := mesh_instance.mesh.surface_get_material(i)
|
||||
if base is StandardMaterial3D:
|
||||
out.append(base)
|
||||
return out
|
||||
|
||||
func _shoot(shot_name: String) -> void:
|
||||
var dir := ProjectSettings.globalize_path(out_dir)
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
var path := dir.path_join(shot_name + ".png")
|
||||
var index := 2
|
||||
while FileAccess.file_exists(path):
|
||||
path = dir.path_join("%s-v%d.png" % [shot_name, index])
|
||||
index += 1
|
||||
var img: Image = root.get_texture().get_image()
|
||||
var err := img.save_png(path)
|
||||
print("%s -> %s err=%d at %d ms (fps %d)" % [shot_name, path, err,
|
||||
Time.get_ticks_msec(), Engine.get_frames_per_second()])
|
||||
@@ -30,6 +30,26 @@ func test_textured_recipes_keep_their_texture_and_profile() -> bool:
|
||||
var e := TestUtils.check(Materials.flat(Color.RED, "metal").metallic > 0.5, "metal role is metallic")
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_realism_pick_tiles_and_wet_asphalt() -> bool:
|
||||
## The realism pass swapped the flat first-generation tiles for the v2 picks.
|
||||
## The street has a dry and a wet variant for day and late night.
|
||||
var brick := Materials.brick()
|
||||
var dry := Materials.asphalt()
|
||||
var wet := Materials.asphalt(Color(0.46, 0.48, 0.53), Vector2(30, 7), true)
|
||||
var a := TestUtils.check(brick.albedo_texture.resource_path.contains("v2/picks/"),
|
||||
"brick uses the promoted v2 pick")
|
||||
var b := TestUtils.check(brick.normal_enabled and brick.normal_texture != null,
|
||||
"brick carries its normal map")
|
||||
var c := TestUtils.check(dry.albedo_texture != wet.albedo_texture,
|
||||
"wet asphalt uses its own albedo")
|
||||
var d := TestUtils.check(wet.roughness < dry.roughness,
|
||||
"wet asphalt is glossier than dry asphalt")
|
||||
var e := TestUtils.check(wet.metallic_specular > dry.metallic_specular,
|
||||
"wet asphalt catches the lamps")
|
||||
var f := TestUtils.check(wet.albedo_texture.resource_path.contains("picks"),
|
||||
"the wet map is a promoted pick")
|
||||
return a and b and c and d and e and f
|
||||
|
||||
func test_glass_and_emissive_recipes() -> bool:
|
||||
var glass := Materials.glass()
|
||||
var pane := Materials.window_pane()
|
||||
|
||||
@@ -94,19 +94,28 @@ func test_steps_have_colliders() -> bool:
|
||||
return a and b and c
|
||||
|
||||
func test_skyline_backdrop_uses_alpha() -> bool:
|
||||
## The opaque skyline texture made the plane render as a black slab (TODO).
|
||||
## The old opaque skyline texture made the plane render as a black slab, and
|
||||
## the scene fog bleached the panorama into a white band. The backdrop is an
|
||||
## alpha-scissored, fog-free shader with a panorama per phase.
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
builder.build()
|
||||
var backdrop := root.find_child("SkylineBackdrop", true, false)
|
||||
var a := TestUtils.check(backdrop != null, "SkylineBackdrop exists")
|
||||
var mat: StandardMaterial3D = builder._skyline_mat
|
||||
var b := TestUtils.check(mat != null, "skyline material captured")
|
||||
var c := TestUtils.check(
|
||||
mat != null and mat.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED,
|
||||
"skyline material uses alpha, not an opaque slab")
|
||||
var mat: ShaderMaterial = builder._skyline_mat
|
||||
var b := TestUtils.check(mat != null and mat.shader != null, "skyline shader material captured")
|
||||
var code := mat.shader.code if mat != null and mat.shader != null else ""
|
||||
var c := TestUtils.check(code.contains("ALPHA") and code.contains("fog_disabled"),
|
||||
"backdrop shader is alpha-scissored and fog-free")
|
||||
var day: Texture2D = mat.get_shader_parameter("sky_texture")
|
||||
builder.set_phase(Rules.Phase.LATE)
|
||||
var night: Texture2D = mat.get_shader_parameter("sky_texture")
|
||||
var d := TestUtils.check(day != null and night != null and day != night,
|
||||
"the skyline panorama follows the shift phase")
|
||||
var e := TestUtils.check(builder._street_mat != null
|
||||
and builder._street_mat.roughness < 0.5, "the night street turns wet")
|
||||
root.free()
|
||||
return a and b and c
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_door_indicator_built() -> bool:
|
||||
var root := Node3D.new()
|
||||
|
||||
Reference in new issue
Block a user