@tool
## Legacy — canonical version is addons/nubis_clouds/nodes/nubis_compositor.gd
extends CompositorEffect
## Nubis cloud CompositorEffect — renders cloud volumes POST_TRANSPARENT
## with proper depth occlusion. Uses SceneData for camera matrices
## (no manual camera extraction).
@export var enabled_setting: bool = true
@export_range(0.0, 200.0) var density_scale: float = 80.0
@export_range(0.0, 1.0) var erosion_strength: float = 0.6
@export_range(0.001, 0.05) var noise_freq: float = 0.003
@export var shader_file: String = "res://addons/nubis/shaders/cloud_compositor.glsl"
# Cloud volumes to render (set by CloudVolume nodes)
var cloud_volumes: Array = [] # [{pos: Vector3, size: Vector3, sdf_tex: RID}]
var rd: RenderingDevice
var shader: RID
var pipeline: RID
var sampler: RID
var _pw_tex: Texture3D # Keep reference so it doesn't get freed
var _pw_tex_rs_rid: RID # RenderingServer RID (stable)
var _pw_debug_printed: bool = false
var _weather_tex: Texture2D
var _weather_tex_rs_rid: RID
var _has_weather_binding: bool = false # true if shader has binding 2
func _init() -> void:
effect_callback_type = EFFECT_CALLBACK_TYPE_POST_TRANSPARENT
rd = RenderingServer.get_rendering_device()
func _generate_noise_texture(res: int) -> ImageTexture3D:
# Generate a 4-channel RGBA noise texture that works as sampler3D in compute shaders
# Channels: R=low freq, G=high freq, B=worley-like, A=detail
var noise_lo := FastNoiseLite.new()
noise_lo.noise_type = FastNoiseLite.TYPE_PERLIN
noise_lo.frequency = 0.04
noise_lo.fractal_octaves = 4
var noise_hi := FastNoiseLite.new()
noise_hi.noise_type = FastNoiseLite.TYPE_PERLIN
noise_hi.frequency = 0.08
noise_hi.fractal_octaves = 3
var noise_worley := FastNoiseLite.new()
noise_worley.noise_type = FastNoiseLite.TYPE_CELLULAR
noise_worley.frequency = 0.06
noise_worley.fractal_octaves = 3
var noise_detail := FastNoiseLite.new()
noise_detail.noise_type = FastNoiseLite.TYPE_CELLULAR
noise_detail.frequency = 0.12
noise_detail.fractal_octaves = 2
var images: Array[Image] = []
for z in res:
var img := Image.create(res, res, false, Image.FORMAT_RGBA8)
for y in res:
for x in res:
var fx := float(x)
var fy := float(y)
var fz := float(z)
var r := noise_lo.get_noise_3d(fx, fy, fz) * 0.5 + 0.5
var g := noise_hi.get_noise_3d(fx, fy, fz) * 0.5 + 0.5
var b := 1.0 - (noise_worley.get_noise_3d(fx, fy, fz) * 0.5 + 0.5) # inverted worley
var a := 1.0 - (noise_detail.get_noise_3d(fx, fy, fz) * 0.5 + 0.5)
img.set_pixel(x, y, Color(r, g, b, a))
images.append(img)
var tex := ImageTexture3D.new()
tex.create(Image.FORMAT_RGBA8, res, res, res, false, images)
return tex
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE:
if shader.is_valid():
RenderingServer.free_rid(shader)
if sampler.is_valid():
rd.free_rid(sampler)
func _check_shader() -> bool:
if not rd:
return false
if pipeline.is_valid():
return true
# Compile shader from source at runtime (avoids stale import cache)
var glsl_path := shader_file
var f := FileAccess.open(glsl_path, FileAccess.READ)
if f == null:
push_error("[NubisCompositor] Cannot open %s" % glsl_path)
return false
var glsl_source := f.get_as_text()
f.close()
# Resolve #include "godot/scene_data_inc.glsl"
var include_path := "res://addons/nubis/shaders/godot/scene_data_inc.glsl"
var inc_f := FileAccess.open(include_path, FileAccess.READ)
if inc_f:
var inc_source := inc_f.get_as_text()
inc_f.close()
glsl_source = glsl_source.replace('#include "godot/scene_data_inc.glsl"', inc_source)
# Strip the #[compute] annotation and compile
glsl_source = glsl_source.replace("#[compute]", "")
var shader_src := RDShaderSource.new()
shader_src.source_compute = glsl_source
var spirv := rd.shader_compile_spirv_from_source(shader_src)
if spirv == null or spirv.compile_error_compute != "":
push_error("[NubisCompositor] Shader compile error: %s" % (spirv.compile_error_compute if spirv else "null"))
return false
shader = rd.shader_create_from_spirv(spirv)
if not shader.is_valid():
return false
pipeline = rd.compute_pipeline_create(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
sampler = rd.sampler_create(ss)
# Load the pre-baked Perlin-Worley texture and re-upload as uncompressed ImageTexture3D
# CompressedTexture3D from import isn't readable in compute shaders
var compressed := load("res://addons/nubis/textures/perlworlnoise.png") as CompressedTexture3D
if compressed:
var slices := compressed.get_data()
var depth := compressed.get_depth()
var width := compressed.get_width()
var height := compressed.get_height()
print("[NubisCompositor] PW source: %dx%dx%d, %d slices returned, format=%d" % [width, height, depth, slices.size(), slices[0].get_format() if slices.size() > 0 else -1])
# Scan full range of each channel across all slices
if slices.size() > 0:
var r_min := 1.0; var r_max := 0.0
var g_min := 1.0; var g_max := 0.0
var b_min := 1.0; var b_max := 0.0
var a_min := 1.0; var a_max := 0.0
for si in mini(slices.size(), depth):
var img: Image = slices[si]
if img.is_compressed():
img.decompress()
for y in range(0, img.get_height(), 8):
for x in range(0, img.get_width(), 8):
var p := img.get_pixel(x, y)
r_min = minf(r_min, p.r); r_max = maxf(r_max, p.r)
g_min = minf(g_min, p.g); g_max = maxf(g_max, p.g)
b_min = minf(b_min, p.b); b_max = maxf(b_max, p.b)
a_min = minf(a_min, p.a); a_max = maxf(a_max, p.a)
print("[NubisCompositor] PW ranges: R=[%.3f,%.3f] G=[%.3f,%.3f] B=[%.3f,%.3f] A=[%.3f,%.3f]" % [r_min, r_max, g_min, g_max, b_min, b_max, a_min, a_max])
var mip0_slices: Array[Image] = []
for i in depth:
if i < slices.size():
var img: Image = slices[i]
if img.is_compressed():
img.decompress()
img.convert(Image.FORMAT_RGBA8)
mip0_slices.append(img)
if mip0_slices.size() == depth:
_pw_tex = ImageTexture3D.new()
_pw_tex.create(Image.FORMAT_RGBA8, width, height, depth, false, mip0_slices)
_pw_tex_rs_rid = _pw_tex.get_rid()
print("[NubisCompositor] Perlin-Worley re-uploaded as ImageTexture3D (%dx%dx%d)" % [width, height, depth])
else:
push_warning("[NubisCompositor] Failed to extract PW slices (got %d, need %d)" % [mip0_slices.size(), depth])
if not _pw_tex:
# Fallback to procedural noise
_pw_tex = _generate_noise_texture(64)
if _pw_tex:
_pw_tex_rs_rid = _pw_tex.get_rid()
print("[NubisCompositor] Fallback: procedural noise texture (64³)")
# Check if shader uses weather_map (binding 2) by looking at source
_has_weather_binding = glsl_source.contains("weather_map")
if _has_weather_binding:
_weather_tex = load("res://addons/nubis/textures/weather.webp")
if _weather_tex:
_weather_tex_rs_rid = _weather_tex.get_rid()
print("[NubisCompositor] Weather map loaded")
print("[NubisCompositor] Shader compiled (%s), ready" % shader_file.get_file())
return pipeline.is_valid()
func _render_callback(p_effect_callback_type: EffectCallbackType, p_render_data: RenderData) -> void:
if not enabled_setting or cloud_volumes.is_empty():
return
if not rd or not _check_shader():
return
var render_scene_buffers := p_render_data.get_render_scene_buffers()
var scene_data := p_render_data.get_render_scene_data()
if not render_scene_buffers or not scene_data:
return
var size: Vector2i = render_scene_buffers.get_internal_size()
if size.x == 0 or size.y == 0:
return
@warning_ignore("integer_division")
var x_groups: int = (size.x - 1) / 8 + 1
@warning_ignore("integer_division")
var y_groups: int = (size.y - 1) / 8 + 1
# Sort volumes back-to-front so near volumes correctly attenuate far ones
var cam_pos: Vector3 = scene_data.get_cam_transform().origin
var sorted_volumes := cloud_volumes.duplicate()
sorted_volumes.sort_custom(func(a, b):
return a["pos"].distance_to(cam_pos) > b["pos"].distance_to(cam_pos)
)
var view_count: int = render_scene_buffers.get_view_count()
for view in view_count:
var scene_data_buffer: RID = scene_data.get_uniform_buffer()
var color_image: RID = render_scene_buffers.get_color_layer(view)
var depth_image: RID = render_scene_buffers.get_depth_layer(view)
# Set 0: SceneData + color + depth
var scene_uniform := RDUniform.new()
scene_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_UNIFORM_BUFFER
scene_uniform.binding = 0
scene_uniform.add_id(scene_data_buffer)
var color_uniform := RDUniform.new()
color_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
color_uniform.binding = 1
color_uniform.add_id(color_image)
var depth_uniform := RDUniform.new()
depth_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
depth_uniform.binding = 2
depth_uniform.add_id(sampler)
depth_uniform.add_id(depth_image)
var uniform_set_0: RID = UniformSetCacheRD.get_cache(shader, 0, [scene_uniform, color_uniform, depth_uniform])
# Render each cloud volume
for vol in sorted_volumes:
var sdf_rid: RID = vol.get("sdf_rid", RID())
if not sdf_rid.is_valid():
continue
# Set 1: cloud SDF + noise
var sdf_uniform := RDUniform.new()
sdf_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
sdf_uniform.binding = 0
sdf_uniform.add_id(sampler)
sdf_uniform.add_id(sdf_rid)
# Perlin-Worley noise: fetch RD RID fresh each frame
var pw_rid: RID = RenderingServer.texture_get_rd_texture(_pw_tex_rs_rid) if _pw_tex_rs_rid.is_valid() else RID()
if not pw_rid.is_valid():
pw_rid = sdf_rid
if not _pw_debug_printed:
print("[NubisCompositor] WARNING: PW RD RID invalid, using SDF fallback. RS RID valid=%s" % _pw_tex_rs_rid.is_valid())
_pw_debug_printed = true
elif not _pw_debug_printed:
print("[NubisCompositor] PW RD RID valid, sdf_rid=%s pw_rid=%s same=%s" % [sdf_rid, pw_rid, sdf_rid == pw_rid])
_pw_debug_printed = true
if not pw_rid.is_valid():
continue
var pw_uniform := RDUniform.new()
pw_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
pw_uniform.binding = 1
pw_uniform.add_id(sampler)
pw_uniform.add_id(pw_rid)
var set1_uniforms := [sdf_uniform, pw_uniform]
if _has_weather_binding:
var weather_rid: RID = RenderingServer.texture_get_rd_texture(_weather_tex_rs_rid) if _weather_tex_rs_rid.is_valid() else pw_rid
var weather_uniform := RDUniform.new()
weather_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
weather_uniform.binding = 2
weather_uniform.add_id(sampler)
weather_uniform.add_id(weather_rid)
set1_uniforms.append(weather_uniform)
var uniform_set_1: RID = UniformSetCacheRD.get_cache(shader, 1, set1_uniforms)
if not uniform_set_1.is_valid():
continue
# Push constants
var pos: Vector3 = vol["pos"]
var half: Vector3 = vol["size"] * 0.5
var sun: Vector3 = vol.get("sun_dir", Vector3(0.5, 0.7, 0.3)).normalized()
var wind_t: float = vol.get("wind_time", 0.0)
var push := PackedFloat32Array([
float(size.x), float(size.y), float(view), density_scale,
pos.x, pos.y, pos.z, wind_t,
half.x, half.y, half.z, erosion_strength,
sun.x, sun.y, sun.z, noise_freq,
])
var compute_list := rd.compute_list_begin()
rd.compute_list_bind_compute_pipeline(compute_list, pipeline)
rd.compute_list_bind_uniform_set(compute_list, uniform_set_0, 0)
rd.compute_list_bind_uniform_set(compute_list, uniform_set_1, 1)
rd.compute_list_set_push_constant(compute_list, push.to_byte_array(), push.size() * 4)
rd.compute_list_dispatch(compute_list, x_groups, y_groups, 1)
rd.compute_list_end()