Adopt the realism pass and refresh screenshots/docs
Full build / Import + headless tests (push) Successful in 34s
Full build / Full desktop build + optional release (push) Skipped

- 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:
zwitschi committed 2026-11-01 17:03:05 +01:00
1 parent 26ecb93fd4
commit d7e523f63c
70 files changed
+4042 -101

No files matched your search

+35 -11
View File
@@ -46,19 +46,19 @@ var _last_phase: int = -1
## structure — phase-based lighting). Night phases switch street lamps on.
const PHASE_LIGHTING := {
Rules.Phase.EARLY: {
"sun_energy": 0.62, "sun_angle": -50.0, "sun_color": Color(1.0, 0.96, 0.88),
"sun_energy": 0.62, "sun_angle": -34.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.96, 0.88),
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
"exposure": 0.6, "glow": 0.35, "fog_color": Color(0.80, 0.85, 0.92),
"fog_density": 0.0026, "fog_energy": 0.75, "contrast": 1.05, "saturation": 1.06,
"exposure": 0.6, "glow": 0.45, "fog_color": Color(0.87, 0.83, 0.76),
"fog_density": 0.0034, "fog_energy": 0.5, "contrast": 1.05, "saturation": 1.04,
"ambient_energy": 0.1,
},
Rules.Phase.MIDDAY: {
"sun_energy": 1.0, "sun_angle": -62.0, "sun_color": Color(1.0, 0.99, 0.96),
"sun_energy": 1.0, "sun_angle": -62.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.99, 0.96),
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
"exposure": 0.6, "glow": 0.3, "fog_color": Color(0.84, 0.88, 0.94),
"fog_density": 0.0021, "fog_energy": 0.8, "contrast": 1.06, "saturation": 1.05,
"exposure": 0.6, "glow": 0.4, "fog_color": Color(0.88, 0.87, 0.83),
"fog_density": 0.0028, "fog_energy": 0.5, "contrast": 1.06, "saturation": 1.04,
"ambient_energy": 0.28,
},
# Evening is dusk, not a bright sunset: the sun is almost down and the street
@@ -75,7 +75,7 @@ const PHASE_LIGHTING := {
"sun_energy": 0.02, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
"sky_top": Color(0.015, 0.02, 0.05), "sky_horizon": Color(0.06, 0.07, 0.13),
"sky_ground": Color(0.03, 0.03, 0.05), "ambient": Color(0.06, 0.07, 0.12), "night": true,
"exposure": 0.95, "glow": 1.0, "fog_color": Color(0.08, 0.1, 0.2),
"exposure": 0.95, "glow": 1.15, "fog_color": Color(0.08, 0.1, 0.2),
"fog_density": 0.0028, "fog_energy": 0.45, "contrast": 1.12, "saturation": 1.0,
"ambient_energy": 0.22,
},
@@ -190,10 +190,31 @@ func _build_guard() -> GuardController:
# Aim straight ahead: tilting it down made the cone hit the ground right in
# front of the guard instead of the person or door being checked.
flashlight.rotation_degrees = Vector3.ZERO
flashlight.spot_range = 22.0
flashlight.spot_angle = 30.0
flashlight.spot_range = 16.0
flashlight.spot_angle = 20.0
flashlight.light_energy = 4.0
flashlight.spot_attenuation = 0.6
flashlight.spot_attenuation = 0.8
flashlight.spot_angle_attenuation = 0.9
# Visible beam: a fading additive cone parented to the lamp, because the
# Compatibility renderer has no volumetric light. It is offset forward so the
# near cap never fills the first-person view.
var beam := MeshInstance3D.new()
beam.name = "Beam"
var cone := CylinderMesh.new()
cone.top_radius = 1.0
cone.bottom_radius = 0.05
cone.height = 5.5
cone.radial_segments = 16
cone.rings = 1
cone.cap_top = false
cone.cap_bottom = false
beam.mesh = cone
# Cylinder axis is +Y; -90° about X aims the wide end along -Z (forward).
beam.rotation_degrees = Vector3(-90, 0, 0)
beam.position = Vector3(0, 0, -3.65)
beam.material_override = Materials.light_beam()
beam.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
flashlight.add_child(beam)
head.add_child(flashlight)
var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
@@ -484,7 +505,10 @@ func _apply_phase_lighting() -> void:
if _sun:
_sun.light_energy = float(cfg.get("sun_energy", 1.0))
_sun.light_color = cfg.get("sun_color", Color.WHITE)
_sun.rotation_degrees = Vector3(float(cfg.get("sun_angle", -50.0)), -30, 0)
# `sun_yaw` sets which side of the street the light comes from; the
# entrance view reads as shade when the sun is off the facade.
_sun.rotation_degrees = Vector3(float(cfg.get("sun_angle", -50.0)),
float(cfg.get("sun_yaw", -30.0)), 0)
if _sky:
_sky.sky_top_color = cfg.get("sky_top", Color.BLUE)
_sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE)
+43 -11
View File
@@ -9,13 +9,19 @@ extends RefCounted
## UV scaling, and texture filtering in one place so the world builder, the NPC
## avatars, and the props all look like one scene.
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
const T_BRICK_NORMAL := preload("res://assets/textures/ai/game_ready/brick_facade_normal.png")
const T_CONCRETE_NORMAL := preload("res://assets/textures/ai/game_ready/concrete_normal.png")
const T_ASPHALT_NORMAL := preload("res://assets/textures/ai/game_ready/asphalt_normal.png")
## Realism-pass picks (tools/scene_concepts.py promote): brick, sidewalk pavers and
## asphalt replaced the flat first-generation tiles. Normal maps carry the mortar,
## paver joints and aggregate that Compatibility cannot get from screen space.
const PICK_DIR := "res://assets/textures/ai/game_ready/v2/picks/"
const T_BRICK := preload(PICK_DIR + "brick_facade.png")
const T_CONCRETE := preload(PICK_DIR + "sidewalk_pavers.png")
const T_ASPHALT := preload(PICK_DIR + "asphalt.png")
const T_ASPHALT_WET := preload(PICK_DIR + "asphalt_wet.png")
const T_AWNING := preload(PICK_DIR + "awning_canvas.png")
const T_BRICK_NORMAL := preload(PICK_DIR + "brick_facade_normal.png")
const T_CONCRETE_NORMAL := preload(PICK_DIR + "sidewalk_pavers_normal.png")
const T_ASPHALT_NORMAL := preload(PICK_DIR + "asphalt_normal.png")
const T_ASPHALT_WET_NORMAL := preload(PICK_DIR + "asphalt_wet_normal.png")
## Roughness / metallic pairs per surface role. Exposed so tests and tooling can
## assert the profiles instead of hard-coding numbers twice.
@@ -90,10 +96,15 @@ static func brick(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(10,
static func concrete(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(9, 2.4)) -> StandardMaterial3D:
return textured(T_CONCRETE, tint, uv_scale, "concrete", T_CONCRETE_NORMAL)
static func asphalt(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(30, 7)) -> StandardMaterial3D:
var mat := textured(T_ASPHALT, tint, uv_scale, "asphalt", T_ASPHALT_NORMAL)
# Damp asphalt at night picks up the street lamps and the marquee.
mat.metallic_specular = 0.6
static func asphalt(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(30, 7),
wet: bool = false) -> StandardMaterial3D:
var texture: Texture2D = T_ASPHALT_WET if wet else T_ASPHALT
var normal: Texture2D = T_ASPHALT_WET_NORMAL if wet else T_ASPHALT_NORMAL
var mat := textured(texture, tint, uv_scale, "asphalt", normal)
# Damp asphalt at night picks up the street lamps and the marquee: glossier
# and slightly metallic so the lamp pools read as reflections.
mat.metallic_specular = 0.75 if wet else 0.6
mat.roughness = 0.38 if wet else float(profile("asphalt").get("roughness", 0.72))
return mat
static func awning(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(4, 1)) -> StandardMaterial3D:
@@ -117,6 +128,27 @@ static func emissive(color: Color, energy: float = 1.4) -> StandardMaterial3D:
mat.roughness = 0.4
return mat
## Fake light shaft for the flashlight (Compatibility has no volumetrics).
## Additive, unshaded, no depth write: it glows over the scene, is still occluded
## by walls, and fades out along its length instead of ending in a hard disc.
static func light_beam() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
mat.albedo_color = Color(1.0, 0.95, 0.82, 1.0)
var fade := Gradient.new()
fade.set_color(0, Color(1.0, 0.95, 0.82, 0.0))
fade.add_point(0.18, Color(1.0, 0.95, 0.82, 0.22))
fade.set_color(fade.get_point_count() - 1, Color(1.0, 0.95, 0.82, 0.0))
var ramp := GradientTexture1D.new()
ramp.gradient = fade
mat.albedo_texture = ramp
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR
return mat
## Dark glass for windows and the entrance door. The alpha keeps the interior
## visible, and the low roughness gives the facade a highlight instead of a hole.
static func glass(color: Color = Color(0.42, 0.56, 0.72, 0.75)) -> StandardMaterial3D:
+101 -24
View File
@@ -2,11 +2,25 @@ class_name WorldBuilder
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
const Materials := preload("res://src/game/materials.gd")
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
const T_SKYLINE := preload("res://assets/textures/skyline.png")
const Rules := preload("res://src/core/rules.gd")
const PICK_DIR := "res://assets/textures/ai/game_ready/v2/picks/"
const T_BRICK := preload(PICK_DIR + "brick_facade.png")
const T_CONCRETE := preload(PICK_DIR + "sidewalk_pavers.png")
const T_ASPHALT := preload(PICK_DIR + "asphalt.png")
const T_AWNING := preload(PICK_DIR + "awning_canvas.png")
## One skyline panorama per shift phase (realism pass): hazy daylight, warm dusk
## with lit windows, and a night city. The old single strip was a black silhouette.
const T_SKYLINE_DAY := preload(PICK_DIR + "skyline_day.png")
const T_SKYLINE_DUSK := preload(PICK_DIR + "skyline_dusk.png")
const T_SKYLINE_NIGHT := preload(PICK_DIR + "skyline_night.png")
## Height of each backdrop plane (metres); the bases sit on the ground plane.
const SKYLINE_HEIGHT := 90.0
const SKYLINE_BY_PHASE := {
Rules.Phase.EARLY: {"texture": T_SKYLINE_DAY, "tint": Color(0.98, 1.0, 1.04)},
Rules.Phase.MIDDAY: {"texture": T_SKYLINE_DAY, "tint": Color(1.02, 1.04, 1.08)},
Rules.Phase.EVENING: {"texture": T_SKYLINE_DUSK, "tint": Color(0.9, 0.92, 1.0)},
Rules.Phase.LATE: {"texture": T_SKYLINE_NIGHT, "tint": Color(1.5, 1.55, 1.65)},
}
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
## Surfaces use the shared PBR profiles in `src/game/materials.gd`; street
## furniture comes from the generated GLB props (tools/model_pipeline.py) with a
@@ -98,9 +112,18 @@ func _build_building() -> void:
var building := StaticBody3D.new()
building.name = "Building"
# Main facade (textured brick)
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.80, 0.76, 0.74), false, T_BRICK, Vector2(22, 10), "brick")
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.86, 0.85, 0.83), false, T_BRICK, Vector2(22, 10), "brick")
# Stone base course so the facade has a footing instead of meeting the steps dead-on.
_add_box(building, Vector3(40, 0.8, 0.35), Vector3(0, 0.4, -3.85), Color(0.58, 0.56, 0.53), false, null, Vector2.ONE, "stone")
_add_box(building, Vector3(40, 0.8, 0.5), Vector3(0, 0.4, -3.78), Color(0.52, 0.5, 0.47), false, null, Vector2.ONE, "stone")
# Facade depth, not a flat wall: a projecting plinth, a string course between
# the window bands, a cornice under the roofline and pilasters at the bays.
_add_box(building, Vector3(40, 0.34, 0.22), Vector3(0, 0.93, -3.9), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(40, 0.28, 0.28), Vector3(0, 7.6, -3.86), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(40, 0.5, 0.5), Vector3(0, 17.7, -3.75), Color(0.56, 0.54, 0.51), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(40, 0.22, 0.2), Vector3(0, 10.9, -3.9), Color(0.58, 0.56, 0.53), true, null, Vector2.ONE, "stone")
for bay in [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3]:
_add_box(building, Vector3(0.5, 8.4, 0.24), Vector3(bay, 12.6, -3.9),
Color(0.8, 0.78, 0.76), true, T_BRICK, Vector2(0.3, 4.7), "brick")
# Entrance recess + door recess frame
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "stone")
# Door frame columns and lintel, so the entrance is framed rather than painted on.
@@ -151,9 +174,14 @@ func _build_facade_windows(parent: Node3D) -> void:
var pane_mat := Materials.window_pane()
pane.material_override = pane_mat
_window_mats.append(pane_mat)
# Sill below the frame.
# Sill below the frame and glazing bars, so the pane reads as a window
# rather than a dark hole.
_add_box(parent, Vector3(2.1, 0.12, 0.3), Vector3(x, y - 1.3, -3.86),
Color(0.55, 0.53, 0.50), false, null, Vector2.ONE, "stone")
_add_box(parent, Vector3(0.06, 2.2, 0.05), Vector3(x, y, -3.9),
Color(0.24, 0.23, 0.22), false, null, Vector2.ONE, "stone")
_add_box(parent, Vector3(1.7, 0.06, 0.05), Vector3(x, y + 0.25, -3.9),
Color(0.24, 0.23, 0.22), false, null, Vector2.ONE, "stone")
func _build_steps() -> void:
# Steps have collision so NPCs and the guard can walk up the entrance.
@@ -187,7 +215,7 @@ func _build_steps() -> void:
func _build_sidewalk() -> void:
var sidewalk := StaticBody3D.new()
sidewalk.name = "Sidewalk"
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.74, 0.74, 0.72), false, T_CONCRETE, Vector2(9, 2.4), "concrete")
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.55, 0.55, 0.54), false, T_CONCRETE, Vector2(9, 2.4), "concrete")
var col := BoxShape3D.new()
col.size = Vector3(30, 0.36, 8)
var so := sidewalk.create_shape_owner(sidewalk)
@@ -200,7 +228,12 @@ func _build_street() -> void:
street.name = "Street"
# The roadway reads darker than the sidewalk; the raw asphalt texture tinted
# white looked like more pavement.
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.52, 0.53, 0.57), false, T_ASPHALT, Vector2(30, 7), "asphalt")
# The street keeps its own material so night mode can switch to the wet
# asphalt map (glossier, with lamp reflections) and back.
_street_mat = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(30, 7))
var street_mesh := _add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22),
Color(0.62, 0.63, 0.66), false, T_ASPHALT, Vector2(30, 7), "asphalt")
street_mesh.material_override = _street_mat
# Center dashes along the street (x varied so they form a lane line, not a stack).
for i in range(-40, 41, 4):
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, 22), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete")
@@ -239,28 +272,32 @@ func _build_skyline_backdrop() -> void:
## band from the sidewalk.
var backdrop := Node3D.new()
backdrop.name = "SkylineBackdrop"
var mat := StandardMaterial3D.new()
mat.albedo_texture = T_SKYLINE
mat.texture_repeat = true
mat.uv1_scale = Vector3(3.0, 1.0, 1.0)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
mat.alpha_scissor_threshold = 0.5
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
# Unshaded silhouettes read as pure black at 255-scale; lift the albedo so the
# distant skyline sits closer to a hazy daytime city.
mat.albedo_color = Color(2.6, 2.7, 3.0, 1.0)
# A shader, not a StandardMaterial3D: the panorama already has its own
# atmospheric haze baked in, and the scene fog at 200 m washed the towers out
# to a white band (Godot 4.3 has no `fog_enabled` on StandardMaterial3D, so the
# `fog_disabled` render mode does the job). Two UV repeats over the 400 m ring:
# each repeat is ~200 x 90 m, close to the 2:1 panorama aspect, so the towers
# keep their proportions instead of stretching into a wide band.
var mat := ShaderMaterial.new()
var shader := Shader.new()
shader.code = SKYLINE_SHADER
mat.shader = shader
mat.set_shader_parameter("sky_texture", SKYLINE_BY_PHASE[_phase]["texture"])
mat.set_shader_parameter("tint", SKYLINE_BY_PHASE[_phase]["tint"])
mat.set_shader_parameter("repeats", 2.0)
_skyline_mat = mat
var mesh := PlaneMesh.new()
# PlaneMesh defaults to a horizontal (XZ) plane; the skyline must stand up.
mesh.orientation = PlaneMesh.FACE_Z
mesh.size = Vector2(400, 60)
# 90 m tall: at the 200 m ring distance the towers subtend a skyline band
# instead of a thin strip on the horizon.
mesh.size = Vector2(400, SKYLINE_HEIGHT)
# Far enough that the skyline reads as background and its top edge stays out
# of the middle of the sky.
const DIST := 200.0
# The texture keeps its buildings in the lower part of the image, so the plane
# center sits at half its height: the building bases meet the ground.
const CENTER_Y := 30.0
const CENTER_Y := SKYLINE_HEIGHT / 2.0
var placements := [
[Vector3(0, CENTER_Y, -DIST), 0.0],
[Vector3(0, CENTER_Y, DIST), 0.0],
@@ -277,7 +314,27 @@ func _build_skyline_backdrop() -> void:
backdrop.add_child(plane)
root.add_child(backdrop)
var _skyline_mat: StandardMaterial3D = null
## Backdrop shader: unshaded, alpha-scissored, and fog-free so the panorama's own
## haze shows instead of the scene fog bleaching it.
const SKYLINE_SHADER := """
shader_type spatial;
render_mode unshaded, cull_disabled, fog_disabled;
uniform sampler2D sky_texture : source_color, repeat_enable, filter_linear_mipmap;
uniform vec4 tint : source_color = vec4(1.0);
uniform float repeats = 2.0;
void fragment() {
vec4 panorama = texture(sky_texture, vec2(UV.x * repeats, UV.y));
ALBEDO = panorama.rgb * tint.rgb;
ALPHA = panorama.a * tint.a;
ALPHA_SCISSOR_THRESHOLD = 0.5;
}
"""
var _skyline_mat: ShaderMaterial = null
var _street_mat: StandardMaterial3D = null
var _phase: int = Rules.Phase.EARLY
func _build_marquee() -> void:
## Blender canopy + sign box above the door; the procedural striped awning and
@@ -712,6 +769,26 @@ func _build_crosswalk() -> void:
_add_box(root, Vector3(0.8, 0.02, 0.5), Vector3(-2.8 + i * 0.85, 0.11, 17.0), Color(0.92, 0.92, 0.92))
_add_box(root, Vector3(0.8, 0.02, 0.5), Vector3(-2.8 + i * 0.85, 0.11, 19.0), Color(0.92, 0.92, 0.92))
## Apply a shift phase: skyline panorama, dry/wet street, and night lighting.
## Called by GameManager on every phase change.
func set_phase(phase: int) -> void:
_phase = phase
var entry: Dictionary = SKYLINE_BY_PHASE.get(phase, SKYLINE_BY_PHASE[Rules.Phase.EARLY])
if _skyline_mat != null:
_skyline_mat.set_shader_parameter("sky_texture", entry["texture"])
_skyline_mat.set_shader_parameter("tint", entry["tint"])
var night: bool = phase == Rules.Phase.EVENING or phase == Rules.Phase.LATE
set_night_mode(night)
if _street_mat != null:
var wet: bool = phase == Rules.Phase.LATE
var dry: StandardMaterial3D = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(30, 7))
var damp: StandardMaterial3D = Materials.asphalt(Color(0.52, 0.55, 0.6), Vector2(30, 7), true)
var target: StandardMaterial3D = damp if wet else dry
_street_mat.albedo_texture = target.albedo_texture
_street_mat.normal_texture = target.normal_texture
_street_mat.roughness = target.roughness
_street_mat.metallic_specular = target.metallic_specular
## Switch street lamps and signage to night mode (emissive + lights on).
func set_night_mode(night: bool) -> void:
for mat in _lamp_bulb_mats:
+231
View File
@@ -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()])
+20
View File
@@ -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()
+16 -7
View File
@@ -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()