Files
Papay-Shooter/scenes/maps/level_environment.gd
2026-08-02 02:20:02 -04:00

297 lines
14 KiB
GDScript

extends Object
class_name LevelEnvironment
const CLOUD_SUN_SYNC_SCRIPT := preload("res://scenes/maps/cloud_sun_sync.gd")
## Shared stylized environment for every level: saturated anime-sky gradient,
## bloom for emissives (tracers, plasma), and a color grade that pushes the
## cel-shaded look (high saturation, slight contrast lift). Builders call
## add_to(level) instead of hand-rolling WorldEnvironment + sun.
## Creates and adds a WorldEnvironment + key sun + cool fill light.
## Returns the WorldEnvironment node.
static func add_to(level: Node, sky_variant: String = "day") -> WorldEnvironment:
var env := WorldEnvironment.new()
env.name = "WorldEnvironment"
env.environment = make_environment(sky_variant)
level.add_child(env)
var sun := level.get_node_or_null("Sun") as DirectionalLight3D
if sun == null:
sun = _build_light_rig(level, sky_variant)
# Screen-space ink edge pass: drawn outlines on every model for whatever
# camera renders (fullscreen POSITION-override quad, never culled).
var ink_material: ShaderMaterial
if not level.has_node("InkEdgePost"):
var ink := MeshInstance3D.new()
ink.name = "InkEdgePost"
var quad := QuadMesh.new()
quad.size = Vector2(2, 2)
ink_material = ShaderMaterial.new()
ink_material.shader = load("res://assets/shaders/ink_edge.gdshader")
_apply_sky_palette(ink_material, sky_variant, true)
quad.material = ink_material
ink.mesh = quad
ink.extra_cull_margin = 16384.0
level.add_child(ink)
else:
var ink := level.get_node("InkEdgePost") as MeshInstance3D
if ink and ink.mesh:
ink_material = ink.mesh.material as ShaderMaterial
if sun and ink_material and not level.has_node("CloudSunSync"):
var sync := CLOUD_SUN_SYNC_SCRIPT.new()
sync.name = "CloudSunSync"
level.add_child(sync)
sync.configure(sun, ink_material)
# Deliberately no global broadband ambience. Constant air/noise masks the
# physical cues that matter in a competitive shooter and was perceived as
# television static. Authored map emitters may add localized machinery;
# traversal wind remains silent until the player is genuinely moving fast.
return env
## The anime light rig.
##
## Not one key and a token fill. The reference names four lights and every one
## of them is doing a specific job that the cel ramp cannot do without it:
##
## key Warm, and the only one casting shadows. It is what the ramp
## quantises, so it alone decides where every band edge in the frame
## falls.
## bounce A STRONG cool light from the opposite quarter — roughly a third of
## the key, not the tenth a PBR fill would be. This is the single most
## important light in an anime rig: it is what puts a distinct, cool,
## non-black value on every shadow plane, so a surface turning away
## from the sun turns INTO another colour rather than into darkness.
## up A weak violet up-light standing in for ground bounce, which keeps
## undersides — eaves, canopies, the underside of a train — from
## collapsing into one flat mass with the shadow planes.
## ground The hemisphere's ground colour, carried here by ambient (see
## make_environment): violet rather than grey, for the same reason the
## cel ramp's shadow tint is.
##
## The energies look high because the cel shaders divide by PI in light(), the
## same as any Lambert term: a key at 2.6 lands its top band at 2.6/PI ≈ 0.83 of
## the surface albedo, which is what "fully lit" should mean.
static func _build_light_rig(level: Node, sky_variant: String) -> DirectionalLight3D:
var warm := Color(1.0, 0.945, 0.847) # PAL.sun 0xfff1d8
var cool := Color(0.663, 0.741, 0.961) # PAL.fill 0xa9bdf5
var key_energy := 2.6
var bounce_energy := 0.95
var key_angle := Vector3(-48, 34, 0)
if sky_variant == "sunset":
warm = Color(1.0, 0.78, 0.58)
cool = Color(0.52, 0.56, 0.92)
key_energy = 2.2
bounce_energy = 1.1
key_angle = Vector3(-16, 28, 0)
var sun := DirectionalLight3D.new()
sun.name = "Sun"
sun.rotation_degrees = key_angle
sun.light_color = warm
sun.light_energy = key_energy
sun.shadow_enabled = true
sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
sun.directional_shadow_max_distance = 160.0
level.add_child(sun)
# The opposite quarter: the key's yaw turned 180 degrees, and a shallower
# pitch, so the bounce rakes across the shadow planes instead of lighting
# them like a second sun.
var bounce := DirectionalLight3D.new()
bounce.name = "FillLight"
bounce.rotation_degrees = Vector3(-24, key_angle.y + 180.0, 0)
bounce.light_color = cool
bounce.light_energy = bounce_energy
bounce.shadow_enabled = false
level.add_child(bounce)
var up := DirectionalLight3D.new()
up.name = "UpLight"
up.rotation_degrees = Vector3(74, key_angle.y - 60.0, 0)
up.light_color = Color(0.714, 0.651, 0.776) # PAL.hemiGround 0xb6a6c6
up.light_energy = 0.30
up.shadow_enabled = false
level.add_child(up)
return sun
## Cel sky with rounded, flat-toned anime clouds.
##
## This replaces ProceduralSkyMaterial, whose smooth two-colour ramp was the
## largest single flat area in every frame — tools/levels.py measured a quarter
## to a third of each shot sitting at one shade, and almost all of it was sky.
## Clouds give the top half of the picture something to be. They use a layered
## 2D field and painted directional tones rather than volumetric ray marching,
## keeping the richer silhouette practical for a competitive shooter.
static func make_sky(sky_variant: String = "day") -> Sky:
var sky := Sky.new()
var mat := ShaderMaterial.new()
mat.shader = load("res://assets/shaders/anime_sky.gdshader")
_apply_sky_palette(mat, sky_variant)
sky.sky_material = mat
# The sky animates, so its radiance would otherwise be re-rendered in full
# every frame for the maps that take ambient from it. INCREMENTAL spreads
# that across frames, which is invisible at a drift this slow.
sky.process_mode = Sky.PROCESS_MODE_INCREMENTAL
return sky
## Keep the Environment sky (ambient/reflections) and the ink pass's final
## background composite on one authored palette.
static func _apply_sky_palette(
mat: ShaderMaterial, sky_variant: String, ink_pass: bool = false) -> void:
var prefix := "sky_" if ink_pass else ""
match sky_variant:
"sakura":
# A hand-painted anime background sky, which means PALE. The bold
# saturated blue the other variants use is a video-game sky; a cel
# background painter keeps the top of the picture light and low in
# contrast so the ink line work and the blossom read against it. The
# horizon carries the reference's pink haze (PAL.skyHaze), which is
# what makes the air itself look like it has petals in it.
mat.set_shader_parameter(prefix + "top_color", Color(0.561, 0.741, 0.918))
mat.set_shader_parameter(prefix + "horizon_color", Color(0.949, 0.910, 0.933))
mat.set_shader_parameter(prefix + "ground_color", Color(0.714, 0.651, 0.776))
mat.set_shader_parameter(prefix + "cloud_color", Color(0.992, 0.980, 0.973))
mat.set_shader_parameter(prefix + "cloud_shadow", Color(0.902, 0.902, 0.949))
mat.set_shader_parameter(prefix + "cloud_cover", 0.520)
mat.set_shader_parameter(prefix + "cloud_scale", 2.2)
# Harder cloud edges than the other skies. A cel cloud is a shape
# with a drawn outline, not a soft mass.
mat.set_shader_parameter(prefix + "cloud_softness", 0.014)
mat.set_shader_parameter(prefix + "horizon_falloff", 0.62)
if ink_pass:
mat.set_shader_parameter("sky_sun_color", Color(1.0, 0.945, 0.847))
mat.set_shader_parameter("sky_sun_halo", 0.25)
mat.set_shader_parameter("sky_sun_direction", Vector3(0.46, 0.52, -0.72))
"sunset":
mat.set_shader_parameter(prefix + "top_color", Color(0.25, 0.20, 0.50))
mat.set_shader_parameter(prefix + "horizon_color", Color(0.95, 0.60, 0.45))
mat.set_shader_parameter(prefix + "ground_color", Color(0.18, 0.11, 0.25))
# Sunset clouds are lit from underneath and from the side, so their
# lit face takes the sun's colour and their shadow goes violet
# rather than grey.
mat.set_shader_parameter(prefix + "cloud_color", Color(1.0, 0.80, 0.62))
mat.set_shader_parameter(prefix + "cloud_shadow", Color(0.52, 0.38, 0.55))
mat.set_shader_parameter(prefix + "cloud_cover", 0.515)
mat.set_shader_parameter(prefix + "cloud_scale", 2.8)
mat.set_shader_parameter(prefix + "cloud_softness", 0.020)
mat.set_shader_parameter(prefix + "sun_halo", 0.55)
if ink_pass:
mat.set_shader_parameter("sky_sun_color", Color(1.0, 0.62, 0.42))
mat.set_shader_parameter("sky_sun_direction", Vector3(0.55, 0.20, -0.81))
_:
# Bold anime day sky: deep saturated blue up top, bright cyan horizon.
mat.set_shader_parameter(prefix + "top_color", Color(0.18, 0.40, 0.85))
mat.set_shader_parameter(prefix + "horizon_color", Color(0.72, 0.88, 0.98))
mat.set_shader_parameter(prefix + "ground_color", Color(0.22, 0.20, 0.28))
mat.set_shader_parameter(prefix + "cloud_color", Color(1.0, 0.99, 0.97))
mat.set_shader_parameter(prefix + "cloud_shadow", Color(0.72, 0.75, 0.88))
mat.set_shader_parameter(prefix + "cloud_cover", 0.500)
mat.set_shader_parameter(prefix + "cloud_scale", 2.5)
mat.set_shader_parameter(prefix + "cloud_softness", 0.020)
static func make_environment(sky_variant: String = "day") -> Environment:
var environment := Environment.new()
environment.background_mode = Environment.BG_SKY
environment.sky = make_sky(sky_variant)
# Flat-ish ambient keeps toon shadow bands readable (sun does the shaping).
environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
environment.ambient_light_energy = 0.55
if sky_variant == "sakura":
# The rig's bounce and up-light already put a cool value on every shadow
# plane, and the cel ramp's darkest stop is 0.36 rather than zero. Left
# at 0.55 on top of that, ambient becomes a flood: the bands converge,
# the violet shadow tint washes out, and the picture goes milky. This is
# the hemisphere's SKY half only — its ground half is the UpLight.
environment.ambient_light_energy = 0.22
# Filmic with a high white point: near-linear through the cel bands (they
# stay crisp) but a soft shoulder above 1.0. LINEAR clipped lit white
# surfaces and then bloomed the clip — every facade glowed like porcelain.
environment.tonemap_mode = Environment.TONE_MAPPER_FILMIC
environment.tonemap_white = 2.4
# Bloom for genuine emissives only (tracers, plasma, neon) — lit geometry
# must never cross this threshold or it reads as glowing glass.
environment.glow_enabled = true
environment.glow_intensity = 0.4
environment.glow_bloom = 0.05
environment.glow_hdr_threshold = 1.45
# The colour grade moved into the ink pass, where it can be a SPLIT-tone
# (cool violet into the darks, warm paper white into the lights) instead of
# a single saturation number. Environment's adjustment stage runs after that
# pass, so leaving it at 1.08 would grade the frame twice and re-introduce
# exactly the hue shifting the note below warns about.
environment.adjustment_enabled = false
environment.adjustment_saturation = 1.0
environment.adjustment_contrast = 1.0
# A touch of depth haze for scale; far enough to not gray the arena.
environment.fog_enabled = true
environment.fog_light_color = Color(0.65, 0.75, 0.9)
environment.fog_density = 0.0012
if sky_variant == "sakura":
# Haze, not fog. The reference's far hills are pale and low-contrast
# because everything more than a hundred metres out is painted as
# atmosphere; the density is chosen so a two-hundred-metre view still
# reads as air rather than as weather.
environment.fog_light_color = Color(0.902, 0.925, 0.969) # PAL.fog
environment.fog_density = 0.0022
_add_grounding(environment)
return environment
## Contact darkening. This is what was missing more than anything else.
##
## Ambient here is a flood — a single energy applied to every surface no matter
## what is standing over it — so a lamp post met the pavement with no darkening
## at all, and so did every building, every crate and every character's feet.
## `tools/levels.py` puts a number on it: the Neon Alley aerial measured a 1st
## percentile of 119, meaning the DARKEST few pixels in the whole frame were
## still mid-grey. Nothing was dark, so nothing looked like it was resting on
## anything.
##
## Lowering the old 1.35 ambient flood restores form, but it cannot solve
## contact on its own. The rest of the fix is to take ambient away only where
## geometry is in the way, which is exactly what SSAO does.
##
## The tuning is what keeps it anime rather than "PBR game with dirt in the
## corners":
##
## radius 0.7 Contact, not global. A big radius produces the soft grey
## haze under everything that reads as smog; a tight one draws
## a dark seam right where two surfaces meet, which is what a
## cel artist would ink in by hand.
## power 3.0 Sharpens the falloff so the occlusion is nearly gone a
## half-metre out. Combined with the small radius this gives a
## defined contact line rather than a gradient.
## sharpness .98 Keeps the AO from bleeding across depth discontinuities.
## Blurry AO over a hard-edged cel look is instantly wrong.
## light_affect Only 0.08. Direct sunlight must keep landing in clean flat
## bands — that IS the style. AO belongs in the ambient term,
## where it darkens what the sun never reached anyway.
static func _add_grounding(environment: Environment) -> void:
environment.ssao_enabled = true
environment.ssao_radius = 0.7
environment.ssao_intensity = 3.2
environment.ssao_power = 3.0
environment.ssao_detail = 0.6
environment.ssao_horizon = 0.1
environment.ssao_sharpness = 0.98
environment.ssao_light_affect = 0.08
environment.ssao_ao_channel_affect = 0.0