| … | … | @@ -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. | |
| 36 | extends SceneTree | |
| 37 | ||
| 38 | var _built := false | |
| 39 | var _frames := 0 | |
| 40 | ||
| 41 | func _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 | ||
| 78 | func _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 |
| … | … | @@ -210,7 +210,7 @@ func _reads_the_right_sockets() -> void: |
| 210 | 210 | body.find("pos.y) * 22.0") != -1 |
| 211 | 211 | and body.find("float(int(33))") != -1 |
| 212 | 212 | 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, | |
| 214 | 214 | "scale 22, detail 33, roughness 44, lacunarity 55, strength 11") |
| 215 | 215 | |
| 216 | 216 | var g2 := ProcNodeGraph.new() |
| … | … | @@ -271,7 +271,7 @@ func _batch_still_uses_push_constants() -> void: |
| 271 | 271 | bt.find("pos.y) * pc.param1") != -1 |
| 272 | 272 | and bt.find("float(int(pc.param2))") != -1 |
| 273 | 273 | 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 | |
| 275 | 275 | and bt.find("11.0") == -1 and bt.find("22.0") == -1, |
| 276 | 276 | "five slots in socket order, and no default baked in") |
| 277 | 277 | |
| … | … | @@ -279,6 +279,15 @@ func _batch_still_uses_push_constants() -> void: |
| 279 | 279 | var sp := g2.add_node(SetPositionNode.new()) |
| 280 | 280 | g2.get_node(sp).set_input_default(3, Vector3(4.25, 5.25, 6.25)) |
| 281 | 281 | 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 | ||
| 282 | 291 | _check("SetPosition batch slots", |
| 283 | 292 | bt2.find("pos += vec3(pc.param0, pc.param1, pc.param2);") != -1 |
| 284 | 293 | and bt2.find("4.25") == -1, |
| … | … | @@ -511,7 +520,7 @@ func _init() -> void: |
| 511 | 520 | # |
| 512 | 521 | # Bumping this by hand when a case is added is the point: an intentional |
| 513 | 522 | # change is a one-line edit, an accidental drop is a failure. |
| 514 | const EXPECTED_CHECKS := 29 | |
| 523 | const EXPECTED_CHECKS := 30 | |
| 515 | 524 | if _ran != EXPECTED_CHECKS: |
| 516 | 525 | _fail += 1 |
| 517 | 526 | print(" %-34s FAIL %d checks ran, expected %d -- a check was DROPPED, " |
| … | … | @@ -445,9 +445,12 @@ vec2 normalize_safe(vec2 v) { |
| 445 | 445 | // of the loop is what makes the normalisation possible at all: side_dir is |
| 446 | 446 | // constant across all 16 samples, so it can be factored out. |
| 447 | 447 | vec4 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; | |
| 451 | 454 | |
| 452 | 455 | vec2 p_int = floor(p); |
| 453 | 456 | vec2 p_frac = p - p_int; |
| … | … | @@ -1,8 +1,13 @@ |
| 1 | 1 | #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> | |
| 2 | 5 | |
| 3 | 6 | namespace godot { |
| 4 | 7 | |
| 5 | 8 | void 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); | |
| 6 | 11 | ClassDB::bind_method(D_METHOD("get_glsl_source"), &ProcVolume::get_glsl_source); |
| 7 | 12 | ClassDB::bind_method(D_METHOD("set_glsl_source", "source"), &ProcVolume::set_glsl_source); |
| 8 | 13 | ClassDB::bind_method(D_METHOD("get_density_grid"), &ProcVolume::get_density_grid); |
| … | … | @@ -88,4 +93,60 @@ Ref<ProcVolume> ProcVolume::duplicate_volume() const { |
| 88 | 93 | return dup; |
| 89 | 94 | } |
| 90 | 95 | |
| 96 | ||
| 97 | // ── Fog material ──────────────────────────────────────────────────────────── | |
| 98 | ||
| 99 | String 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 | ||
| 132 | Ref<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 | ||
| 91 | 152 | } // namespace godot |
| … | … | @@ -1,6 +1,7 @@ |
| 1 | 1 | #ifndef PROC_NODES_PROC_VOLUME_H |
| 2 | 2 | #define PROC_NODES_PROC_VOLUME_H |
| 3 | 3 | |
| 4 | #include <godot_cpp/classes/material.hpp> | |
| 4 | 5 | #include <godot_cpp/classes/ref.hpp> |
| 5 | 6 | #include <godot_cpp/classes/ref_counted.hpp> |
| 6 | 7 | #include <godot_cpp/variant/packed_float32_array.hpp> |
| … | … | @@ -52,6 +53,29 @@ public: |
| 52 | 53 | bool has_glsl() const { return !glsl_source.is_empty(); } |
| 53 | 54 | bool has_grid() const { return density_grid.size() > 0; } |
| 54 | 55 | |
| 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 | ||
| 55 | 79 | // Sample density from pre-computed grid (trilinear interpolation) |
| 56 | 80 | float sample_density(const Vector3 &p_world_pos) const; |
| 57 | 81 |
| … | … | @@ -286,9 +286,23 @@ String NoiseTextureNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_i |
| 286 | 286 | // Detail is a FLOAT socket truncated by int(), matching |
| 287 | 287 | // `static_cast<int>(read_float(...))`, so it is not subject to the octave |
| 288 | 288 | // 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) + ", " + | |
| 292 | 306 | String::num(lacunarity, 9) + ") * 0.5 + 0.5;"; |
| 293 | 307 | } |
| 294 | 308 | |
| … | … | @@ -383,10 +397,17 @@ String DisplaceNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) |
| 383 | 397 | // pos += normal * n * 0.0; // displacement x 0 |
| 384 | 398 | // |
| 385 | 399 | // 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) + ";"; | |
| 390 | 411 | } |
| 391 | 412 | |
| 392 | 413 | // --- CPU helper: hash-based random for Voronoi and white noise --- |
| … | … | @@ -444,9 +444,12 @@ vec2 normalize_safe(vec2 v) { |
| 444 | 444 | // of the loop is what makes the normalisation possible at all: side_dir is |
| 445 | 445 | // constant across all 16 samples, so it can be factored out. |
| 446 | 446 | vec4 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; | |
| 450 | 453 | |
| 451 | 454 | vec2 p_int = floor(p); |
| 452 | 455 | vec2 p_frac = p - p_int; |