Package a Forward+ preset with volumetric fog, SDFGI and a volumetric flashlight for a separate Linux/Windows Desktop HQ zip. Gate the effects on a real RenderingDevice, tune LATE fog, and adapt local light falloff for Godot 4.7. Record the Compatibility/Forward+ A/B captures and metrics, add the artifact to the build/publish scripts, and keep the default Compatibility/Web path unchanged. Headless 231/231, Python 87/87; exported HQ binary boots with Vulkan.
399 lines
15 KiB
GDScript
399 lines
15 KiB
GDScript
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
|
|
## # Or feature one NPC role in the portrait: --portrait-type=2
|
|
##
|
|
## `--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).
|
|
|
|
## NPC roles that wear the hood prop (see NpcAvatar.ACCESSORIES); their faces are
|
|
## hidden, so they are poor portrait subjects. Indices follow `Rules.NpcType`.
|
|
const HOODED_TYPES := [5, 6, 7] # TRESPASSER, VIOLENT_THREAT, MENTAL_CRISIS
|
|
|
|
## Optional `--portrait-type=N` picks the NpcType a portrait should feature.
|
|
var _portrait_type := -1
|
|
## Optional `--flashlight` turns the guard lamp on for night stages; the desktop
|
|
## HQ A/B uses it to show the volumetric shaft.
|
|
var _flashlight := false
|
|
## Where the portrait subject is placed, relative to the guard's spawn.
|
|
var _portrait_spot := Vector3(0.0, 0.5, 9.0)
|
|
var _portrait_subject: Node3D = null
|
|
var _last_positions := {}
|
|
|
|
## 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},
|
|
]
|
|
## A face-on portrait: staged relative to a live NPC by `_stage_portrait()`.
|
|
const SHOTS_PORTRAIT := [
|
|
{"name": "npc_portrait", "pos": Vector3.ZERO, "yaw": 0.0, "pitch": 0.0,
|
|
"portrait": true, "spot": Vector3(0.0, 0.5, 9.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},
|
|
# Portrait next, while the day crowd is still on the street.
|
|
{"name": "portrait", "elapsed": 0.0, "venue": "", "shots": SHOTS_PORTRAIT,
|
|
"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
|
|
elif text.begins_with("--portrait-type="):
|
|
_portrait_type = int(text.substr(16))
|
|
elif text == "--flashlight":
|
|
_flashlight = 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(delta)
|
|
"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]
|
|
var venue := str(stage["venue"])
|
|
if venue != "" and venue != str(gm.get("active_scenario_id")):
|
|
gm.call("switch_scenario", venue)
|
|
gm.coordinator.elapsed_s = float(stage["elapsed"])
|
|
if _flashlight:
|
|
var want := bool(stage["night"])
|
|
if want != bool(gm.flashlight_on):
|
|
gm.call("_toggle_flashlight")
|
|
_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(delta: float) -> void:
|
|
if _queue.is_empty():
|
|
_phase = "stage"
|
|
return
|
|
var shot: Dictionary = _queue[0]
|
|
var portrait := bool(shot.get("portrait", false))
|
|
if portrait:
|
|
# NPCs spawn over time; re-stage every frame until one exists, then shoot.
|
|
if shot.has("spot"):
|
|
_portrait_spot = shot["spot"]
|
|
if not _stage_portrait(delta, float(shot.get("angle", 0.0))):
|
|
_shot_frames += 1
|
|
if _shot_frames >= SETTLE_FRAMES * 10:
|
|
push_warning("capture_realism: no NPC for %s" % shot["name"])
|
|
_queue.pop_front()
|
|
_shot_frames = 0
|
|
return
|
|
_shot_frames += 1
|
|
if _shot_frames >= SETTLE_FRAMES:
|
|
_shoot(str(shot["name"]))
|
|
_queue.pop_front()
|
|
_shot_frames = 0
|
|
return
|
|
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
|
|
|
|
## Frame a nearby NPC from the front, with the sun behind the camera so the face
|
|
## is lit. The subject's own yaw is re-written every frame by its FSM, so the
|
|
## portrait turns the visual `Rig` child instead and stands on the sunlit side.
|
|
func _stage_portrait(delta: float, angle_deg: float = 0.0) -> bool:
|
|
var guard_pos: Vector3 = gm.guard.global_position
|
|
var light := _sun_direction()
|
|
var speeds := _speeds(delta)
|
|
var best: Node3D = null
|
|
var best_distance := 999.0
|
|
for node in get_nodes_in_group("npc"):
|
|
var npc := node as Node3D
|
|
if npc == null or _faces_covered(npc):
|
|
continue
|
|
if _portrait_type >= 0 and int(_profile_of(npc).get("npc_type")) != _portrait_type:
|
|
continue
|
|
var distance: float = npc.global_position.distance_to(guard_pos)
|
|
if distance > 25.0:
|
|
continue
|
|
# Close, standing still, and easy to reach: the crowd keeps moving.
|
|
var score: float = distance / 25.0
|
|
if float(speeds.get(npc.get_instance_id(), 9.9)) < 0.05:
|
|
score -= 0.5
|
|
if not bool(npc.get("ephemeral")):
|
|
score -= 0.2
|
|
if best == null or score < best_distance:
|
|
best = npc
|
|
best_distance = score
|
|
if best == null:
|
|
return false
|
|
if _portrait_subject != best:
|
|
_portrait_subject = best
|
|
# Park the rest of the crowd far away: a close-up is a character still and
|
|
# a passer-by walking through the frame would block the subject.
|
|
for node in get_nodes_in_group("npc"):
|
|
var other := node as Node3D
|
|
if other == null or other == best:
|
|
continue
|
|
other.set_physics_process(false)
|
|
other.global_position += Vector3(0.0, 0.0, 140.0)
|
|
# Freeze the subject: its FSM re-writes the yaw every physics tick, which
|
|
# would turn it away from the camera again.
|
|
best.set_physics_process(false)
|
|
# Clear standing spot on the sidewalk, away from the queue: the close-up is a
|
|
# character still, not a slice of the scene, and the crowd would block it.
|
|
var spot := Vector3(0.0, 0.0, 0.0)
|
|
if gm.guard.get("spawn_position") != null:
|
|
spot = gm.guard.get("spawn_position")
|
|
best.global_position = spot + _portrait_spot
|
|
best.rotation.y = 0.0
|
|
# Stand between the subject and the sun, so the face is front-lit.
|
|
var to_light := -light
|
|
to_light = to_light.rotated(Vector3.UP, deg_to_rad(angle_deg))
|
|
gm.guard.global_position = best.global_position + to_light * 1.3
|
|
gm.guard.global_position.y = 0.1
|
|
var look: Vector3 = best.global_position - gm.guard.global_position
|
|
look.y = 0.0
|
|
gm.guard.rotation.y = atan2(-look.x, -look.z)
|
|
gm.guard.head.rotation.x = deg_to_rad(1.0)
|
|
# The focus disc is a gameplay overlay, not part of the character still.
|
|
for focus in best.find_children("FocusCircle", "MeshInstance3D", true, false):
|
|
(focus as MeshInstance3D).visible = false
|
|
# Face the camera: from the subject, the guard lies in the -look direction.
|
|
# Use the drawn rig so an FSM yaw on the root cannot fight the pose.
|
|
var rig := best.find_child("Rig", true, false) as Node3D
|
|
if rig != null:
|
|
rig.rotation.y = atan2(-look.x, -look.z)
|
|
else:
|
|
best.rotation.y = atan2(-look.x, -look.z)
|
|
|
|
return true
|
|
|
|
## Per-instance walking speed in m/s, from the position delta of the last frame.
|
|
func _speeds(delta: float) -> Dictionary:
|
|
var now := {}
|
|
var speeds := {}
|
|
for node in get_nodes_in_group("npc"):
|
|
var npc := node as Node3D
|
|
if npc == null:
|
|
continue
|
|
var id := npc.get_instance_id()
|
|
now[id] = npc.global_position
|
|
if _last_positions.has(id) and delta > 0.0:
|
|
speeds[id] = npc.global_position.distance_to(_last_positions[id]) / delta
|
|
_last_positions = now
|
|
return speeds
|
|
|
|
## Horizontal direction the sun light comes from (the way the light travels).
|
|
func _sun_direction() -> Vector3:
|
|
for node in root.find_children("*", "DirectionalLight3D", true, false):
|
|
var sun := node as DirectionalLight3D
|
|
if sun == null or not sun.visible:
|
|
continue
|
|
var dir := -sun.global_transform.basis.z
|
|
dir.y = 0.0
|
|
if dir.length() > 0.01:
|
|
return dir.normalized()
|
|
return Vector3(0, 0, 1)
|
|
|
|
## True when the avatar wears something over its face (see NpcAvatar.ACCESSORIES).
|
|
func _profile_of(npc: Node3D) -> Variant:
|
|
var brain = npc.get("npc")
|
|
return brain.get("profile") if brain != null else null
|
|
|
|
func _faces_covered(npc: Node3D) -> bool:
|
|
var brain = npc.get("npc")
|
|
if brain == null:
|
|
return false
|
|
var profile = brain.get("profile")
|
|
if profile == null:
|
|
return false
|
|
var npc_type := int(profile.get("npc_type"))
|
|
# An explicit --portrait-type is a deliberate request to inspect headgear,
|
|
# including a hood; the default crowd selection still avoids covered faces.
|
|
if _portrait_type >= 0 and npc_type == _portrait_type:
|
|
return false
|
|
return npc_type in HOODED_TYPES
|
|
|
|
## 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()])
|