OverviewFilesCommitsIssuesDocsReleasesPeers

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

318 lines · gd ref:
@tool
class_name NubisCompositor
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_enum("Normal", "Sun Optical", "Beer Trans", "SDF Profile", "Density", "Beer-Powder") var debug_mode: int = 0
@export var shader_file: String = "res://addons/nubis_clouds/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_clouds/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_clouds/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_clouds/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 sun_col: Color = vol.get("sun_color", Color(1.0, 0.95, 0.85, 1.0))
			var moon: Vector3 = vol.get("moon_dir", Vector3(-0.5, 0.3, -0.7)).normalized()
			var moon_col: Color = vol.get("moon_color", Color(0.5, 0.55, 0.7, 0.0))
			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,
				sun_col.r, sun_col.g, sun_col.b, sun_col.a,
				moon.x, moon.y, moon.z, float(debug_mode),
				moon_col.r, moon_col.g, moon_col.b, moon_col.a,
			])

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