#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