class_name CloudBank
extends Node3D
## A SunshineClouds2 cloud layer on the GAME'S SHARED SKY, for as long as this node is
## in the tree. Moved out of map_mountain.gd (2026-10-06) so every world map can have
## one: the mountain (a donut around the massif) and the Geiss maps (a plain layer).
##
## HOW IT IS LOCALISED (read from SunshineCloudsCompute.glsl, see the sunshineclouds2
## skill). A Sunshine cloud exists where
## large_noise * extra_large * heightGradient.R > ~(1 - coverage)
## (sampleScene, compute.glsl:216-217). With extra_large_used_as_mask the "extra large"
## factor is a 2D TEXTURE ALPHA laid over mask_width_km centred on the driver's
## origin_offset — so a radial alpha disk IS the bank's footprint (footprint "donut").
## Without the mask (footprint "everywhere") the extra-large factor is the resource's own
## noise and clouds form wherever coverage allows. cloud_floor..cloud_ceiling is the
## altitude band. The effector is NOT the localiser: it adds to the shape AFTER the
## coverage threshold (compute.glsl:217,228), so a strong one floods its whole sphere to
## shape 1 — a featureless solid disk.
##
## ⛔ ADDED TO THE GAME'S SHARED SKY, NOT A MAP-OWNED WorldEnvironment: a map that owns
## one makes game.gd detach its Sky3D and the Atmosphere panel then drives a dead node
## (map_flat.gd's header). So the effect is appended to /root/Game/Sky3DExtended's
## compositor in _ready and removed in _exit_tree.
const GAME_SKY := "/root/Game/Sky3DExtended"
const DONUT := "donut"
const EVERYWHERE := "everywhere"
@export var enabled: bool = true
## The whole cloud look: every SunshineCloudsGD export. This SAME instance goes into the
## shared sky's compositor (never a duplicate), so editing it in the Remote inspector
## while the game runs changes the live clouds. Null = default_resource().
## ⚠ The noise textures and the mask are bound into GPU uniform sets once; after
## swapping a TEXTURE live, press the resource's "Refresh Compute" button.
@export var resource: SunshineCloudsGD
@export_enum("donut", "everywhere") var footprint: String = DONUT
## DONUT — metres from the map origin:
## inside inner_radius the cloud bank
## between the two clear, no clouds
## outside outer_radius background clouds, all the way out
## Each radius is the MIDDLE of a soft edge `fade` metres wide. The mask is GENERATED
## from these in _ready (_apply_donut_mask) and replaces any mask the resource holds.
@export var inner_radius: float = 3500.0
@export var outer_radius: float = 11500.0
@export var fade: float = 2000.0
## Optional density boost around the origin (SunshineCloudsEffector, world metres): adds
## Power*(1 - d/Radius) to the cloud shape inside the sphere; negative carves clouds
## away. Keep |Power| well under 1 or it erases the noise inside the sphere. 0 = off.
@export var effector_radius: float = 4000.0
@export_range(-100.0, 100.0) var effector_power: float = 0.0
## Wind: noise offsets move at direction * speed, in world m/s (driver._process). Scaled
## to km-sized features, not the addon's 100 m/s sky layer.
@export var wind_direction: Vector3 = Vector3(0.7071, 0.0, 0.7071)
@export var wind_large: float = 6.0
@export var wind_medium: float = 3.0
@export var wind_small: float = 1.5
## Prefix for this bank's log lines.
@export var log_tag: String = "CloudBank"
var _fx: CompositorEffect = null
var _sky: WorldEnvironment = null
func _ready() -> void:
if not enabled or DisplayServer.get_name() == "headless":
return
var sky := get_node_or_null(GAME_SKY) as WorldEnvironment
if sky == null:
push_warning("[%s] no shared sky at %s — no clouds" % [log_tag, GAME_SKY])
return
if resource == null:
resource = default_resource()
var res := resource
if footprint == DONUT:
_apply_donut_mask(res)
else:
res.extra_large_used_as_mask = false
res.refresh_compute()
if sky.compositor == null:
sky.compositor = Compositor.new()
var fx: Array[CompositorEffect] = sky.compositor.compositor_effects.duplicate()
if not fx.has(res):
fx.append(res)
sky.compositor.compositor_effects = fx
_fx = res
_sky = sky
var eff := SunshineCloudsEffector.new()
eff.name = "CloudBankEffector"
eff.Radius = effector_radius
eff.Power = effector_power
add_child(eff)
eff.global_position = Vector3(0.0, (res.cloud_floor + res.cloud_ceiling) * 0.5, 0.0)
var drv := SunshineCloudsDriverGD.new()
drv.name = "CloudBankDriver"
drv.clouds_resource = res
var lights: Array[DirectionalLight3D] = []
var steps: Array[int] = []
# Every tracked light runs its own light march at EVERY in-cloud sample, lit or
# not (compute.glsl:879-884 has no energy check), so the moon gets few steps.
for p in ["SunLight", "MoonLight"]:
var l := sky.get_node_or_null(p) as DirectionalLight3D
if l != null:
lights.append(l)
steps.append(16 if p == "SunLight" else 6)
drv.tracked_directional_lights = lights
drv.tracked_directional_light_shadow_steps = steps
var found: Array[String] = []
for l in lights:
found.append("%s(%d steps)" % [l.name, steps[lights.find(l)]])
var missing: Array[String] = []
for p in ["SunLight", "MoonLight"]:
if sky.get_node_or_null(p) == null:
missing.append(p)
print("[%s] cloud lights: %s%s" % [log_tag, ", ".join(found) if not found.is_empty() else "NONE",
(" (not found under %s: %s)" % [sky.name, ", ".join(missing)]) if not missing.is_empty() else ""])
var effs: Array[SunshineCloudsEffector] = [eff]
drv.tracked_point_effectors = effs
drv.origin_offset = Vector3.ZERO # the mask is centred here
drv.wind_direction = wind_direction
drv.extra_large_structures_wind_speed = 0.0
drv.large_structures_wind_speed = wind_large
drv.medium_structures_wind_speed = wind_medium
drv.small_structures_wind_speed = wind_small
drv.update_continuously = true
add_child(drv)
print("[%s] clouds %.0f..%.0f m, coverage %.2f, density %.2f, footprint %s" % [
log_tag, res.cloud_floor, res.cloud_ceiling, res.clouds_coverage, res.clouds_density, footprint])
func _exit_tree() -> void:
# The compositor belongs to the shared sky and outlives the map: take ours back.
if _sky != null and is_instance_valid(_sky) and _sky.compositor != null:
var fx: Array[CompositorEffect] = _sky.compositor.compositor_effects.duplicate()
fx.erase(_fx)
_sky.compositor.compositor_effects = fx
_fx = null
_sky = null
## Code copy of res://scenes/maps/mountain_clouds.tres, used when `resource` is null.
## The .tres is the one to tune.
static func default_resource() -> SunshineCloudsGD:
var res := SunshineCloudsGD.new() # loads its own shaders/noise when unset
res.cloud_floor = 650.0
res.cloud_ceiling = 1450.0
res.clouds_coverage = 0.7
res.clouds_density = 0.35
res.clouds_sharpness = 0.5
res.clouds_detail_power = 1.0
res.large_noise_scale = 8000.0
res.medium_noise_scale = 2000.0
res.small_noise_scale = 600.0
res.curl_noise_strength = 300.0
res.min_step_distance = 30.0
res.max_step_distance = 120.0
res.max_step_count = 160.0
res.max_lighting_steps = 16.0
res.lighting_travel_distance = 1200.0
res.cloud_shadow_steps = 16
res.atmospheric_density = 0.0
res.fog_effect_ground = 0.0
res.lighting_density = 0.55
res.lighting_sharpness = 0.34
res.clouds_anisotropy = 0.057
res.cloud_ambient_color = Color(1, 1, 1, 1)
res.cloud_ambient_tint = Color(0.1276, 0.18766, 0.22, 1)
res.ambient_occlusion_color = Color(0.693375, 0.223129, 0, 0.466667)
res.accumulation_decay = 0.8
return res
## Build the donut footprint from inner_radius / outer_radius / fade.
##
## The mask is a texture ALPHA laid over mask_width_km, centred on the driver's
## origin_offset (SunshineClouds.gd update_matrices); cloud density is multiplied by it
## at every height (compute.glsl sampleScene). A radial GradientTexture2D holds its last
## stop past fill_to, so everything beyond the outer radius is 1 = background clouds to
## the edge of the tile.
##
## The tile REPEATS, so it is made wide enough that the next copy's centre is further
## than any ray marches (max_step_count * max_step_distance, read now — raise those a
## lot at runtime and a far copy could come into reach).
func _apply_donut_mask(res: SunshineCloudsGD) -> void:
var f := maxf(fade, 1.0)
var half := f * 0.5
# Edges centred on the radii: bank solid to inner-half, gone by inner+half;
# background starts at outer-half, solid from outer+half. If the two edges would
# overlap, the clear ring collapses to zero width (no inversion).
var in_solid := maxf(inner_radius - half, 0.0)
var in_clear := in_solid + f
var out_clear := maxf(outer_radius - half, in_clear)
var outer := out_clear + f # background fully in from here
var reach := res.max_step_count * res.max_step_distance
var width := 2.0 * (outer + reach)
var g := Gradient.new()
g.offsets = PackedFloat32Array([0.0, in_solid / outer, in_clear / outer,
out_clear / outer, 1.0])
g.colors = PackedColorArray([Color(1, 1, 1, 1), Color(1, 1, 1, 1), Color(1, 1, 1, 0),
Color(1, 1, 1, 0), Color(1, 1, 1, 1)])
var tex := GradientTexture2D.new()
tex.gradient = g
# ~4 texels across the narrowest band (the fade, or the clear ring if narrower).
var gap := out_clear - in_clear
var narrowest := maxf(minf(f, gap) if gap > 0.0 else f, 1.0)
var px := clampi(nearest_po2(int(ceil(4.0 * width / narrowest))), 256, 4096)
tex.width = px
tex.height = px
tex.fill = GradientTexture2D.FILL_RADIAL
tex.fill_from = Vector2(0.5, 0.5)
tex.fill_to = Vector2(0.5 + outer / width, 0.5)
res.extra_large_noise_patterns = tex
res.extra_large_used_as_mask = true
res.mask_width_km = width / 1000.0
res.refresh_compute() # rebind: a cached resource may hold the previous mask
print("[%s] cloud donut: bank to %.0f m, clear %.0f..%.0f m, background from %.0f m (edges %.0f m soft; mask %.1f km, %d px)"
% [log_tag, inner_radius, in_clear, out_clear, outer_radius, f, width / 1000.0, px])