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//baseline ## godot --path . --script res://src/test/capture_realism.gd -- \ ## --out=res://docs/screens/realism//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()])