@tool
class_name NubisEffect
extends CompositorEffect
## Nubis-inspired volumetric clouds via compute shader ray-marching.
## Phase 2: 3D noise textures + Nubis density pipeline.
@export_range(500.0, 20000.0, 100.0) var cloud_altitude_min: float = 1500.0
@export_range(1000.0, 30000.0, 100.0) var cloud_altitude_max: float = 4000.0
@export_range(0.0, 1.0, 0.01) var weather: float = 0.3
@export var enabled_setting: bool = true
@export var debug_view: bool = false
var rd: RenderingDevice
# Ray-march pass
var _march_shader: RID
var _march_pipeline: RID
var _march_sampler: RID
var _cloud_texture: RID
var _cloud_texture_size: Vector2i = Vector2i.ZERO
# Composite pass
var _comp_shader: RID
var _comp_pipeline: RID
var _comp_sampler: RID
# Noise textures
var _noise_gen: NubisNoiseGenerator
var _noise_ready: bool = false
var _noise_dispatched: bool = false # true after noise compute dispatched, wait 1 frame
# Timing
var _timing_sum_usec: float = 0.0
var _timing_count: int = 0
# Sun direction — updated externally or from scene
var sun_direction: Vector3 = Vector3(0.5, -0.7, 0.3).normalized()
func _init() -> void:
effect_callback_type = EFFECT_CALLBACK_TYPE_POST_TRANSPARENT
needs_normal_roughness = false
access_resolved_color = true
access_resolved_depth = true
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE:
if is_instance_valid(self):
_cleanup()
func _cleanup() -> void:
if rd == null:
return
if _noise_gen:
_noise_gen.cleanup()
_noise_gen = null
if _cloud_texture.is_valid(): rd.free_rid(_cloud_texture); _cloud_texture = RID()
if _march_sampler.is_valid(): rd.free_rid(_march_sampler); _march_sampler = RID()
if _march_pipeline.is_valid(): rd.free_rid(_march_pipeline); _march_pipeline = RID()
if _march_shader.is_valid(): rd.free_rid(_march_shader); _march_shader = RID()
if _comp_sampler.is_valid(): rd.free_rid(_comp_sampler); _comp_sampler = RID()
if _comp_pipeline.is_valid(): rd.free_rid(_comp_pipeline); _comp_pipeline = RID()
if _comp_shader.is_valid(): rd.free_rid(_comp_shader); _comp_shader = RID()
func _setup_shaders() -> bool:
rd = RenderingServer.get_rendering_device()
if rd == null:
return false
# Ray-march shader
var march_file := load("res://addons/nubis/shaders/cloud_raymarch.glsl") as RDShaderFile
if march_file == null:
push_error("[Nubis] Failed to load cloud_raymarch.glsl")
return false
var march_spirv := march_file.get_spirv()
if march_spirv == null:
push_error("[Nubis] Failed to get ray-march SPIR-V")
return false
_march_shader = rd.shader_create_from_spirv(march_spirv)
if not _march_shader.is_valid():
push_error("[Nubis] Failed to create ray-march shader")
return false
_march_pipeline = rd.compute_pipeline_create(_march_shader)
if not _march_pipeline.is_valid():
push_error("[Nubis] Failed to create ray-march pipeline")
return false
var sampler_state := RDSamplerState.new()
sampler_state.min_filter = RenderingDevice.SAMPLER_FILTER_LINEAR
sampler_state.mag_filter = RenderingDevice.SAMPLER_FILTER_LINEAR
sampler_state.mip_filter = RenderingDevice.SAMPLER_FILTER_LINEAR
_march_sampler = rd.sampler_create(sampler_state)
# Composite shader
var comp_file := load("res://addons/nubis/shaders/cloud_composite.glsl") as RDShaderFile
if comp_file == null:
push_error("[Nubis] Failed to load cloud_composite.glsl")
return false
var comp_spirv := comp_file.get_spirv()
if comp_spirv == null:
push_error("[Nubis] Failed to get composite SPIR-V")
return false
_comp_shader = rd.shader_create_from_spirv(comp_spirv)
if not _comp_shader.is_valid():
push_error("[Nubis] Failed to create composite shader")
return false
_comp_pipeline = rd.compute_pipeline_create(_comp_shader)
if not _comp_pipeline.is_valid():
push_error("[Nubis] Failed to create composite pipeline")
return false
var comp_sampler_state := RDSamplerState.new()
comp_sampler_state.min_filter = RenderingDevice.SAMPLER_FILTER_LINEAR
comp_sampler_state.mag_filter = RenderingDevice.SAMPLER_FILTER_LINEAR
_comp_sampler = rd.sampler_create(comp_sampler_state)
# Setup noise generator (textures generated on first render frame)
_noise_gen = NubisNoiseGenerator.new()
if not _noise_gen.setup(rd):
push_error("[Nubis] Failed to setup noise generator")
return false
print("[Nubis] Shaders compiled, noise pending")
return true
func _ensure_cloud_texture(size: Vector2i) -> void:
if _cloud_texture.is_valid() and _cloud_texture_size == size:
return
if _cloud_texture.is_valid():
rd.free_rid(_cloud_texture)
var fmt := RDTextureFormat.new()
fmt.format = RenderingDevice.DATA_FORMAT_R16G16B16A16_SFLOAT
fmt.width = size.x
fmt.height = size.y
fmt.depth = 1
fmt.array_layers = 1
fmt.mipmaps = 1
fmt.texture_type = RenderingDevice.TEXTURE_TYPE_2D
fmt.usage_bits = (
RenderingDevice.TEXTURE_USAGE_STORAGE_BIT |
RenderingDevice.TEXTURE_USAGE_SAMPLING_BIT
)
_cloud_texture = rd.texture_create(fmt, RDTextureView.new())
_cloud_texture_size = size
func _render_callback(p_effect_callback_type: int, p_render_data: RenderData) -> void:
if not enabled_setting:
return
if rd == null:
if not _setup_shaders():
return
# Frame 1: dispatch noise generation, skip ray-march.
# Frame 2+: noise is ready, do ray-march.
if not _noise_dispatched:
_noise_gen.generate()
_noise_dispatched = true
print("[Nubis] Noise textures dispatched, ready next frame")
return
if not _noise_ready:
_noise_ready = true
print("[Nubis] Noise textures ready, starting cloud rendering")
var render_scene_buffers := p_render_data.get_render_scene_buffers()
if render_scene_buffers == null:
return
var render_scene_data := p_render_data.get_render_scene_data()
if render_scene_data == null:
return
var size: Vector2i = render_scene_buffers.get_internal_size()
if size.x == 0 or size.y == 0:
return
_ensure_cloud_texture(size)
var depth_tex: RID = render_scene_buffers.get_texture("render_buffers", "depth")
if not depth_tex.is_valid():
return
var color_tex: RID = render_scene_buffers.get_texture("render_buffers", "color")
if not color_tex.is_valid():
return
var t0 := Time.get_ticks_usec()
var view_count: int = render_scene_buffers.get_view_count()
for view in view_count:
var cam_transform: Transform3D = render_scene_data.get_cam_transform()
var cam_pos: Vector3 = cam_transform.origin
# --- Pass 1: Ray-march ---
# Camera basis: transform local axes to world space (verified correct)
var cam_right: Vector3 = cam_transform.basis * Vector3(1, 0, 0)
var cam_up: Vector3 = cam_transform.basis * Vector3(0, 1, 0)
var cam_fwd: Vector3 = cam_transform.basis * Vector3(0, 0, -1)
var view_proj: Projection = render_scene_data.get_view_projection(view)
var proj := view_proj * Projection(cam_transform) # P = VP * V^-1
var half_fov_tan: float = 1.0 / proj.y.y # 1/P[1][1] = tan(fov/2)
var aspect: float = proj.y.y / proj.x.x # P[1][1]/P[0][0]
# 6 vec4s = 24 floats = 96 bytes
var push_data := PackedFloat32Array()
push_data.resize(24)
push_data[0] = cam_pos.x
push_data[1] = cam_pos.y
push_data[2] = cam_pos.z
push_data[3] = cloud_altitude_min
push_data[4] = -sun_direction.x
push_data[5] = -sun_direction.y
push_data[6] = -sun_direction.z
push_data[7] = cloud_altitude_max
push_data[8] = float(size.x)
push_data[9] = float(size.y)
push_data[10] = float(Time.get_ticks_msec()) / 1000.0
push_data[11] = weather
push_data[12] = cam_right.x
push_data[13] = cam_right.y
push_data[14] = cam_right.z
push_data[15] = half_fov_tan
push_data[16] = cam_up.x
push_data[17] = cam_up.y
push_data[18] = cam_up.z
push_data[19] = aspect
push_data[20] = cam_fwd.x
push_data[21] = cam_fwd.y
push_data[22] = cam_fwd.z
push_data[23] = 0.0
var push_bytes := push_data.to_byte_array()
# Uniforms: depth texture + cloud output image
var depth_uniform := RDUniform.new()
depth_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
depth_uniform.binding = 0
depth_uniform.add_id(_march_sampler)
depth_uniform.add_id(depth_tex)
var cloud_uniform := RDUniform.new()
cloud_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
cloud_uniform.binding = 1
cloud_uniform.add_id(_cloud_texture)
var uniform_set_0 := UniformSetCacheRD.get_cache(_march_shader, 0, [depth_uniform, cloud_uniform])
# Set 1: 3D noise textures
var pw_uniform := RDUniform.new()
pw_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
pw_uniform.binding = 0
pw_uniform.add_id(_noise_gen.noise_sampler)
pw_uniform.add_id(_noise_gen.perlin_worley_texture)
var detail_uniform := RDUniform.new()
detail_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
detail_uniform.binding = 1
detail_uniform.add_id(_noise_gen.noise_sampler)
detail_uniform.add_id(_noise_gen.detail_texture)
var uniform_set_1 := UniformSetCacheRD.get_cache(_march_shader, 1, [pw_uniform, detail_uniform])
var groups_x := ceili(float(size.x) / 8.0)
var groups_y := ceili(float(size.y) / 8.0)
var compute_list := rd.compute_list_begin()
rd.compute_list_bind_compute_pipeline(compute_list, _march_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_bytes, push_bytes.size())
rd.compute_list_dispatch(compute_list, groups_x, groups_y, 1)
rd.compute_list_end()
# --- Pass 2: Composite clouds over scene ---
_composite_clouds(color_tex, depth_tex, size)
var dt := Time.get_ticks_usec() - t0
_timing_sum_usec += dt
_timing_count += 1
if _timing_count >= 60:
var avg_ms: float = (_timing_sum_usec / _timing_count) / 1000.0
print("[Nubis] avg %.2fms" % avg_ms)
_timing_sum_usec = 0.0
_timing_count = 0
func _composite_clouds(color_tex: RID, depth_tex: RID, size: Vector2i) -> void:
var cloud_sample := RDUniform.new()
cloud_sample.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
cloud_sample.binding = 0
cloud_sample.add_id(_comp_sampler)
cloud_sample.add_id(_cloud_texture)
var depth_sample := RDUniform.new()
depth_sample.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
depth_sample.binding = 1
depth_sample.add_id(_comp_sampler)
depth_sample.add_id(depth_tex)
var color_uniform := RDUniform.new()
color_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
color_uniform.binding = 2
color_uniform.add_id(color_tex)
var uniform_set := UniformSetCacheRD.get_cache(_comp_shader, 0, [cloud_sample, depth_sample, color_uniform])
var push_data := PackedFloat32Array()
push_data.resize(4)
push_data[0] = float(size.x)
push_data[1] = float(size.y)
push_data[2] = 1.0 if debug_view else 0.0
push_data[3] = 0.0
var push_bytes := push_data.to_byte_array()
var groups_x := ceili(float(size.x) / 8.0)
var groups_y := ceili(float(size.y) / 8.0)
var compute_list := rd.compute_list_begin()
rd.compute_list_bind_compute_pipeline(compute_list, _comp_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_x, groups_y, 1)
rd.compute_list_end()