@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)