OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / addons / nubis_clouds / nodes / cloud_volume.gd

722 lines · gd ref:
@tool
class_name CloudVolume
extends Node3D

## A physical cloud volume rendered as a FogVolume with GPU-generated density.
## Uses the same Perlin-Worley noise textures as the Nubis sky shader.

@export var cloud_size := Vector3(500.0, 200.0, 500.0):
	set(v):
		cloud_size = v
		if _fog_volume: _fog_volume.size = v
@export_range(0.0, 5.0) var density_scale: float = 1.5:
	set(v):
		density_scale = v
		if _fog_material: _fog_material.set_shader_parameter("density_scale", v)
@export_range(0.0, 1.0) var coverage: float = 0.35
@export_range(16, 128, 8) var grid_resolution: int = 32
@export var shared_sdf: ImageTexture3D  ## If set, use this instead of generating
@export var terrain_heightmap: Texture2D
@export var terrain_aabb_min := Vector3.ZERO
@export var terrain_aabb_max := Vector3(1000, 500, 1000)
@export_range(0.0, 200.0) var terrain_fade_height: float = 50.0
@export_range(0.0, 10.0) var wind_speed: float = 0.02

var _fog_volume: FogVolume
var _fog_material: ShaderMaterial
var _density_texture_rid: RID
var _density_image_texture: ImageTexture3D

# GPU compute
var _rd: RenderingDevice
var _fill_shader: RID
var _fill_pipeline: RID
var _noise_sampler: RID
var _gpu_ready := false


var _pw_images: Array[Image]
var _worl_images: Array[Image]
var _weather_image: Image
var _pw_size: int = 0
var _worl_size: int = 0
var _terrain_image: Image

func _ready() -> void:
	_load_noise_images()
	_load_terrain_heightmap()
	if shared_sdf:
		_density_image_texture = shared_sdf
	else:
		_generate_sdf_cpu_procedural()
	_register_with_compositor()


func _load_terrain_heightmap() -> void:
	if terrain_heightmap == null:
		return
	_terrain_image = terrain_heightmap.get_image()
	if _terrain_image and _terrain_image.is_compressed():
		_terrain_image.decompress()
	if _terrain_image:
		print("[CloudVolume] Terrain heightmap loaded (%dx%d)" % [_terrain_image.get_width(), _terrain_image.get_height()])


var _sun_light: DirectionalLight3D
var _moon_light: DirectionalLight3D


var _wind_time: float = 0.0

@export var wrap_distance: float = 3000.0  ## Clouds wrap around at this distance from camera

func _process(delta: float) -> void:
	_wind_time += delta * wind_speed
	# Physical cloud drift with wind
	position.x += delta * wind_speed * 50.0
	position.z += delta * wind_speed * 15.0

	# Wrap around camera: when cloud drifts too far, teleport to other side
	if wrap_distance > 0.0:
		var cam := get_viewport().get_camera_3d()
		if cam:
			var cam_xz := Vector2(cam.global_position.x, cam.global_position.z)
			var cloud_xz := Vector2(global_position.x, global_position.z)
			var diff := cloud_xz - cam_xz
			if diff.x > wrap_distance:
				position.x -= wrap_distance * 2.0
			elif diff.x < -wrap_distance:
				position.x += wrap_distance * 2.0
			if diff.y > wrap_distance:
				position.z -= wrap_distance * 2.0
			elif diff.y < -wrap_distance:
				position.z += wrap_distance * 2.0

	var sun := _get_sun_direction()
	var sun_color := _get_sun_color()
	var moon := _get_moon_direction()
	var moon_color := _get_moon_color()
	if not _compositor_entry.is_empty():
		_compositor_entry["pos"] = global_position
		_compositor_entry["sun_dir"] = sun
		_compositor_entry["sun_color"] = sun_color
		_compositor_entry["moon_dir"] = moon
		_compositor_entry["moon_color"] = moon_color
		_compositor_entry["wind_time"] = _wind_time
	if _fog_material:
		_fog_material.set_shader_parameter("sun_direction", sun)


func _get_sun_direction() -> Vector3:
	if not is_instance_valid(_sun_light):
		_find_lights()
	if _sun_light:
		return _sun_light.global_basis.z.normalized()
	return Vector3(0.5, 0.7, 0.3).normalized()


func _get_sun_color() -> Color:
	if _sun_light:
		var c := _sun_light.light_color
		return Color(c.r, c.g, c.b, _sun_light.light_energy)
	return Color(1.0, 0.95, 0.85, 1.0)


func _get_moon_direction() -> Vector3:
	if not is_instance_valid(_moon_light):
		_find_lights()
	if _moon_light:
		return _moon_light.global_basis.z.normalized()
	return Vector3(-0.5, 0.3, -0.7).normalized()


func _get_moon_color() -> Color:
	if _moon_light:
		var c := _moon_light.light_color
		return Color(c.r, c.g, c.b, _moon_light.light_energy)
	return Color(0.5, 0.55, 0.7, 0.0)


func _find_lights() -> void:
	var lights: Array[DirectionalLight3D] = []
	_collect_directional_lights(get_tree().root, lights)
	if lights.size() >= 1 and not is_instance_valid(_sun_light):
		_sun_light = lights[0]
	if lights.size() >= 2 and not is_instance_valid(_moon_light):
		_moon_light = lights[1]


func _collect_directional_lights(node: Node, out: Array[DirectionalLight3D]) -> void:
	if node is DirectionalLight3D:
		out.append(node)
		if out.size() >= 2:
			return
	for child in node.get_children():
		_collect_directional_lights(child, out)
		if out.size() >= 2:
			return


var _mesh_instance: MeshInstance3D

func _create_cloud_box() -> void:
	# Box mesh for ray-marching (unit cube, scaled to cloud_size)
	var box := BoxMesh.new()
	box.size = Vector3(1, 1, 1)

	var shader := load("res://addons/nubis_clouds/shaders/cloud_spatial.gdshader") as Shader
	if shader == null:
		push_error("[CloudVolume] Failed to load cloud_spatial.gdshader")
		return

	_fog_material = ShaderMaterial.new()
	_fog_material.shader = shader
	_fog_material.set_shader_parameter("density_scale", density_scale)

	# Load and bind noise textures for up-rez
	var pw_tex = load("res://addons/nubis_clouds/textures/perlworlnoise.png")
	var worl_tex = load("res://addons/nubis_clouds/textures/worlnoise.webp")
	if pw_tex: _fog_material.set_shader_parameter("perlworlnoise", pw_tex)
	if worl_tex: _fog_material.set_shader_parameter("worlnoise", worl_tex)

	box.surface_set_material(0, _fog_material)

	_mesh_instance = MeshInstance3D.new()
	_mesh_instance.mesh = box
	_mesh_instance.scale = cloud_size
	# Transparency sorting
	_mesh_instance.transparency = 0.01
	add_child(_mesh_instance)


func _setup_gpu() -> void:
	_rd = RenderingServer.get_rendering_device()
	if _rd == null:
		return

	var shader_file := load("res://addons/nubis_clouds/shaders/cloud_density_fill.glsl") as RDShaderFile
	if shader_file == null:
		push_warning("[CloudVolume] No GPU shader, falling back to CPU")
		return
	var spirv := shader_file.get_spirv()
	if spirv == null:
		return
	_fill_shader = _rd.shader_create_from_spirv(spirv)
	if not _fill_shader.is_valid():
		return
	_fill_pipeline = _rd.compute_pipeline_create(_fill_shader)

	var ss := RDSamplerState.new()
	ss.min_filter = RenderingDevice.SAMPLER_FILTER_LINEAR
	ss.mag_filter = RenderingDevice.SAMPLER_FILTER_LINEAR
	ss.mip_filter = RenderingDevice.SAMPLER_FILTER_LINEAR
	ss.repeat_u = RenderingDevice.SAMPLER_REPEAT_MODE_REPEAT
	ss.repeat_v = RenderingDevice.SAMPLER_REPEAT_MODE_REPEAT
	ss.repeat_w = RenderingDevice.SAMPLER_REPEAT_MODE_REPEAT
	_noise_sampler = _rd.sampler_create(ss)

	_gpu_ready = true


func _generate_density_gpu() -> void:
	var res := grid_resolution

	# Create output 3D texture on GPU
	var fmt := RDTextureFormat.new()
	fmt.format = RenderingDevice.DATA_FORMAT_R16_SFLOAT
	fmt.width = res
	fmt.height = res
	fmt.depth = res
	fmt.array_layers = 1
	fmt.mipmaps = 1
	fmt.texture_type = RenderingDevice.TEXTURE_TYPE_3D
	fmt.usage_bits = (
		RenderingDevice.TEXTURE_USAGE_STORAGE_BIT |
		RenderingDevice.TEXTURE_USAGE_SAMPLING_BIT |
		RenderingDevice.TEXTURE_USAGE_CAN_COPY_FROM_BIT
	)
	_density_texture_rid = _rd.texture_create(fmt, RDTextureView.new())

	# Load noise textures
	var pw_tex := load("res://addons/nubis_clouds/textures/perlworlnoise.png")
	var worl_tex := load("res://addons/nubis_clouds/textures/worlnoise.webp")
	var weather_tex := load("res://addons/nubis_clouds/textures/weather.webp")
	if pw_tex == null or worl_tex == null or weather_tex == null:
		push_error("[CloudVolume] Missing noise textures")
		_generate_density_cpu()
		return

	# Convert Godot texture RIDs to RenderingDevice RIDs
	var pw_rid: RID = RenderingServer.texture_get_rd_texture(pw_tex.get_rid())
	var worl_rid: RID = RenderingServer.texture_get_rd_texture(worl_tex.get_rid())
	var weather_rid: RID = RenderingServer.texture_get_rd_texture(weather_tex.get_rid())

	# Uniforms: output image + 3 noise samplers
	var out_uniform := RDUniform.new()
	out_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
	out_uniform.binding = 0
	out_uniform.add_id(_density_texture_rid)

	var pw_uniform := RDUniform.new()
	pw_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
	pw_uniform.binding = 1
	pw_uniform.add_id(_noise_sampler)
	pw_uniform.add_id(pw_rid)

	var worl_uniform := RDUniform.new()
	worl_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
	worl_uniform.binding = 2
	worl_uniform.add_id(_noise_sampler)
	worl_uniform.add_id(worl_rid)

	var weather_uniform := RDUniform.new()
	weather_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
	weather_uniform.binding = 3
	weather_uniform.add_id(_noise_sampler)
	weather_uniform.add_id(weather_rid)

	var uniform_set := UniformSetCacheRD.get_cache(_fill_shader, 0,
		[out_uniform, pw_uniform, worl_uniform, weather_uniform])

	# Push constants
	var pos := global_position
	var push := PackedFloat32Array()
	push.resize(12)
	push[0] = pos.x; push[1] = pos.y; push[2] = pos.z; push[3] = float(res)
	push[4] = cloud_size.x; push[5] = cloud_size.y; push[6] = cloud_size.z; push[7] = coverage
	push[8] = float(Time.get_ticks_msec()) / 1000.0; push[9] = 1.0; push[10] = 1.0; push[11] = 0.0
	var push_bytes := push.to_byte_array()

	var groups := ceili(float(res) / 8.0)
	var compute_list := _rd.compute_list_begin()
	_rd.compute_list_bind_compute_pipeline(compute_list, _fill_pipeline)
	_rd.compute_list_bind_uniform_set(compute_list, uniform_set, 0)
	_rd.compute_list_set_push_constant(compute_list, push_bytes, push_bytes.size())
	_rd.compute_list_dispatch(compute_list, groups, groups, groups)
	_rd.compute_list_end()

	# Read back density data and create ImageTexture3D for the fog shader
	# (FogVolume needs a Godot texture, not a raw RID)
	call_deferred("_readback_density")


func _readback_density() -> void:
	# Wait a few frames for GPU to finish
	await get_tree().process_frame
	await get_tree().process_frame
	await get_tree().process_frame

	var res := grid_resolution
	var slice_size := res * res * 2  # R16F = 2 bytes per texel
	var total_size := slice_size * res
	var raw: PackedByteArray = _rd.texture_get_data(_density_texture_rid, 0)

	var images: Array[Image] = []
	for z in res:
		var slice_data := raw.slice(z * slice_size, (z + 1) * slice_size)
		var img := Image.create_from_data(res, res, false, Image.FORMAT_RH, slice_data)
		images.append(img)

	_density_image_texture = ImageTexture3D.new()
	_density_image_texture.create(Image.FORMAT_RH, res, res, res, false, images)

	if _fog_material:
		_fog_material.set_shader_parameter("cloud_density", _density_image_texture)

	# Clean up GPU texture
	if _density_texture_rid.is_valid():
		_rd.free_rid(_density_texture_rid)
		_density_texture_rid = RID()

	print("[CloudVolume] GPU density fill complete (%dx%dx%d)" % [res, res, res])


func _load_noise_images() -> void:
	var pw_tex = load("res://addons/nubis_clouds/textures/perlworlnoise.png") as Texture3D
	var worl_tex = load("res://addons/nubis_clouds/textures/worlnoise.webp") as Texture3D
	var weather_tex = load("res://addons/nubis_clouds/textures/weather.webp") as Texture2D

	if pw_tex:
		var all_images := pw_tex.get_data()
		_pw_size = pw_tex.get_width()
		var depth := pw_tex.get_depth()
		# get_data() returns all mip levels concatenated — only take mip 0
		_pw_images = []
		for i in mini(depth, all_images.size()):
			var img := all_images[i]
			if img.is_compressed():
				img.decompress()
			_pw_images.append(img)
		print("[CloudVolume] Perlin-Worley: %d slices, %dx%d" % [_pw_images.size(), _pw_size, _pw_size])
	if worl_tex:
		var all_images := worl_tex.get_data()
		_worl_size = worl_tex.get_width()
		var depth := worl_tex.get_depth()
		_worl_images = []
		for i in mini(depth, all_images.size()):
			var img := all_images[i]
			if img.is_compressed():
				img.decompress()
			_worl_images.append(img)
	if weather_tex:
		_weather_image = weather_tex.get_image()
		if _weather_image.is_compressed():
			_weather_image.decompress()


func _sample_3d_texture(images: Array[Image], tex_size: int, uvw: Vector3) -> Color:
	if images.is_empty() or tex_size == 0:
		return Color.BLACK
	# Wrap UVW to [0,1]
	var u := fmod(uvw.x, 1.0); if u < 0: u += 1.0
	var v := fmod(uvw.y, 1.0); if v < 0: v += 1.0
	var w := fmod(uvw.z, 1.0); if w < 0: w += 1.0
	# Nearest-neighbor sampling (fast, good enough for 128³)
	var ix := clampi(int(u * tex_size), 0, tex_size - 1)
	var iy := clampi(int(v * tex_size), 0, tex_size - 1)
	var iz := clampi(int(w * images.size()), 0, images.size() - 1)
	return images[iz].get_pixel(ix, iy)


func _sample_weather(uv: Vector2) -> Color:
	if _weather_image == null:
		return Color(0.5, 0.5, 1.0, 1.0)
	var u := fmod(uv.x, 1.0); if u < 0: u += 1.0
	var v := fmod(uv.y, 1.0); if v < 0: v += 1.0
	var ix := clampi(int(u * _weather_image.get_width()), 0, _weather_image.get_width() - 1)
	var iy := clampi(int(v * _weather_image.get_height()), 0, _weather_image.get_height() - 1)
	return _weather_image.get_pixel(ix, iy)


func _remap(v: float, lo: float, hi: float, nlo: float, nhi: float) -> float:
	return nlo + (v - lo) / (hi - lo) * (nhi - nlo)


func _generate_sdf_cpu() -> void:
	# Generate dimensional profile (SDF gradient 0=outside, 1=deep inside)
	# from noise. This is what Nubis Cubed calls the NVDF dimensional profile.
	var res := grid_resolution
	var has_textures := not _pw_images.is_empty()
	var images: Array[Image] = []

	for z in res:
		var img := Image.create(res, res, false, Image.FORMAT_RF)
		for y in res:
			for x in res:
				var uvw := Vector3(float(x) / float(res), float(y) / float(res), float(z) / float(res))
				var world_pos := global_position + (uvw - Vector3(0.5, 0.5, 0.5)) * cloud_size
				var sdf: float
				if has_textures:
					sdf = _schneider_sdf(uvw, world_pos)
				else:
					sdf = _procedural_sdf(uvw, world_pos)
				img.set_pixel(x, y, Color(sdf, 0.0, 0.0, 1.0))
		images.append(img)

	_density_image_texture = ImageTexture3D.new()
	_density_image_texture.create(Image.FORMAT_RF, res, res, res, false, images)
	if _fog_material:
		_fog_material.set_shader_parameter("cloud_sdf", _density_image_texture)
	var method := "Schneider SDF" if has_textures else "procedural SDF"
	print("[CloudVolume] %s complete (%dx%dx%d)" % [method, res, res, res])


func _schneider_sdf(uvw: Vector3, world_pos: Vector3) -> float:
	# Generate dimensional profile from Perlin-Worley noise
	# Result is 0 (outside cloud) to 1 (deep inside cloud) — a smooth SDF gradient
	var height_frac := uvw.y

	# Scale noise to cloud volume — uvw gives us [0,1] within the box
	# Use UVW directly so the noise pattern fills the volume
	var noise_uvw := uvw * 2.0  # 2 noise periods across the volume
	var n: Color = _sample_3d_texture(_pw_images, _pw_size, noise_uvw)
	var fbm_val := n.g * 0.625 + n.b * 0.25 + n.a * 0.125

	# Height gradient (cumulus)
	var gradient := _density_height_gradient(height_frac, 0.5)

	# Base cloud shape
	var base := _remap(n.r, -(1.0 - fbm_val), 1.0, 0.0, 1.0)

	# Weather coverage — use UVW so it maps to the volume
	var weather_uv := Vector2(uvw.x, uvw.z)
	var weather: Color = _sample_weather(weather_uv)
	var weather_coverage := coverage * weather.b

	base = _remap(base * gradient, 1.0 - weather_coverage, 1.0, 0.0, 1.0)
	base *= weather_coverage

	# Clamp to [0, 1] — this IS the dimensional profile (SDF-like gradient)
	# 0 = outside cloud, >0 = inside with gradient toward core
	return clampf(base, 0.0, 1.0)


var _sphere_cache: Array = []  # pre-computed sphere positions for fractal cumulus

func _build_cumulus_spheres() -> void:
	# Single well-formed cumulus per CloudVolume
	# Wide flat base, towering cauliflower top — like Houdini Cloud Shape
	_sphere_cache.clear()

	# Base: wide ellipsoidal dome
	_add_sphere(Vector3(0.5, 0.35, 0.5), 0.32, 0)
	_add_sphere(Vector3(0.38, 0.33, 0.45), 0.18, 0)
	_add_sphere(Vector3(0.62, 0.33, 0.55), 0.18, 0)

	# Tower: center column rising upward
	_add_sphere(Vector3(0.5, 0.55, 0.5), 0.20, 1)
	_add_sphere(Vector3(0.5, 0.68, 0.5), 0.16, 1)

	# Ring of cauliflower bumps around the top
	for i in 7:
		var angle := float(i) * TAU / 7.0 + _h3(Vector3(float(i), 10.0, 0.0)) * 0.4
		var r_offset := 0.12 + _h3(Vector3(float(i), 11.0, 0.0)) * 0.08
		var pos := Vector3(
			0.5 + cos(angle) * r_offset,
			0.50 + _h3(Vector3(float(i), 12.0, 0.0)) * 0.18,
			0.5 + sin(angle) * r_offset)
		var radius := 0.10 + _h3(Vector3(float(i), 13.0, 0.0)) * 0.08
		_add_sphere(pos, radius, 1)

	# Sub-bumps on level 1 spheres
	var l1_count := _sphere_cache.size()
	for si in range(3, l1_count):
		var parent: Dictionary = _sphere_cache[si]
		var pc: Vector3 = parent["pos"]
		var pr: float = parent["radius"]
		var child_count := 3 + int(_h3(Vector3(float(si), 20.0, 0.0)) * 3.0)
		for i in child_count:
			var angle := float(i) * TAU / float(child_count) + _h3(Vector3(float(si * 10 + i), 21.0, 0.0)) * 1.0
			var child_r := pr * (0.45 + _h3(Vector3(float(si * 10 + i), 22.0, 0.0)) * 0.25)
			var offset_r := pr * 0.65
			var y_bias := pr * 0.5 + _h3(Vector3(float(si * 10 + i), 23.0, 0.0)) * pr * 0.4
			var child_pos := Vector3(
				pc.x + cos(angle) * offset_r,
				pc.y + y_bias,
				pc.z + sin(angle) * offset_r)
			child_pos = child_pos.clamp(Vector3(0.05, 0.05, 0.05), Vector3(0.95, 0.95, 0.95))
			_add_sphere(child_pos, child_r, 2)

	# Detail bumps on level 2
	var l2_start := l1_count
	var l2_end := _sphere_cache.size()
	for si in range(l2_start, mini(l2_end, l2_start + 40)):
		var parent: Dictionary = _sphere_cache[si]
		var pc: Vector3 = parent["pos"]
		var pr: float = parent["radius"]
		for i in 3:
			var angle := float(i) * TAU / 3.0 + _h3(Vector3(float(si * 100 + i), 30.0, 0.0)) * 2.0
			var child_r := pr * (0.35 + _h3(Vector3(float(si * 100 + i), 31.0, 0.0)) * 0.25)
			var offset_r := pr * 0.55
			var child_pos := Vector3(
				pc.x + cos(angle) * offset_r,
				pc.y + pr * 0.35,
				pc.z + sin(angle) * offset_r)
			child_pos = child_pos.clamp(Vector3(0.05, 0.05, 0.05), Vector3(0.95, 0.95, 0.95))
			_add_sphere(child_pos, child_r, 3)

	print("[CloudVolume] Built %d spheres (4 levels)" % _sphere_cache.size())


func _add_sphere(pos: Vector3, radius: float, level: int) -> void:
	_sphere_cache.append({"pos": pos, "radius": radius, "level": level})


func _procedural_sdf(uvw: Vector3, _world_pos: Vector3) -> float:
	# Pure sphere dimensional profile: 0 at surface, 1 at core
	# All detail (noise erosion, flat bottom, wisps) happens in the compositor shader
	var center := Vector3(0.5, 0.5, 0.5)
	var dist := (uvw - center).length()
	var sdf := 1.0 - smoothstepf(0.0, 0.45, dist)
	# Soft edge fade at box boundaries
	var ex := smoothstepf(0.0, 0.05, uvw.x) * smoothstepf(0.0, 0.05, 1.0 - uvw.x)
	var ey := smoothstepf(0.0, 0.05, uvw.y) * smoothstepf(0.0, 0.05, 1.0 - uvw.y)
	var ez := smoothstepf(0.0, 0.05, uvw.z) * smoothstepf(0.0, 0.05, 1.0 - uvw.z)
	return clampf(sdf * ex * ey * ez, 0.0, 1.0)


var _sdf_rd_rid: RID  # RD-level texture RID for the compositor
var _compositor_entry: Dictionary  # Reference to our entry in NubisCompositor.cloud_volumes

func _register_with_compositor() -> void:
	if not _density_image_texture:
		push_warning("[CloudVolume] No SDF texture to register")
		return

	# Get the RD-level texture RID from the Godot texture
	var rs_rid := _density_image_texture.get_rid()
	_sdf_rd_rid = RenderingServer.texture_get_rd_texture(rs_rid)

	# Find or create the NubisCompositor in the scene
	var compositor := _find_nubis_compositor()
	if compositor:
		_compositor_entry = {
			"pos": global_position,
			"size": cloud_size,
			"sdf_rid": _sdf_rd_rid,
			"sun_dir": _get_sun_direction(),
			"sun_color": _get_sun_color(),
			"moon_dir": _get_moon_direction(),
			"moon_color": _get_moon_color(),
		}
		compositor.cloud_volumes.append(_compositor_entry)
		print("[CloudVolume] Registered with NubisCompositor (pos=%s size=%s)" % [str(global_position), str(cloud_size)])
	else:
		push_warning("[CloudVolume] No NubisCompositor found — cloud won't render via compositor")


func _find_nubis_compositor() -> NubisCompositor:
	# Search the scene tree for a WorldEnvironment with a Compositor that has NubisCompositor
	var we := _find_node_of_type(get_tree().root, "WorldEnvironment")
	if we and we.has_method("get") and we.get("compositor"):
		var comp = we.get("compositor")
		if comp and comp is Compositor:
			for effect in comp.compositor_effects:
				if effect is NubisCompositor:
					return effect
	return null


func _find_node_of_type(node: Node, type_name: String) -> Node:
	if node.get_class() == type_name:
		return node
	for child in node.get_children():
		var found := _find_node_of_type(child, type_name)
		if found:
			return found
	return null


func _generate_sdf_cpu_procedural() -> void:
	var res := grid_resolution
	var images: Array[Image] = []
	for z in res:
		var img := Image.create(res, res, false, Image.FORMAT_RF)
		for y in res:
			for x in res:
				# Flip Y: Image y=0 is top, GLSL texture v=0 is bottom
				var uvw := Vector3(float(x) / float(res), 1.0 - float(y) / float(res), float(z) / float(res))
				var world_pos := global_position + (uvw - Vector3(0.5, 0.5, 0.5)) * cloud_size
				var sdf := _procedural_sdf(uvw, world_pos)
				img.set_pixel(x, y, Color(sdf, 0.0, 0.0, 1.0))
		images.append(img)
	_density_image_texture = ImageTexture3D.new()
	_density_image_texture.create(Image.FORMAT_RF, res, res, res, false, images)
	if _fog_material:
		_fog_material.set_shader_parameter("cloud_sdf", _density_image_texture)
	print("[CloudVolume] Procedural cumulus SDF complete (%dx%dx%d)" % [res, res, res])


func _generate_density_cpu() -> void:
	var res := grid_resolution
	var has_textures := not _pw_images.is_empty()
	var images: Array[Image] = []

	for z in res:
		var img := Image.create(res, res, false, Image.FORMAT_RF)
		for y in res:
			for x in res:
				# Flip Y: Image y=0 is top, GLSL texture v=0 is bottom
				var uvw := Vector3(float(x) / float(res), 1.0 - float(y) / float(res), float(z) / float(res))
				var world_pos := global_position + (uvw - Vector3(0.5, 0.5, 0.5)) * cloud_size
				var d: float
				if has_textures:
					d = _schneider_density(uvw, world_pos)
				else:
					d = _procedural_density(uvw, world_pos)
				img.set_pixel(x, y, Color(d, 0.0, 0.0, 1.0))
		images.append(img)

	_density_image_texture = ImageTexture3D.new()
	_density_image_texture.create(Image.FORMAT_RF, res, res, res, false, images)
	if _fog_material:
		_fog_material.set_shader_parameter("cloud_density", _density_image_texture)
	var method := "Schneider" if has_textures else "procedural"
	print("[CloudVolume] CPU density (%s) complete (%dx%dx%d)" % [method, res, res, res])


func _schneider_density(uvw: Vector3, world_pos: Vector3) -> float:
	# Schneider/HZD density function using pre-baked noise textures
	var height_frac := uvw.y

	# Sample base Perlin-Worley noise (same scale as sky shader: pos * 0.00008)
	var noise_uvw := world_pos * 0.00008
	var n: Color = _sample_3d_texture(_pw_images, _pw_size, noise_uvw)
	var fbm_val := n.g * 0.625 + n.b * 0.25 + n.a * 0.125

	# Height gradient (stratocumulus-cumulus blend)
	var gradient := _density_height_gradient(height_frac, 0.5)

	# Base cloud shape
	var base_cloud := _remap(n.r, -(1.0 - fbm_val), 1.0, 0.0, 1.0)

	# Weather map coverage
	var weather_scale := 0.00006
	var weather_uv := Vector2(world_pos.x, world_pos.z) * weather_scale + Vector2(0.5, 0.5)
	var weather: Color = _sample_weather(weather_uv)
	var weather_coverage := coverage * weather.b

	base_cloud = _remap(base_cloud * gradient, 1.0 - weather_coverage, 1.0, 0.0, 1.0)
	base_cloud *= weather_coverage

	# Detail erosion
	var detail_uvw := world_pos * 0.001
	var hn: Color = _sample_3d_texture(_worl_images, _worl_size, detail_uvw)
	var hfbm := hn.r * 0.625 + hn.g * 0.25 + hn.b * 0.125
	hfbm = lerpf(hfbm, 1.0 - hfbm, clampf(height_frac * 4.0, 0.0, 1.0))
	base_cloud = _remap(base_cloud, hfbm * 0.2 * height_frac, 1.0, 0.0, 1.0)

	var result := pow(clampf(base_cloud, 0.0, 1.0), (1.0 - height_frac) * 0.8 + 0.5)

	# Edge fade
	var ex := smoothstepf(0.0, 0.05, uvw.x) * smoothstepf(0.0, 0.05, 1.0 - uvw.x)
	var ey := smoothstepf(0.0, 0.05, uvw.y) * smoothstepf(0.0, 0.05, 1.0 - uvw.y)
	var ez := smoothstepf(0.0, 0.05, uvw.z) * smoothstepf(0.0, 0.05, 1.0 - uvw.z)
	result *= ex * ey * ez

	return result


func _density_height_gradient(height_frac: float, cloud_type: float) -> float:
	# Schneider height gradient presets
	var st := 1.0 - clampf(cloud_type * 2.0, 0.0, 1.0)
	var sc := 1.0 - absf(cloud_type - 0.5) * 2.0
	var cu := clampf(cloud_type - 0.5, 0.0, 1.0) * 2.0
	# Stratus, Stratocumulus, Cumulus gradients blended
	var g_bottom := 0.02 * st + 0.02 * sc + 0.01 * cu
	var g_bottom_end := 0.05 * st + 0.2 * sc + 0.0625 * cu
	var g_top_start := 0.09 * st + 0.48 * sc + 0.65 * cu
	var g_top_end := 0.11 * st + 0.625 * sc + 1.0 * cu
	return smoothstepf(g_bottom, g_bottom_end, height_frac) - smoothstepf(g_top_start, g_top_end, height_frac)


func _procedural_density(uvw: Vector3, world_pos: Vector3) -> float:
	var p := world_pos * 0.003
	var n := _vnoise(p) * 0.6 + _vnoise(p * 2.0) * 0.25 + _vnoise(p * 4.0) * 0.15
	var hg := smoothstepf(0.0, 0.15, uvw.y) * (1.0 - smoothstepf(0.7, 1.0, uvw.y))
	n *= hg
	n = maxf(n - 0.35, 0.0) / 0.65
	return clampf(n, 0.0, 1.0)


func smoothstepf(a: float, b: float, x: float) -> float:
	var t := clampf((x - a) / (b - a), 0.0, 1.0)
	return t * t * (3.0 - 2.0 * t)


func _vnoise(p: Vector3) -> float:
	var i := Vector3(floorf(p.x), floorf(p.y), floorf(p.z))
	var f := Vector3(p.x - i.x, p.y - i.y, p.z - i.z)
	f = Vector3(f.x*f.x*(3.0-2.0*f.x), f.y*f.y*(3.0-2.0*f.y), f.z*f.z*(3.0-2.0*f.z))
	return lerpf(
		lerpf(lerpf(_h3(i), _h3(i+Vector3(1,0,0)), f.x),
			  lerpf(_h3(i+Vector3(0,1,0)), _h3(i+Vector3(1,1,0)), f.x), f.y),
		lerpf(lerpf(_h3(i+Vector3(0,0,1)), _h3(i+Vector3(1,0,1)), f.x),
			  lerpf(_h3(i+Vector3(0,1,1)), _h3(i+Vector3(1,1,1)), f.x), f.y),
		f.z)


func _h3(p: Vector3) -> float:
	var q := Vector3(fmod(absf(p.x*443.897),1.0), fmod(absf(p.y*441.423),1.0), fmod(absf(p.z*437.195),1.0))
	return fmod(absf(q.x*(q.y+19.19) + q.y*(q.z+19.19) + q.z*(q.x+19.19)), 1.0)