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