Files
security-game/src/game/materials.gd
T
zwitschi d7220c1a0a
Full build / Import + headless tests (push) Successful in 37s
Full build / Full desktop build + optional release (push) Skipped
Refine NPC fabric UVs and remove baked AO blotches
2026-11-02 20:28:25 +01:00

231 lines
11 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/"
## The 256² fabric maps are authored for a fine weave; repeating them 4×
## across the body keeps the pattern readable instead of magnifying one cell
## into a large blotch.
const NPC_FABRIC_UV_SCALE := Vector3(4.0, 4.0, 1.0)
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_LIMESTONE := preload(PICK_DIR + "stone_steps.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")
const T_LIMESTONE_NORMAL := preload(PICK_DIR + "stone_steps_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
if material.resource_name in ["cloth", "pants"]:
material.uv1_scale = NPC_FABRIC_UV_SCALE
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 limestone(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(6, 3)) -> StandardMaterial3D:
return textured(T_LIMESTONE, tint, uv_scale, "stone", T_LIMESTONE_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
## 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
## Godot 4.7 changed the Compatibility light falloff in practice: the same
## attenuation/range that read well on 4.3 leaves the night scene nearly black.
## Use this switch for local lights until the project moves its baseline to 4.7.
static func modern_light_falloff() -> bool:
var info := Engine.get_version_info()
return int(info.get("major", 4)) > 4 \
or (int(info.get("major", 4)) == 4 and int(info.get("minor", 0)) >= 7)
## True only when a real RenderingDevice exists. The Compatibility renderer and
## the headless dummy renderer return null, so the desktop-only effects can be
## gated without Web/CI builds ever enabling them.
static func forward_plus_active() -> bool:
return RenderingServer.get_rendering_device() != null
## 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`. Volumetric fog and SDFGI are only enabled when
## Forward+ is actually available (the optional desktop preset).
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
var advanced := forward_plus_active()
env.volumetric_fog_enabled = advanced
env.sdfgi_enabled = advanced
if advanced:
# Desktop HQ: a shallow, cool fog gives the torch a visible shaft while
# keeping the entrance facade readable. Phase values are tuned per phase.
env.volumetric_fog_density = float(cfg.get("volumetric_density", 0.012))
env.volumetric_fog_albedo = cfg.get("volumetric_albedo", Color(0.55, 0.62, 0.72))
env.volumetric_fog_emission = cfg.get("volumetric_emission", Color.BLACK)
env.volumetric_fog_emission_energy = float(cfg.get("volumetric_energy", 0.0))
env.volumetric_fog_anisotropy = 0.25
env.volumetric_fog_length = 48.0
env.volumetric_fog_ambient_inject = 0.15
env.volumetric_fog_sky_affect = 0.2
env.sdfgi_use_occlusion = true
env.sdfgi_cascades = 4
env.sdfgi_min_cell_size = 0.2
env.sdfgi_energy = 0.7
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))