OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / addons / nubis / nubis_effect.gd

333 lines · gd ref:
@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()