231 lines
11 KiB
GDScript
231 lines
11 KiB
GDScript
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))
|