OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / cloud_bank.gd

220 lines · gd ref:
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])