OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / src / gpu / gpu_compiler.cpp

585 lines · cpp ref:
#include "gpu_compiler.h"
#include "erosion_filter_glsl.gen.h"
#include "hypso_curve_glsl.gen.h"
#include "proc_simplex_glsl.gen.h"
#include <godot_cpp/classes/rd_shader_source.hpp>
#include <godot_cpp/classes/rd_shader_spirv.hpp>
#include "../graph/proc_node_graph.h"
#include <godot_cpp/variant/utility_functions.hpp>

namespace godot {

// --- Noise library ---

String GpuCompiler::get_erosion_library_glsl() {
	// THE HAND-WRITTEN NOISE LIBRARY THAT USED TO BE HERE IS GONE.
	//
	// ~366 lines of GLSL: an Ashima simplex, a Jenkins hash, a Perlin with its
	// five fractal variants, a Voronoi and four musgrave forms. Every one of
	// them was a SECOND implementation of something proc-nodes also had in
	// C++, and nothing compared any pair. Two of them were live -- noise_fbm
	// for the Simplex Noise node and perlin_fbm for Displace -- and the rest
	// had no caller at all.
	//
	// Both live ones now emit calls into src/shared/*.inc, the same text their
	// execute() runs, and they bring it with them through
	// compile_glsl_prelude(). The deletion is a relocation and not a change:
	// the C++ that was deleted alongside this agreed with the .inc replacing
	// it on 660000/660000 comparisons for Perlin and 240000/240000 for
	// simplex, bit-for-bit.
	//
	// What remains below is the erosion filter and its wrapper globals, which
	// are already one text (src/shared/erosion_filter.inc) and are injected
	// wholesale for the erosion family.
	return String()
		// THE erosion filter, generated from src/shared/erosion_filter.inc by
		// tools/gen_erosion_glsl.py. It used to be inlined in the literal above,
		// and dc-terrain carried a hand-written C++ transcription of it that had
		// drifted 60x in gully frequency and amplitude — so the surface this
		// shader meshed and the surface collision reported were different
		// functions. One text now, compiled as GLSL here and as C++ there.
		+ String(EROSION_FILTER_GLSL)
		// The land/sea transfer curve. Beside the filter rather than inside it:
		// it is not erosion, it runs BEFORE erosion on the mountain layer, and
		// the wrapper is its only caller. Free when unused -- an uncalled GLSL
		// function costs nothing after dead-code elimination.
		+ String(HYPSO_CURVE_GLSL)
		+ String(PROC_SIMPLEX_GLSL)
		// Wrapper-supplied inputs to ErosionNoiseNode, declared here so a shader
		// that never sets them still compiles and still behaves exactly as before
		// (zero offset, runevision's 0.6 divisor). Same pattern as pn_max_octaves.
		+ String("\nvec3 pn_terrain_offset = vec3(0.0);\nfloat pn_fade_div = 0.6;\n"
		         "float pn_erosion_scale = 0.15;\n")
		// ── THE ONE THAT GOES THE OTHER WAY ────────────────────────────────
		//
		// Every other pn_* global above is WRAPPER -> NODE. This one is
		// NODE -> WRAPPER: ErosionNoiseNode writes the filter's ridge channel
		// into it, and a caller that wants ridge reads it after calling
		// proc_density.
		//
		// WHY A GLOBAL AND NOT A RETURN VALUE. compile_to_glsl_function fixes
		// the signature at `float f(vec3 pos)`, so the density function has
		// exactly one float of egress and it is spent on density. ridge is
		// computed on every sample regardless (erosion_filter.inc:490, "Free;
		// drives materials") and was reachable only from inside the density
		// shader -- AltitudeColorNode reads the same value as `en_erosion`.
		// This is the door out for a caller that is NOT a node.
		//
		// ⚠ WRITE-ONLY FROM THE GPU PATH. ProcNodesBridge::evaluate is the CPU
		// twin and does not set this; anything reading it must be GPU-side, or
		// it reads 0.0 and gets a plausible-looking flat field. The density
		// itself is untouched -- this assigns a global and nothing else, so
		// test_erosion_parity stays bit-identical.
		+ String("float pn_out_ridge = 0.0;\n")
		+ String("vec2 pn_out_grad = vec2(0.0);\n")
		// EROSION IS A SUBAERIAL PROCESS. Rain and rivers carve land; a seabed
		// is shaped by the base topology and deposition, not by gullies. The
		// wrapper sets this from the column's depth and the erosion node scales
		// its strength by it -- at 0 erosion_filter returns its input unchanged
		// (delta and magnitude both vanish), so the seabed is the mountain and
		// hypso curve alone, with the submarine term supplying its own relief.
		+ String("float pn_erosion_mask = 1.0;\n")
		// EVERY REMAINING FILTER KNOB, as a wrapper-settable global.
		//
		// These were baked literals inside erosion_node_eval because
		// GpuCompiler's push-constant block is param0..param6 -- seven floats,
		// a hard cap -- and rounding and onset alone need eight. That cap
		// applies to SOCKET parameters; it never applied to globals, which is
		// how pn_fade_div and pn_erosion_scale already escaped it. So the whole
		// filter is tunable through the same door, with no second uniform
		// buffer and no change to the node's socket count.
		//
		// The defaults here are runevision's reference values, so a wrapper
		// that sets none of them produces exactly the previous surface.
		+ String(
			"float pn_ero_input_height_amplitude = 0.25;\n"
			"float pn_ero_octave_lacunarity = 2.0;\n"
			"float pn_ero_gully_depth = 0.5;\n"
			// LOWER restricts high-frequency gullies to steeper slopes.
			"float pn_ero_fine_gully_slope_restriction = 1.5;\n"
			// Only gullies above this are normalised -- what keeps peaks pointy.
			"float pn_ero_gully_normalize_threshold = 0.5;\n"
			"float pn_ero_height_offset = -0.65;\n"
			"float pn_ero_height_offset_follows_altitude = 0.0;\n"
			"float pn_ero_crease_rounding = 0.1;\n"
			"float pn_ero_ridge_rounding = 0.0;\n"
			// The INPUT feature-size control runevision singles out. It has
			// been pinned at 0.1 while terrain_height went 20 km -> 60 km.
			"float pn_ero_input_feature_rounding = 0.1;\n"
			"float pn_ero_rounding_per_octave = 2.0;\n"
			"float pn_ero_gully_onset_slope = 1.25;\n"
			"float pn_ero_octave_onset_slope = 1.25;\n"
			"float pn_ero_ridge_onset_slope = 2.8;\n"
			"float pn_ero_ridge_octave_onset = 1.5;\n"
			"float pn_ero_assumed_slope = 0.7;\n"
			// 0 = use the input's real slope, 1 = fully assumed. We ship 1.0,
			// which discards slope MAGNITUDE from the gully direction entirely.
			"float pn_ero_assumed_slope_mix = 1.0;\n"
			// FRACTIONAL OCTAVE LIMIT, set per chunk from the voxel size.
			//
			// The integer pn_max_octaves cannot be used for this: truncating the
			// loop moves the surface bodily (measured 20.4 m for 7->5 octaves,
			// 48.1 m for 7->4, on map_dc_diffusion's settings), because each
			// octave sets the fade target and mask for the next and because the
			// -0.65 * magnitude offset shrinks with the octave sum. That is why
			// pn_max_octaves is pinned at 99.
			//
			// This one weights the last PARTIAL octave instead, so the same range
			// is continuous: measured linear within each octave, halving at each
			// boundary, no jump at the integers. 999 = no taper.
			"float pn_ero_max_octaves = 999.0;\n");
}

// --- Shader template ---

String GpuCompiler::get_shader_template() {
	return String(R"(#version 450

layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;

layout(push_constant) uniform PushConstants {
    uint element_count;
    float param0;
    float param1;
    float param2;
    float param3;
    float param4;
    float param5;
    float param6;
} pc;

// Set 0: Input buffers (read-only)
layout(set = 0, binding = 0) restrict readonly buffer InputPositions {
    vec4 data[];
} in_positions;

layout(set = 0, binding = 1) restrict readonly buffer InputNormals {
    vec4 data[];
} in_normals;

// Set 1: Output buffers (write-only)
layout(set = 1, binding = 0) restrict writeonly buffer OutputPositions {
    vec4 data[];
} out_positions;

layout(set = 1, binding = 1) restrict writeonly buffer OutputNormals {
    vec4 data[];
} out_normals;

NOISE_LIBRARY_PLACEHOLDER

NODE_CODE_PLACEHOLDER

void main() {
    uint idx = gl_GlobalInvocationID.x;
    if (idx >= pc.element_count) return;

    vec3 pos = in_positions.data[idx].xyz;
    vec3 normal = in_normals.data[idx].xyz;

    MAIN_BODY_PLACEHOLDER

    out_positions.data[idx] = vec4(pos, 0.0);
    out_normals.data[idx] = vec4(normal, 0.0);
}
)");
}

// --- Compile batch ---

String GpuCompiler::compile_batch(
		const Vector<Ref<ProcNode>> &p_nodes,
		const Vector<int> &p_node_indices,
		const ProcNodeGraph *p_graph) {
	// The batch path does not have pn_max_octaves, so integer parameters are
	// emitted as plain literals here.
	GlslEmitContext ctx;
	ctx.clamp_octaves = false;
	// This path HAS the push-constant block the template declares, and
	// GraphExecutor refills it on every dispatch. See GlslEmitContext.
	ctx.push_constants = true;
	ctx.graph = p_graph;
	// ⛔ EMITTED, NOT MERELY LISTED, AND ONLY IF ALREADY WRITTEN.
	//
	// This was set to the whole batch up front, so param() would resolve a
	// socket to `nX_sY` for a producer that had not emitted yet -- or had
	// emitted NOTHING, because compile_glsl returned empty and the loop below
	// skips it. The consumer then referenced a local that does not exist:
	//
	//     ERROR: 0:46: 'n30_s0' : undeclared identifier
	//     ProcNodes: GPU pipeline creation failed for SetPosition
	//
	// The list is built as the loop goes, so a node can only name a producer
	// that has ALREADY put its declaration in the body above it. Execution
	// order is topological, so a real producer is always there first; anything
	// missing falls back to the push constant, which is what it did before.
	ctx.emitted_nodes.clear();

	String main_body;
	String preludes;
	bool needs_noise = false;

	for (int i = 0; i < p_nodes.size(); i++) {
		Ref<ProcNode> node = p_nodes[i];
		ERR_FAIL_COND_V(node.is_null(), String());

		// p_node_indices[i] is the graph-level index (for compile_glsl context)
		int graph_idx = (i < p_node_indices.size()) ? p_node_indices[i] : i;
		String glsl = node->compile_glsl(ctx, graph_idx);
		if (glsl.is_empty() && node->gpu_transparent()) {
			// A boundary node with nothing to declare. Not the lie below.
			continue;
		}
		if (glsl.is_empty()) {
			continue;
		}

		// A node that needs shared source now SAYS SO, the way the density
		// path has always worked -- rather than this function sniffing the
		// emitted text for names. The substring list below it is what remains
		// for the erosion family, which is still injected wholesale.
		String pre = node->compile_glsl_prelude(graph_idx);
		if (!pre.is_empty() && preludes.find(pre) == -1) {
			preludes += pre;
		}

		if (glsl.find("erosion_") != -1 || glsl.find("grad_noised") != -1 ||
				glsl.find("fractal_noise_2d") != -1 || glsl.find("gullies_2d") != -1 ||
				glsl.find("magnitude_sum") != -1) {
			needs_noise = true;
		}

		main_body += "    // Node " + itos(graph_idx) + ": " + node->get_node_title() + "\n";
		main_body += "    " + glsl + "\n";
		ctx.emitted_nodes.push_back(graph_idx);
	}

	String shader = get_shader_template();
	// Preludes first: each is #ifndef-guarded, so a second copy is dropped by
	// the preprocessor rather than by anyone reasoning about ordering.
	shader = shader.replace("NOISE_LIBRARY_PLACEHOLDER",
			preludes + (needs_noise ? get_erosion_library_glsl() : String()));
	shader = shader.replace("NODE_CODE_PLACEHOLDER", "");
	shader = shader.replace("MAIN_BODY_PLACEHOLDER", main_body);

	return shader;
}

// --- Compile to standalone function ---

String GpuCompiler::compile_to_function(
		const Vector<Ref<ProcNode>> &p_nodes,
		const Vector<int> &p_node_indices,
		const String &p_function_name,
		bool p_clamp_octaves,
		const ProcNodeGraph *p_graph) {
	GlslEmitContext ctx;
	ctx.clamp_octaves = p_clamp_octaves;
	ctx.graph = p_graph;

	// Collect GLSL snippets from nodes
	String body;
	bool needs_noise = false;

	for (int i = 0; i < p_nodes.size(); i++) {
		Ref<ProcNode> node = p_nodes[i];
		if (node.is_null()) {
			continue;
		}

		int graph_idx = (i < p_node_indices.size()) ? p_node_indices[i] : i;
		String glsl = node->compile_glsl(ctx, graph_idx);
		if (glsl.is_empty()) {
			// A NODE THAT CLAIMS GPU SUPPORT AND EMITS NOTHING IS THE SAME LIE
			// one level down, and it survived step 1's refusal because that
			// only asked supports_gpu(). Measured on a bridged graph: Value,
			// Vector, Integer, Boolean and Color all claimed support with no
			// compile_glsl, so proc_density returned `n2_s0` -- a variable
			// nothing declared, in a shader that could not compile.
			//
			// The batch path still skips, deliberately: there a node absent
			// from the batch falls back to the CPU. Here there is no fallback.
			UtilityFunctions::push_error(
					"proc-nodes: node ", graph_idx, " (", node->get_node_title(),
					") reports GPU support but emitted no GLSL. Emission refused "
					"rather than leaving a variable nothing declares.");
			return String();
		}

		if (glsl.find("noise_fbm") != -1 || glsl.find("simplex_noise_3d") != -1 ||
				glsl.find("noise_voronoi") != -1 || glsl.find("noise_musgrave") != -1 ||
				glsl.find("perlin_") != -1 || glsl.find("erosion_") != -1 ||
				glsl.find("grad_noised") != -1 || glsl.find("fractal_noise_2d") != -1 ||
				glsl.find("gullies_2d") != -1 || glsl.find("magnitude_sum") != -1) {
			needs_noise = true;
		}

		// ⛔ THE TEXTUAL pc.paramN SUBSTITUTION BELOW IS A LEGACY FALLBACK AND
		// MUST NOT BE REACHED BY A NODE THAT IS STILL MAINTAINED.
		//
		// It replaces `pc.paramN` with SOCKET N's default, which is a different
		// convention from the batch path's, where N is a push-constant SLOT.
		// Every node that used both conventions was silently wrong here:
		// Displace shifted every argument by one socket and multiplied its
		// displacement by 0.0, Set Position emitted `pos += vec3(0,0,0)`, and
		// Simplex Noise left `pc.param3` verbatim in a function with no `pc`.
		// All three now emit through GlslEmitContext::param()/in() and never
		// reach this loop. It is kept only so a node nobody has migrated
		// degrades the way it always did rather than emitting a dangling `pc.`,
		// and glsl_golden_gate.gd asserts that no GPU-capable node leaves one.
		for (int s = 0; s < node->get_input_count() && s < 7; s++) {
			const SocketDef &def = node->get_input_def(s);
			String param_name = "pc.param" + itos(s);
			String literal;

			if (def.type == SocketType::Float) {
				float v = def.default_value;
				// NINE digits, not four. Four decimals is fine for a frequency or
				// a gain and destroys anything small: a y-scale of 5/21000 =
				// 0.000238 truncated to 0.0002 is a 16% error in the vertical
				// mapping, which moved the terrain surface ~1770 m and read as
				// "the spawn is super high". Socket defaults are baked as
				// literals, so the precision here is the precision the shader gets.
				literal = String::num(v, 9);
			} else if (def.type == SocketType::Int) {
				int v = def.default_value;
				String int_literal = itos(v);
				// When clamp_octaves is enabled, wrap integer params (octave/detail counts)
				// with min(N, pn_max_octaves) so external callers can enforce Nyquist limits.
				if (p_clamp_octaves) {
					literal = "min(" + int_literal + ", pn_max_octaves)";
				} else {
					literal = int_literal + ".0";
				}
			} else {
				literal = "0.0";
			}

			glsl = glsl.replace(param_name, literal);
		}

		body += "    // Node " + itos(graph_idx) + ": " + node->get_node_title() + "\n";
		body += "    " + glsl + "\n";
	}

	// Node preludes: constants, helpers, anything that is not a statement.
	// Emitted at FILE SCOPE, before the function, because splicing a function
	// definition into another function's body is not valid GLSL and fails the
	// whole shader.
	String preludes;
	for (int i = 0; i < p_nodes.size(); i++) {
		Ref<ProcNode> node = p_nodes[i];
		if (node.is_null()) continue;
		int graph_idx = (i < p_node_indices.size()) ? p_node_indices[i] : i;
		String pre = node->compile_glsl_prelude(graph_idx);
		// DEDUPLICATED HERE, not by the node. A node that emitted its helpers
		// only when its index happened to be 0 emitted NOTHING in a graph where
		// it sat anywhere else, and the shader then failed to compile on an
		// undefined function -- a whole-map outage decided by a node's position
		// in a list.
		if (!pre.is_empty() && preludes.find(pre) == -1) {
			preludes += pre + "\n";
		}
	}

	// Build standalone function
	String result;

	// Include noise library if needed
	if (needs_noise) {
		result += get_erosion_library_glsl();
		result += "\n";
	}
	result += preludes;

	// Generate function: takes world position, returns density float
	// The node GLSL snippets operate on `pos` (vec3) — displacement modifies pos.
	// For SDF/density convention: density = ground_height - pos.y
	// After displacement, pos.y contains the displaced height, so we use that.
	result += "float " + p_function_name + "(vec3 pos) {\n";
	// ⛔ THE FRAME, WHICH THE EMITTER USED TO IGNORE ENTIRELY.
	//
	// GraphExecutor converts a bridged graph's result out of Blender's frame at
	// the end (_to_godot_frame, gated on is_blender_frame). The emitter had
	// never heard of that flag -- `grep is_blender_frame src/gpu/` was empty --
	// so the SAME graph was one field on the CPU and another on the GPU.
	//
	// For a heightfield authored in Blender the difference is not subtle. A
	// Blender graph displaces along Z, so the emitted tail read `pos.y -
	// query_y` on an axis nothing had touched: measured on the bridged voronoi
	// mountain, 0/100 sample points agreed and the emitted density was
	// IDENTICALLY ZERO while the CPU returned a real mountain
	// (tests/test_emitted_density_parity.cpp).
	//
	// The local stays named query_y in BOTH branches on purpose. Renaming it
	// changed the emitted text of every Godot-frame graph -- the shipping
	// ones -- for no behavioural reason, and glsl_golden_gate went red on two
	// assertions that are spellings of a return statement. A frame fix that
	// rewrites the shader for graphs it does not apply to is a bigger change
	// than it looks.
	//
	// M = (x, y, z) -> (-x, z, y), the same conversion, self-inverse, applied
	// to the query point on the way IN so every node sees the coordinates the
	// CPU gives it. The density is then the displacement along BLENDER's up,
	// which is exactly Godot's y displacement after M -- M(q).y == q.z -- so
	// the consumer's convention does not change.
	const bool blender_frame = (p_graph != nullptr && p_graph->is_blender_frame());
	if (blender_frame) {
		result += "    // Bridged from Blender: M maps the query point into the frame\n";
		result += "    // the graph was authored in. M is self-inverse.\n";
		result += "    pos = vec3(-pos.x, pos.z, pos.y);\n";
		result += "    float query_y = pos.z; // Blender's up, read by the return below\n";
	} else {
		result += "    float query_y = pos.y; // Save original query height before nodes modify pos\n";
	}
	result += "    vec3 normal = vec3(0.0, 1.0, 0.0);\n";
	result += body;
	// WHICH VALUE THIS FUNCTION RETURNS, chosen in a fixed order and never by a
	// heuristic. docs/glsl-emitter-design.md has the reasoning; the short of it
	// is that a volume graph used to compute a density and have it thrown away
	// by the displacement return, so it compiled and rendered nothing.
	String ret;

	// 1. EXPLICIT: a Group Output with a connected float input is the graph
	//    saying what it produces.
	//    ONE rule, ONE implementation. This used to be a second copy of the loop
	//    in proc_node_graph.cpp, and a mutation to that one left this one
	//    answering the old way -- two texts agreeing by review.
	if (p_graph != nullptr) {
		int out_node = -1, out_socket = -1;
		if (p_graph->find_density_output(out_node, out_socket)) {
			ret = ctx.in(out_node, out_socket);
		}
	}

	// 2. DISPLACEMENT, the default: a node carrying geometry in and out is the
	//    chain that moves `pos`, and the density is how far it moved.
	if (ret.is_empty()) {
		for (int i = 0; i < p_nodes.size(); i++) {
			if (p_nodes[i].is_null()) {
				continue;
			}
			bool geo_in = false, geo_out = false;
			for (int s = 0; s < p_nodes[i]->get_input_count(); s++) {
				if (p_nodes[i]->get_input_def(s).type == SocketType::Geometry) geo_in = true;
			}
			for (int s = 0; s < p_nodes[i]->get_output_count(); s++) {
				if (p_nodes[i]->get_output_def(s).type == SocketType::Geometry) geo_out = true;
			}
			if (geo_in && geo_out) {
				ret = blender_frame ? "pos.z - query_y" : "pos.y - query_y";
				break;
			}
		}
	}

	// 3. NO GEOMETRY CHAIN AND NO EXPLICIT OUTPUT. This is NOT an error, and
	//    asserting that it was is what this gate caught: dc-terrain skips the
	//    Erosion Noise node when the erosion toggle is off, leaving a graph with
	//    no geometry chain on purpose -- proc_density contributes nothing and
	//    the wrapper's own mountain, relief and detail layers do the work.
	//    Refusing there would have broken the toggle.
	//
	//    So the displacement stays the fallback and the graph is told about it
	//    instead. A volume graph whose density is discarded and a deliberately
	//    empty one are indistinguishable WITHOUT an explicit output, which is
	//    the argument for rule 1 rather than for a cleverer guess here.
	if (ret.is_empty()) {
		UtilityFunctions::push_warning(
				"proc-nodes: no geometry chain and no Group Output float, so "
				"proc_density returns the displacement of `pos` -- zero unless a "
				"node moved it. A graph computing a density value needs a Group "
				"Output to return it.");
		ret = blender_frame ? "pos.z - query_y" : "pos.y - query_y";
	}

	// The comment has to follow the BRANCH. It said "terrain_height - query_y"
	// unconditionally, including over a `return n2_s0;` that is a Group Output
	// float -- so a volume graph's emitted text described itself as a
	// displacement. A stale comment on the one line that says what the shader
	// computes is worth more than a stale comment anywhere else.
	if (ret == "pos.y - query_y" || ret == "pos.z - query_y") {
		result += "    // SDF: terrain_height - query_y. Positive below surface, negative above.\n";
	} else {
		result += "    // The graph's OWN density, from its Group Output float.\n";
	}
	result += "    return " + ret + ";\n";
	result += "}\n";

	return result;
}

// --- Create pipeline ---

bool GpuCompiler::create_pipeline(
		RenderingDevice *p_rd,
		const String &p_glsl_source,
		GpuBatch &r_batch) {
	ERR_FAIL_NULL_V(p_rd, false);

	Ref<RDShaderSource> shader_src;
	shader_src.instantiate();
	shader_src->set_stage_source(RenderingDevice::SHADER_STAGE_COMPUTE, p_glsl_source);

	Ref<RDShaderSPIRV> spirv = p_rd->shader_compile_spirv_from_source(shader_src);
	if (spirv.is_null()) {
		UtilityFunctions::push_error("GpuCompiler: Failed to compile SPIR-V from source.");
		return false;
	}

	String err = spirv->get_stage_compile_error(RenderingDevice::SHADER_STAGE_COMPUTE);
	if (!err.is_empty()) {
		UtilityFunctions::push_error("GpuCompiler: Shader compile error: ", err);
		// ⛔ PRINT THE TEXT THAT FAILED. A compile error names a line in a
		// shader nobody can see, and "'n30_s0' : undeclared identifier" is
		// unanswerable without the body -- which node emitted, which did not,
		// and what it named. The emitter is the only thing that knows.
		const PackedStringArray lines = p_glsl_source.split("\n");
		String dump;
		for (int i = 0; i < lines.size(); i++) {
			dump += String::num_int64(i + 1) + ": " + lines[i] + "\n";
		}
		UtilityFunctions::push_error("GpuCompiler: the shader that failed:\n", dump);
		return false;
	}

	r_batch.shader = p_rd->shader_create_from_spirv(spirv);
	if (!r_batch.shader.is_valid()) {
		UtilityFunctions::push_error("GpuCompiler: Failed to create shader from SPIR-V.");
		return false;
	}

	r_batch.pipeline = p_rd->compute_pipeline_create(r_batch.shader);
	if (!r_batch.pipeline.is_valid()) {
		UtilityFunctions::push_error("GpuCompiler: Failed to create compute pipeline.");
		p_rd->free_rid(r_batch.shader);
		r_batch.shader = RID();
		return false;
	}

	r_batch.compiled_glsl = p_glsl_source;
	return true;
}

// --- Destroy pipeline ---

void GpuCompiler::destroy_pipeline(RenderingDevice *p_rd, GpuBatch &p_batch) {
	if (p_rd == nullptr) {
		return;
	}

	if (p_batch.pipeline.is_valid()) {
		p_rd->free_rid(p_batch.pipeline);
		p_batch.pipeline = RID();
	}

	if (p_batch.shader.is_valid()) {
		p_rd->free_rid(p_batch.shader);
		p_batch.shader = RID();
	}

	p_batch.compiled_glsl = "";
}

} // namespace godot