OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / ac43b85

fog: a volume graph renders as a FogVolume, and compiling one found two bugs
ProcVolume::build_fog_shader_code() wraps the graph's emitted proc_density in a `shader_type fog` shader; make_fog_material() returns it as a ShaderMaterial, so a scene is two lines: fog_volume.material = volume.make_fog_material() fog_volume.size = Vector3(64, 64, 64) DENSITY comes from clamp((proc_density(WORLD_POSITION - volume_origin) - iso_level) / edge_softness, 0, 1) * density_scale, with albedo and emission as uniforms. The volume is evaluated in its OWN space, so moving the FogVolume moves the cloud without recompiling. This is dc-terrain-nubis Layer 3 -- the artist-placed local-effects layer -- rendered by the engine's own froxel pass, no custom compute pipeline. --- CAN A FogVolume TAKE RAW GLSL? NO, and the answer matters. A FogVolume takes a Material. `shader_type fog` is compiled by GODOT's shader compiler from GODOT SHADING LANGUAGE, which is GLSL-like and is not GLSL 450. Raw GLSL in this stack goes elsewhere: dc-terrain's RenderingDevice compute path (gpu_shader_source -> glslc), which is where a Nubis-style custom raymarcher would live too. So the same emitted proc_density has to be valid in BOTH languages, and "mostly is" is not a thing a shader compiler accepts. MEASURED on a real vulkan driver -- demo/fog_shader_gate.gd, which must NOT be run headless because the dummy driver never compiles a shader and would report success for anything: sdf 1106 chars COMPILES simplex 7112 chars COMPILES erosion 44921 chars FAILS --- THE FIRST RUN FOUND TWO BUGS, and only one was a language difference. ⛔ `Unknown identifier in expression: 'n1_s0'`. NoiseTextureNode's compile_glsl assigned a hardcoded local `noise_val` and NEVER ASSIGNED ITS OUTPUT SOCKET, so any consumer resolving its Value through GlslEmitContext::out() read a name nothing declares. Not a fog problem -- it breaks the density path identically; no graph had ever consumed the node's output, so nothing had noticed. It declares p_ctx.declare(idx, 0) now. Its locals were fixed names too, so two Simplex Noise nodes in one graph redefined `nsp`. Same for Displace's `npos` and `n`. Both are per-node now (n<idx>_nsp, n<idx>_npos, n<idx>_nval) and the gate asserts two Displace nodes declare distinct locals. ⚠ `Redefinition of 'TAU'`. Godot shading language RESERVES TAU as a builtin; GLSL 450 does not, and erosion_filter.inc declared its own inside phacelle_noise. Renamed PN_TAU. The golden shader moved by exactly that rename and its comment -- 9 diff lines, all in that block, checked before regenerating, because regenerating a golden is how a regression hides. --- WHAT IS LEFT, structural rather than a bug. get_erosion_library_glsl declares ~18 MUTABLE GLOBALS (pn_ero_gully_depth and friends) so a wrapper can set them per chunk. GLSL 450 allows that; Godot shading language does not -- "Global non-constant variables are not supported". `const` would break the wrapper; `uniform` would work AND put them on the fog material's inspector, which is arguably where a cloud's knobs belong. Not done: a cloud does not use the erosion filter, so this blocks nothing the feature is for. glsl_golden_gate 29/29 -> 30/30, shared-noise parity 6445/6445, emitted-op parity 770/770, all three targets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ac43b851f7628c09155ecd6e32affcf6146c5325
by plankatron <fleeky@gmail.com> · 2026-09-06T15:40:51.000Z

Changed files (7) +235 -16

demo/fog_shader_gate.gd +98 -0
……@@ -0,0 +1,98 @@
1## Does a proc-nodes volume actually compile as a Godot FOG shader?
2##
3## ⚠ THIS GATE MUST NOT RUN HEADLESS. --headless uses the dummy rendering
4## driver, which never compiles a shader, so "no SHADER ERROR" there means
5## nothing at all. It is run on a real driver (DISPLAY=:0) and the runner greps
6## stderr for SHADER ERROR.
7##
8## The question it answers: our proc_density is emitted for GLSL 450 -- the same
9## text dc-terrain hands glslc -- and a FogVolume needs GODOT SHADING LANGUAGE.
10## They overlap; they are not the same. Which graphs land in the intersection is
11## a measurement, not a guess.
12##
13## MEASURED 2026-09-06, on a real vulkan driver:
14##
15## sdf 1106 chars COMPILES
16## simplex 7112 chars COMPILES
17## erosion 44921 chars FAILS -- "Global non-constant variables are not
18## supported. Expected 'const' keyword"
19##
20## The first run found two bugs that were NOT language differences:
21##
22## * `Unknown identifier: 'n1_s0'` -- NoiseTextureNode assigned a hardcoded
23## local `noise_val` and never assigned its OUTPUT SOCKET, so any consumer
24## resolving it through GlslEmitContext::out() read a name nothing declared.
25## That broke the density path too; nothing had consumed the node.
26## * `Redefinition of 'TAU'` -- Godot RESERVES TAU as a builtin. The .inc
27## declared its own. Renamed PN_TAU.
28##
29## WHAT IS LEFT, and it is structural rather than a bug: get_erosion_library_glsl
30## declares ~18 MUTABLE GLOBALS (pn_ero_gully_depth and friends) so a wrapper can
31## set them per chunk. GLSL 450 allows that; Godot shading language does not.
32## Making them `const` would break the wrapper; making them `uniform` for the fog
33## path would work AND put them on the material's inspector, which is arguably
34## where a cloud's knobs belong. Not done -- a cloud does not use the erosion
35## filter, so this blocks nothing the feature is for.
36extends SceneTree
37
38var _built := false
39var _frames := 0
40
41func _volume(kind: String) -> ProcVolume:
42 var g := ProcNodeGraph.new()
43 var density := -1
44 if kind == "sdf":
45 var s1 := g.add_node(SphereSdfNode.new())
46 var s2 := g.add_node(SphereSdfNode.new())
47 var bo := g.add_node(VolumeBooleanNode.new())
48 g.connect_sockets(s1, 0, bo, 0)
49 g.connect_sockets(s2, 0, bo, 1)
50 density = bo
51 elif kind == "simplex":
52 # A cloud, roughly: a sphere carved by noise. This is the case that
53 # pulls a PRELUDE -- proc_simplex.inc, with its #ifndef guard.
54 var sp := g.add_node(SphereSdfNode.new())
55 var nz := g.add_node(NoiseTextureNode.new())
56 var bo := g.add_node(VolumeBooleanNode.new())
57 g.get_node(bo).set("operation", 2) # subtract
58 g.connect_sockets(sp, 0, bo, 0)
59 g.connect_sockets(nz, 0, bo, 1)
60 density = bo
61 elif kind == "erosion":
62 # The case predicted to FAIL: the erosion library declares mutable
63 # globals at file scope, which Godot shading language rejects.
64 var gn := g.add_node(GradientNoiseNode.new())
65 var en := g.add_node(ErosionNoiseNode.new())
66 g.connect_sockets(gn, 0, en, 7)
67 g.connect_sockets(gn, 1, en, 8)
68 density = en
69 var vtm := g.add_node(VolumeToMeshNode.new())
70 g.connect_sockets(density, 0, vtm, 0)
71 # evaluate_socket runs the graph, so this goes through the real execute()
72 # that fills the ProcVolume -- not a side door built for the test.
73 var geo = g.evaluate_socket(vtm, 0)
74 if geo == null or not (geo is ProcGeometry):
75 return null
76 return geo.get_volume()
77
78func _process(_d: float) -> bool:
79 if not _built:
80 _built = true
81 for kind in ["sdf", "simplex", "erosion"]:
82 var vol := _volume(kind)
83 if vol == null or vol.get_glsl_source().is_empty():
84 print(" %-10s NO VOLUME (graph did not compile to GLSL)" % kind)
85 continue
86 var mat := vol.make_fog_material()
87 var fv := FogVolume.new()
88 fv.size = Vector3(64, 64, 64)
89 fv.material = mat
90 root.add_child(fv)
91 print(" %-10s fog shader %d chars, FogVolume in tree -- watch for SHADER ERROR"
92 % [kind, vol.build_fog_shader_code().length()])
93 _frames += 1
94 if _frames > 8:
95 print("done")
96 quit()
97 return true
98 return false
demo/glsl_golden_gate.gd +12 -3
……@@ -210,7 +210,7 @@ func _reads_the_right_sockets() -> void:
210210 body.find("pos.y) * 22.0") != -1
211211 and body.find("float(int(33))") != -1
212212 and body.find("33)), 44.0, 55.0,") != -1
213 and body.find("* n * 11.0;") != -1,
213 and body.find("* n%d_nval * 11.0;" % d) != -1,
214214 "scale 22, detail 33, roughness 44, lacunarity 55, strength 11")
215215
216216 var g2 := ProcNodeGraph.new()
……@@ -271,7 +271,7 @@ func _batch_still_uses_push_constants() -> void:
271271 bt.find("pos.y) * pc.param1") != -1
272272 and bt.find("float(int(pc.param2))") != -1
273273 and bt.find("pc.param3, pc.param4,") != -1
274 and bt.find("* n * pc.param0;") != -1
274 and bt.find("* n%d_nval * pc.param0;" % d) != -1
275275 and bt.find("11.0") == -1 and bt.find("22.0") == -1,
276276 "five slots in socket order, and no default baked in")
277277
……@@ -279,6 +279,15 @@ func _batch_still_uses_push_constants() -> void:
279279 var sp := g2.add_node(SetPositionNode.new())
280280 g2.get_node(sp).set_input_default(3, Vector3(4.25, 5.25, 6.25))
281281 var bt2: String = g2.compile_batch_glsl(PackedInt32Array([sp]))
282 # Two Displace nodes in one graph must not redefine each other's locals.
283 var g2b := ProcNodeGraph.new()
284 var d1 := g2b.add_node(DisplaceNode.new())
285 var d2 := g2b.add_node(DisplaceNode.new())
286 var bt1b: String = g2b.compile_batch_glsl(PackedInt32Array([d1, d2]))
287 _check("Displace locals are per-node",
288 bt1b.find("n%d_npos" % d1) != -1 and bt1b.find("n%d_npos" % d2) != -1,
289 "two Displace nodes declare distinct locals")
290
282291 _check("SetPosition batch slots",
283292 bt2.find("pos += vec3(pc.param0, pc.param1, pc.param2);") != -1
284293 and bt2.find("4.25") == -1,
……@@ -511,7 +520,7 @@ func _init() -> void:
511520 #
512521 # Bumping this by hand when a case is added is the point: an intentional
513522 # change is a one-line edit, an accidental drop is a failure.
514 const EXPECTED_CHECKS := 29
523 const EXPECTED_CHECKS := 30
515524 if _ran != EXPECTED_CHECKS:
516525 _fail += 1
517526 print(" %-34s FAIL %d checks ran, expected %d -- a check was DROPPED, "
demo/golden/map_dc_erosion_density.glsl +6 -3
……@@ -445,9 +445,12 @@ vec2 normalize_safe(vec2 v) {
445445// of the loop is what makes the normalisation possible at all: side_dir is
446446// constant across all 16 samples, so it can be factored out.
447447vec4 phacelle_noise(vec2 p, vec2 norm_dir, float freq, float offset, float normalization) {
448 const float TAU = 6.28318530717958647;
449 vec2 side_dir = vec2(-norm_dir.y, norm_dir.x) * freq * TAU;
450 offset *= TAU;
448 // PN_TAU, not TAU: Godot shading language RESERVES TAU as a builtin, and
449 // this .inc is compiled as a fog shader as well as GLSL 450 and C++.
450 // Measured: `SHADER ERROR: Redefinition of 'TAU'` from a FogVolume.
451 const float PN_TAU = 6.28318530717958647;
452 vec2 side_dir = vec2(-norm_dir.y, norm_dir.x) * freq * PN_TAU;
453 offset *= PN_TAU;
451454
452455 vec2 p_int = floor(p);
453456 vec2 p_frac = p - p_int;
src/core/proc_volume.cpp +61 -0
……@@ -1,8 +1,13 @@
11#include "proc_volume.h"
2#include <godot_cpp/variant/utility_functions.hpp>
3#include <godot_cpp/classes/shader_material.hpp>
4#include <godot_cpp/classes/shader.hpp>
25
36namespace godot {
47
58void ProcVolume::_bind_methods() {
9 ClassDB::bind_method(D_METHOD("build_fog_shader_code"), &ProcVolume::build_fog_shader_code);
10 ClassDB::bind_method(D_METHOD("make_fog_material"), &ProcVolume::make_fog_material);
611 ClassDB::bind_method(D_METHOD("get_glsl_source"), &ProcVolume::get_glsl_source);
712 ClassDB::bind_method(D_METHOD("set_glsl_source", "source"), &ProcVolume::set_glsl_source);
813 ClassDB::bind_method(D_METHOD("get_density_grid"), &ProcVolume::get_density_grid);
……@@ -88,4 +93,60 @@ Ref<ProcVolume> ProcVolume::duplicate_volume() const {
8893 return dup;
8994}
9095
96
97// ── Fog material ────────────────────────────────────────────────────────────
98
99String ProcVolume::build_fog_shader_code() const {
100 // The graph's function verbatim, then a fog() that drives DENSITY from it.
101 //
102 // WORLD_POSITION is the froxel's world-space point; the volume is evaluated
103 // in its OWN space, so the FogVolume's placement moves the cloud without
104 // recompiling anything.
105 //
106 // The density field is an SDF -- positive inside -- and fog wants an
107 // OPACITY in 0..1. `edge_softness` is how many world units the transition
108 // spans: 0 gives a hard-edged blob, large values a soft cloud. Multiplying
109 // rather than stepping keeps it a continuous field, which is what the
110 // froxel integrator wants.
111 String src;
112 src += "shader_type fog;\n\n";
113 src += "// Emitted by proc-nodes: ProcNodeGraph::compile_to_glsl_function.\n";
114 src += "// Do not edit here -- edit the graph.\n";
115 src += glsl_source;
116 src += "\n";
117 src += "uniform vec3 volume_origin = vec3(0.0);\n";
118 src += "uniform float iso_level = 0.0;\n";
119 src += "uniform float edge_softness : hint_range(0.001, 100.0) = 4.0;\n";
120 src += "uniform float density_scale : hint_range(0.0, 32.0) = 1.0;\n";
121 src += "uniform vec4 volume_albedo : source_color = vec4(1.0, 1.0, 1.0, 1.0);\n";
122 src += "uniform vec3 volume_emission = vec3(0.0);\n\n";
123 src += "void fog() {\n";
124 src += "\tfloat d = proc_density(WORLD_POSITION - volume_origin);\n";
125 src += "\tDENSITY = clamp((d - iso_level) / edge_softness, 0.0, 1.0) * density_scale;\n";
126 src += "\tALBEDO = volume_albedo.rgb;\n";
127 src += "\tEMISSION = volume_emission;\n";
128 src += "}\n";
129 return src;
130}
131
132Ref<Material> ProcVolume::make_fog_material() const {
133 if (glsl_source.is_empty()) {
134 // An empty volume would compile to a shader with no proc_density and
135 // fail at the call site, which reads as a shader bug rather than as an
136 // empty volume. Say which it is.
137 UtilityFunctions::push_error(
138 "proc-nodes: ProcVolume has no GLSL, so there is no fog material to "
139 "make. A volume graph must end in a Volume to Mesh node (or carry a "
140 "Group Output float) for the terminal to compile one.");
141 return Ref<Material>();
142 }
143 Ref<Shader> sh;
144 sh.instantiate();
145 sh->set_code(build_fog_shader_code());
146 Ref<ShaderMaterial> mat;
147 mat.instantiate();
148 mat->set_shader(sh);
149 return mat;
150}
151
91152} // namespace godot
src/core/proc_volume.h +24 -0
……@@ -1,6 +1,7 @@
11#ifndef PROC_NODES_PROC_VOLUME_H
22#define PROC_NODES_PROC_VOLUME_H
33
4#include <godot_cpp/classes/material.hpp>
45#include <godot_cpp/classes/ref.hpp>
56#include <godot_cpp/classes/ref_counted.hpp>
67#include <godot_cpp/variant/packed_float32_array.hpp>
……@@ -52,6 +53,29 @@ public:
5253 bool has_glsl() const { return !glsl_source.is_empty(); }
5354 bool has_grid() const { return density_grid.size() > 0; }
5455
56 // ── THE GPU CONSUMER ─────────────────────────────────────────────────────
57 //
58 // A Godot `shader_type fog` material wrapping this volume's proc_density,
59 // for a FogVolume to raymarch. That is dc-terrain-nubis Layer 3 -- the
60 // artist-placed local effects layer -- and it is the whole reason
61 // glsl_source exists: an authored cloud formation, a fog body, a blob, made
62 // in the graph editor and rendered by the engine's own froxel pass. No
63 // custom compute pipeline.
64 //
65 // ⚠ THE EMITTED TEXT MUST BE GODOT SHADING LANGUAGE, NOT GLSL 450. They are
66 // close but not the same: no `#version`, and Godot rejects a mutable global
67 // at file scope. compile_to_glsl_function's output for an SDF graph is
68 // already portable (measured: 469 characters of vec3/float/max/length, all
69 // of which Godot has). A graph pulling in a noise prelude is the case to
70 // check, not to assume -- see demo/fog_shader_gate.gd, which compiles the
71 // result for real rather than eyeballing it.
72 String build_fog_shader_code() const;
73
74 // The same thing as a ready ShaderMaterial, so a scene is two lines:
75 // fog_volume.material = volume.make_fog_material()
76 // fog_volume.size = ...
77 Ref<Material> make_fog_material() const;
78
5579 // Sample density from pre-computed grid (trilinear interpolation)
5680 float sample_density(const Vector3 &p_world_pos) const;
5781
src/nodes/noise_nodes.cpp +28 -7
……@@ -286,9 +286,23 @@ String NoiseTextureNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_i
286286 // Detail is a FLOAT socket truncated by int(), matching
287287 // `static_cast<int>(read_float(...))`, so it is not subject to the octave
288288 // clamp -- that applies to Int sockets, and this one is not.
289 return String("vec3 nsp = ") + p_ctx.in(p_node_idx, 0) + " * " +
290 p_ctx.in(p_node_idx, 1) + ";\n\tfloat noise_val = ps_fbm_noise_3d(nsp.x, nsp.y, "
291 "nsp.z, int(" + p_ctx.in(p_node_idx, 2) + "), " + String::num(roughness, 9) + ", " +
289 // ⛔ IT NEVER ASSIGNED ITS OUTPUT SOCKET. This wrote `float noise_val = ...`
290 // and stopped, so a consumer resolving its Value output through
291 // GlslEmitContext::out() got `n<idx>_s0` -- a name nothing declares. Found
292 // 2026-09-06 by compiling a volume graph as a Godot fog shader:
293 //
294 // SHADER ERROR: Unknown identifier in expression: 'n1_s0'.
295 //
296 // It is not a fog problem. Any graph consuming this node's output emits an
297 // undeclared identifier, on the density path too; nothing had consumed it.
298 //
299 // And the locals are PER NODE now. `nsp` was a fixed name, so two Simplex
300 // Noise nodes in one graph redefined it -- the same shape one step along.
301 const String v = String("n") + itos(p_node_idx) + "_nsp";
302 return String("vec3 ") + v + " = " + p_ctx.in(p_node_idx, 0) + " * " +
303 p_ctx.in(p_node_idx, 1) + ";\n\t" + p_ctx.declare(p_node_idx, 0) +
304 " = ps_fbm_noise_3d(" + v + ".x, " + v + ".y, " + v + ".z, int(" +
305 p_ctx.in(p_node_idx, 2) + "), " + String::num(roughness, 9) + ", " +
292306 String::num(lacunarity, 9) + ") * 0.5 + 0.5;";
293307}
294308
……@@ -383,10 +397,17 @@ String DisplaceNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx)
383397 // pos += normal * n * 0.0; // displacement x 0
384398 //
385399 // Displace was a silent no-op in every emitted density shader.
386 return String("vec3 npos = vec3(-pos.x, pos.z, pos.y) * ") + p_ctx.param(p_node_idx, 2) +
387 ";\n\tfloat n = bp_perlin_fbm_3(npos, float(int(" + p_ctx.param(p_node_idx, 3) +
388 ")), " + p_ctx.param(p_node_idx, 4) + ", " + p_ctx.param(p_node_idx, 5) +
389 ", true) * 2.0 - 1.0;\n\tpos += normal * n * " + p_ctx.param(p_node_idx, 1) + ";";
400 // Per-node locals: `npos` and `n` were fixed names, so two Displace nodes in
401 // one graph redefined them. Displace's output is GEOMETRY -- it moves the
402 // shared `pos` and declares no value socket -- so there is nothing to
403 // assign here, unlike Simplex Noise above.
404 const String np = String("n") + itos(p_node_idx) + "_npos";
405 const String nv = String("n") + itos(p_node_idx) + "_nval";
406 return String("vec3 ") + np + " = vec3(-pos.x, pos.z, pos.y) * " + p_ctx.param(p_node_idx, 2) +
407 ";\n\tfloat " + nv + " = bp_perlin_fbm_3(" + np + ", float(int(" +
408 p_ctx.param(p_node_idx, 3) + ")), " + p_ctx.param(p_node_idx, 4) + ", " +
409 p_ctx.param(p_node_idx, 5) + ", true) * 2.0 - 1.0;\n\tpos += normal * " + nv +
410 " * " + p_ctx.param(p_node_idx, 1) + ";";
390411}
391412
392413// --- CPU helper: hash-based random for Voronoi and white noise ---
src/shared/erosion_filter.inc +6 -3
……@@ -444,9 +444,12 @@ vec2 normalize_safe(vec2 v) {
444444// of the loop is what makes the normalisation possible at all: side_dir is
445445// constant across all 16 samples, so it can be factored out.
446446vec4 phacelle_noise(vec2 p, vec2 norm_dir, float freq, float offset, float normalization) {
447 const float TAU = 6.28318530717958647;
448 vec2 side_dir = vec2(-norm_dir.y, norm_dir.x) * freq * TAU;
449 offset *= TAU;
447 // PN_TAU, not TAU: Godot shading language RESERVES TAU as a builtin, and
448 // this .inc is compiled as a fog shader as well as GLSL 450 and C++.
449 // Measured: `SHADER ERROR: Redefinition of 'TAU'` from a FogVolume.
450 const float PN_TAU = 6.28318530717958647;
451 vec2 side_dir = vec2(-norm_dir.y, norm_dir.x) * freq * PN_TAU;
452 offset *= PN_TAU;
450453
451454 vec2 p_int = floor(p);
452455 vec2 p_frac = p - p_int;