class_name SurfaceMaterials extends RefCounted ## Shared surface recipes for the procedural world (ADR-0012). ## ## Godot's `StandardMaterial3D` defaults are flat: every surface comes out with ## the same roughness and a plain albedo, which is what made the street read as ## grey boxes. These helpers give each surface a physically plausible profile ## (brick is rough, glass is smooth, metal is metallic) and keep the textures, ## UV scaling, and texture filtering in one place so the world builder, the NPC ## avatars, and the props all look like one scene. ## 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. const PROFILES := { "brick": {"roughness": 0.88, "metallic": 0.0}, "concrete": {"roughness": 0.94, "metallic": 0.0}, "asphalt": {"roughness": 0.72, "metallic": 0.0}, "awning": {"roughness": 0.80, "metallic": 0.0}, "stone": {"roughness": 0.88, "metallic": 0.0}, "metal": {"roughness": 0.32, "metallic": 0.75}, "painted_metal": {"roughness": 0.48, "metallic": 0.35}, "wood": {"roughness": 0.70, "metallic": 0.0}, "cloth": {"roughness": 0.85, "metallic": 0.0}, "skin": {"roughness": 0.62, "metallic": 0.0}, "glass": {"roughness": 0.12, "metallic": 0.10}, "rubber": {"roughness": 0.95, "metallic": 0.0}, } ## NPC material slot: keep the baked albedo/normal maps from the imported model ## and tint them with the seed-driven outfit colour. static func npc_slot(source: Material, tint: Color) -> StandardMaterial3D: var material := source.duplicate() as StandardMaterial3D if material == null: material = StandardMaterial3D.new() material.albedo_color = tint return material ## Wire a baked AO map (red channel) into every surface of an imported mesh. ## Compatibility has no SSAO, so the library assets carry their own AO. static func apply_ao(mesh_instance: MeshInstance3D, texture: Texture2D) -> void: if mesh_instance == null or mesh_instance.mesh == null or texture == null: return for index in mesh_instance.mesh.get_surface_count(): var base := mesh_instance.get_surface_override_material(index) if base == null: base = mesh_instance.mesh.surface_get_material(index) var material := base.duplicate() as StandardMaterial3D if material == null: continue material.ao_enabled = true material.ao_texture = texture material.ao_texture_channel = BaseMaterial3D.TEXTURE_CHANNEL_RED mesh_instance.set_surface_override_material(index, material) static func load_ao(path: String) -> Texture2D: if not ResourceLoader.exists(path): return null return ResourceLoader.load(path) as Texture2D static func profile(role: String) -> Dictionary: return PROFILES.get(role, PROFILES["concrete"]) ## Base textured surface with a named profile. static func textured(texture: Texture2D, tint: Color, uv_scale: Vector2, role: String = "concrete", normal: Texture2D = null) -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.albedo_texture = texture if normal != null: mat.normal_enabled = true mat.normal_texture = normal mat.normal_scale = 0.8 mat.albedo_color = tint mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0) mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC mat.roughness = float(profile(role).get("roughness", 0.9)) mat.metallic = float(profile(role).get("metallic", 0.0)) return mat static func brick(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(10, 5)) -> StandardMaterial3D: return textured(T_BRICK, tint, uv_scale, "brick", T_BRICK_NORMAL) 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), 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: return textured(T_AWNING, tint, uv_scale, "awning") ## Flat-coloured surface with a profile (props, trim, limbs, clothing). static func flat(color: Color, role: String = "concrete") -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.albedo_color = color mat.roughness = float(profile(role).get("roughness", 0.9)) mat.metallic = float(profile(role).get("metallic", 0.0)) return mat ## Self-lit surface (marquee bars, lamp bulbs, lock indicator). static func emissive(color: Color, energy: float = 1.4) -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.albedo_color = color mat.emission_enabled = true mat.emission = color mat.emission_energy_multiplier = energy 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: var mat := StandardMaterial3D.new() mat.albedo_color = color mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.roughness = float(profile("glass").get("roughness", 0.12)) mat.metallic = 0.15 mat.metallic_specular = 0.8 return mat ## Window pane that can be lit from the inside at night. Solid dark glass reads ## as a window at midday; alpha glass just looked like white paper on the brick. static func window_pane() -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.albedo_color = Color(0.09, 0.12, 0.17) mat.roughness = 0.18 mat.metallic = 0.35 mat.metallic_specular = 0.6 mat.emission_enabled = true mat.emission = Color(1.0, 0.86, 0.62) mat.emission_energy_multiplier = 0.0 return mat ## Environment look shared by every phase: ACES tonemapping, soft glow, aerial ## fog for depth, and a mild contrast/saturation lift. Phase values come from ## `GameManager.PHASE_LIGHTING`. static func configure_environment(env: Environment, cfg: Dictionary) -> void: if env == null: return env.background_mode = Environment.BG_SKY env.tonemap_mode = Environment.TONE_MAPPER_ACES env.tonemap_exposure = float(cfg.get("exposure", 1.0)) env.tonemap_white = 6.0 env.ambient_light_energy = float(cfg.get("ambient_energy", 0.5)) env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY # Glow makes the marquee, lamp bulbs, and windows read as light sources. env.glow_enabled = true env.glow_normalized = true env.glow_intensity = float(cfg.get("glow", 0.55)) env.glow_strength = 1.0 env.glow_bloom = 0.08 env.glow_hdr_threshold = 1.0 env.glow_blend_mode = Environment.GLOW_BLEND_MODE_SOFTLIGHT # Aerial perspective: the skyline ring sits ~200 m out, so light fog gives the # distance its haze without hiding the street. env.fog_enabled = true env.fog_light_color = cfg.get("fog_color", Color(0.72, 0.78, 0.88)) env.fog_light_energy = float(cfg.get("fog_energy", 0.7)) env.fog_density = float(cfg.get("fog_density", 0.0035)) env.fog_aerial_perspective = 0.25 env.fog_sky_affect = 0.4 env.adjustment_enabled = true env.adjustment_brightness = 1.0 env.adjustment_contrast = float(cfg.get("contrast", 1.06)) env.adjustment_saturation = float(cfg.get("saturation", 1.08))