OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain commits / b868717

generator: a proc-nodes graph, meshed at the size Blender says it is
DCTerrainGeneratorProcNodesBounded. 10/10 on demo/gate_bounded_procnodes.gd against the bridged voronoi mountain. WHY NOT DCTerrainGeneratorProcNodes. That one is the WORLD generator: it maps metres into a UV-like node space through terrain_height, adds its own mountain / relief / detail layers, clips to [floor_y, ceiling_y], and takes its VERTICAL SCALE from a discovered parameter whose node must be titled "Erosion Noise". A graph bridged from Blender has no such node, so height_scale is pinned at the 5.0 default whatever the map does. The mountain is 25.8 node units tall over a 40-unit footprint and the band is 5 -- and that ratio is independent of terrain_height, because both axes scale together. No map constant fixes it. It would arrive clipped at 21% of its height. So this generator does not scale anything, and the whole wrapper is one line: float evaluate_density(float x, float y, float z) { return proc_density(vec3(x, y, z)); } DCGenerator::evaluate takes world coordinates and the graph declares its bounds in world units, so node units ARE world units. The gate asserts that call by text, because a terrain_height creeping back in here would be invisible except as an asset that arrives the wrong size. BOUNDED, NOT TILING. get_bounds() -- which DCGenerator has always had, defaulting to false -- is answered from the graph's own Group Output interface via ProcNodeGraph::get_density_bounds. A graph declaring no bounds stays unbounded rather than getting a guessed box; a degenerate or inverted one is refused by name rather than meshing nothing and looking like a missing generator. No octave clamp and no skip list: both are properties of a streaming clipmap, and clamping here would silently change the field with distance. ⚠ GPU ONLY, and it says so instead of inventing a CPU field. evaluate() returns air. A CPU path would have to call back into the graph from dc-terrain's worker threads, and neither the reflection call nor ProcNodeGraph's cached execution plan has been shown safe there -- a generator that quietly returns a DIFFERENT field on CPU than on GPU is the exact failure proc-nodes' test_emitted_density_parity exists to catch. Same stance DCGeneratorGeiss takes while its volumes are unloaded. No compile-time dependency on proc-nodes: the graph is held as a Ref<Resource> and everything goes through call(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b86871701724065b4fea07804f34f78706b323e2
by plankatron <fleeky@gmail.com> · 2026-09-08T14:30:23.000Z

Changed files (4) +298 -0

demo/gate_bounded_procnodes.gd +71 -0
……@@ -0,0 +1,71 @@
1## The bounded generator, end to end: a bridged graph in, a shader and a box out.
2##
3## What this can see that the density-parity gate cannot: that gate compares the
4## GRAPH's field on two devices. This one asks whether dc-terrain accepted the
5## graph at all -- whether the wrapper compiled, and whether the box the graph
6## declared survived the trip out of Blender's frame.
7##
8## Usage: godot --headless --path demo --script res://gate_bounded_procnodes.gd -- <graph.json>
9extends SceneTree
10
11var _pass := 0
12var _fail := 0
13
14func _check(name: String, ok: bool, note: String = "") -> void:
15 if ok:
16 _pass += 1
17 print(" %-46s PASS %s" % [name, note])
18 else:
19 _fail += 1
20 print(" %-46s FAIL %s" % [name, note])
21
22func _init() -> void:
23 var a := OS.get_cmdline_user_args()
24 var g: ProcNodeGraph = GraphImporter.import_from_json(FileAccess.get_file_as_string(a[0]))
25 _check("graph imported", g != null, "%d nodes" % (g.get_node_count() if g else 0))
26 if g == null:
27 quit(1); return
28
29 var gen := DCTerrainGeneratorProcNodesBounded.new()
30 gen.set_graph(g)
31
32 _check("wrapper compiled", gen.has_compiled_shader(),
33 gen.get_last_compile_error() if not gen.has_compiled_shader()
34 else "%d chars" % gen.get_compiled_glsl().length())
35
36 var glsl: String = gen.get_compiled_glsl()
37 _check("emits evaluate_density", glsl.find("float evaluate_density(") != -1)
38 # THE WHOLE POINT: world coordinates straight through, no rescale. A future
39 # terrain_height creeping in here would be invisible except as an asset that
40 # arrives the wrong size, so assert the identity call by text.
41 _check("world coords pass through unscaled",
42 glsl.find("proc_density(vec3(x, y, z))") != -1,
43 "no terrain_height mapping on this path")
44
45 _check("graph declared bounds", gen.has_bounds())
46 if gen.has_bounds():
47 var lo: Vector3 = gen.get_bounds_min()
48 var hi: Vector3 = gen.get_bounds_max()
49 print(" bounds min=%v max=%v voxel=%.4f" % [lo, hi, gen.get_authored_voxel_size()])
50 # M negates x, so an unsorted conversion inverts that axis. A box that
51 # is not strictly increasing on every axis meshes nothing and looks
52 # exactly like a missing generator.
53 _check("box is not inverted", hi.x > lo.x and hi.y > lo.y and hi.z > lo.z)
54 # Blender's up is z; after M it must be Godot's y. The mountain is much
55 # taller than it is deep in Blender's z, so a frame slip shows here.
56 _check("Blender up became Godot y", absf((hi.y - lo.y) - 25.7648) < 0.01,
57 "height %.4f" % (hi.y - lo.y))
58 _check("footprint survived", absf((hi.x - lo.x) - 40.0) < 0.01
59 and absf((hi.z - lo.z) - 40.0) < 0.01,
60 "%.2f x %.2f" % [hi.x - lo.x, hi.z - lo.z])
61 _check("authored voxel size crossed", absf(gen.get_authored_voxel_size() - 0.25) < 1e-6)
62
63 # An unbounded graph must say so rather than guess a box.
64 var g2: ProcNodeGraph = GraphImporter.import_from_json(FileAccess.get_file_as_string(a[0]))
65 var gen2 := DCTerrainGeneratorProcNodesBounded.new()
66 _check("unconfigured generator is not an error", not gen2.has_compiled_shader()
67 and gen2.get_last_compile_error() != "",
68 gen2.get_last_compile_error())
69
70 print("\n%d/%d passed" % [_pass, _pass + _fail])
71 quit(1 if _fail > 0 else 0)
src/dc_terrain_generator_procnodes_bounded.cpp +114 -0
……@@ -0,0 +1,114 @@
1#include "dc_terrain_generator_procnodes_bounded.h"
2
3#include <godot_cpp/core/class_db.hpp>
4#include <godot_cpp/variant/dictionary.hpp>
5#include <godot_cpp/variant/utility_functions.hpp>
6
7using namespace godot;
8
9void DCTerrainGeneratorProcNodesBounded::_bind_methods() {
10 ClassDB::bind_method(D_METHOD("set_graph", "graph"), &DCTerrainGeneratorProcNodesBounded::set_graph);
11 ClassDB::bind_method(D_METHOD("get_graph"), &DCTerrainGeneratorProcNodesBounded::get_graph);
12 ADD_PROPERTY(PropertyInfo(Variant::OBJECT, "graph", PROPERTY_HINT_RESOURCE_TYPE, "Resource"),
13 "set_graph", "get_graph");
14
15 ClassDB::bind_method(D_METHOD("get_last_compile_error"),
16 &DCTerrainGeneratorProcNodesBounded::get_last_compile_error);
17 ClassDB::bind_method(D_METHOD("has_compiled_shader"),
18 &DCTerrainGeneratorProcNodesBounded::has_compiled_shader);
19 ClassDB::bind_method(D_METHOD("get_compiled_glsl"),
20 &DCTerrainGeneratorProcNodesBounded::get_compiled_glsl);
21 ClassDB::bind_method(D_METHOD("has_bounds"), &DCTerrainGeneratorProcNodesBounded::has_bounds);
22 ClassDB::bind_method(D_METHOD("get_bounds_min"), &DCTerrainGeneratorProcNodesBounded::get_bounds_min);
23 ClassDB::bind_method(D_METHOD("get_bounds_max"), &DCTerrainGeneratorProcNodesBounded::get_bounds_max);
24 ClassDB::bind_method(D_METHOD("get_authored_voxel_size"),
25 &DCTerrainGeneratorProcNodesBounded::get_authored_voxel_size);
26}
27
28void DCTerrainGeneratorProcNodesBounded::set_graph(const Ref<Resource> &p_graph) {
29 graph_ = p_graph;
30 _recompile();
31 emit_changed();
32}
33
34void DCTerrainGeneratorProcNodesBounded::_recompile() {
35 cached_glsl_.clear();
36 last_compile_error_ = String();
37 has_bounds_ = false;
38 authored_voxel_size_ = 0.0f;
39
40 if (graph_.is_null()) {
41 // Not an error: an unconfigured generator is a legitimate state and a
42 // scene that has not been wired up yet should not scream on load. It is
43 // recorded so a gate can tell this apart from a real failure.
44 last_compile_error_ = "no graph assigned";
45 return;
46 }
47
48 // NO SKIP LIST AND NO OCTAVE CLAMP. The world generator clamps octaves so a
49 // coarse LOD cannot run more of them than its voxel size can carry; that is
50 // a property of a streaming clipmap, and this generator meshes one bounded
51 // asset at the resolution its author chose. Adding the clamp here would
52 // silently change the field with distance.
53 Variant glsl_var = graph_->call("compile_to_glsl_function", "proc_density", false,
54 PackedStringArray());
55 if (glsl_var.get_type() != Variant::STRING) {
56 last_compile_error_ = "compile_to_glsl_function returned non-string "
57 "(is the proc-nodes extension loaded?)";
58 UtilityFunctions::push_error("DCTerrainGeneratorProcNodesBounded: ", last_compile_error_);
59 return;
60 }
61 String proc_glsl = glsl_var;
62 if (proc_glsl.is_empty()) {
63 // proc-nodes refuses rather than dropping a node it cannot emit, and it
64 // has already said which node. What it cannot know is that the caller
65 // was a terrain generator, so say what the refusal COSTS.
66 last_compile_error_ = "compile_to_glsl_function REFUSED (a node has no GLSL -- "
67 "see the proc-nodes error above for which)";
68 UtilityFunctions::push_error(
69 "DCTerrainGeneratorProcNodesBounded: ", last_compile_error_,
70 ". No GPU shader, and this generator has no CPU path, so the terrain "
71 "will be empty rather than wrong.");
72 return;
73 }
74
75 // THE WHOLE WRAPPER. World coordinates in, density out, nothing between.
76 //
77 // This one line is the point of the class. The world generator spends ~400
78 // lines here mapping metres into node space and back, because its graph is
79 // a heightfield over a planet. A bounded asset declares its own extent, so
80 // node units ARE world units and there is nothing to convert. Any rescale
81 // added here would put the asset somewhere other than where Blender says.
82 String wrapped = proc_glsl +
83 "\nfloat evaluate_density(float x, float y, float z) {\n"
84 " return proc_density(vec3(x, y, z));\n"
85 "}\n";
86 CharString u8 = wrapped.utf8();
87 cached_glsl_ = std::string(u8.get_data(), u8.length());
88
89 // The graph's own extent, already converted out of Blender's frame and
90 // re-sorted componentwise by ProcNodeGraph::get_density_bounds.
91 Variant b = graph_->call("get_density_bounds");
92 if (b.get_type() == Variant::DICTIONARY) {
93 Dictionary d = b;
94 if (d.has("min") && d.has("max")) {
95 const Vector3 lo = d["min"];
96 const Vector3 hi = d["max"];
97 // A degenerate box would mesh nothing and look exactly like a
98 // missing generator, so refuse it by name instead.
99 if (hi.x > lo.x && hi.y > lo.y && hi.z > lo.z) {
100 bmin_[0] = lo.x; bmin_[1] = lo.y; bmin_[2] = lo.z;
101 bmax_[0] = hi.x; bmax_[1] = hi.y; bmax_[2] = hi.z;
102 has_bounds_ = true;
103 if (d.has("voxel_size")) {
104 authored_voxel_size_ = (float)(double)d["voxel_size"];
105 }
106 } else {
107 UtilityFunctions::push_warning(
108 "DCTerrainGeneratorProcNodesBounded: the graph's declared bounds "
109 "are empty or inverted (min ", lo, ", max ", hi,
110 "). Treating the field as unbounded.");
111 }
112 }
113 }
114}
src/dc_terrain_generator_procnodes_bounded.h +111 -0
……@@ -0,0 +1,111 @@
1#ifndef DC_TERRAIN_GENERATOR_PROCNODES_BOUNDED_H
2#define DC_TERRAIN_GENERATOR_PROCNODES_BOUNDED_H
3
4#include "dc_terrain_generator.h"
5
6#include <godot_cpp/variant/string.hpp>
7#include <string>
8
9namespace godot {
10
11// ---------------------------------------------------------------------------
12// DCTerrainGeneratorProcNodesBounded — a proc-nodes graph, at its own size
13// ---------------------------------------------------------------------------
14//
15// WHY THIS IS NOT DCTerrainGeneratorProcNodes.
16//
17// That one is the WORLD generator: it maps world metres into a UV-like node
18// space through terrain_height, adds its own mountain / relief / detail noise
19// layers, clips the density to [floor_y, ceiling_y], and takes its vertical
20// scale from a discovered parameter whose node must be titled "Erosion Noise".
21// All of that is right for a planet and wrong for an asset. A graph bridged
22// from Blender has no Erosion Noise node, so its vertical scale is pinned at
23// the 5.0 default whatever the map does, and a mountain 25.8 node units tall
24// over a 40-unit footprint cannot fit a band of 5 -- a ratio no choice of
25// terrain_height changes, because both axes scale together.
26//
27// So this generator does not scale anything. DCGenerator::evaluate takes world
28// coordinates, the graph declares its bounds in world units, and the field is
29// meshed at the size Blender says it is. The emitted wrapper is one line.
30//
31// BOUNDED, NOT TILING. get_bounds() is answered from the graph's own Group
32// Output interface -- "Bounds Min" / "Bounds Max", read by name and converted
33// out of Blender's frame by ProcNodeGraph::get_density_bounds. A graph that
34// declares no bounds falls back to the base's "unbounded", which is the honest
35// answer rather than a guessed box.
36//
37// ⚠ GPU ONLY, deliberately, and it says so rather than inventing a CPU field.
38// evaluate() returns air. The CPU path would have to call back into the graph
39// from dc-terrain's worker threads, and neither the reflection call nor
40// ProcNodeGraph's cached execution plan has been shown to be safe there. A
41// generator that quietly returns a DIFFERENT field on the CPU than it does on
42// the GPU is the exact failure the emitted-density gate exists to catch, so
43// this returns nothing and is visible about it -- the same stance
44// DCGeneratorGeiss takes while its volumes are unloaded.
45class DCTerrainGeneratorProcNodesBounded : public DCTerrainGenerator {
46 GDCLASS(DCTerrainGeneratorProcNodesBounded, DCTerrainGenerator)
47
48 // Held as a generic Resource: dc-terrain has NO compile-time dependency on
49 // proc-nodes and must not gain one. Everything goes through call().
50 Ref<Resource> graph_;
51 std::string cached_glsl_;
52 String last_compile_error_ = "never compiled (no graph has been assigned)";
53
54 bool has_bounds_ = false;
55 float bmin_[3] = { 0, 0, 0 };
56 float bmax_[3] = { 0, 0, 0 };
57 float authored_voxel_size_ = 0.0f;
58
59 class Bridge : public DCGenerator {
60 DCTerrainGeneratorProcNodesBounded *owner_;
61
62 public:
63 Bridge(DCTerrainGeneratorProcNodesBounded *o) : owner_(o) {}
64
65 float evaluate(float, float, float, float) override {
66 // Air. See the GPU-ONLY note above: an invented CPU field that
67 // disagrees with the shader is worse than no field.
68 return -1.0f;
69 }
70 bool has_gpu_shader() const override { return !owner_->cached_glsl_.empty(); }
71 const char *gpu_shader_source() const override {
72 return owner_->cached_glsl_.empty() ? nullptr : owner_->cached_glsl_.c_str();
73 }
74 bool get_bounds(float p_min[3], float p_max[3]) const override {
75 if (!owner_->has_bounds_) {
76 return false;
77 }
78 for (int i = 0; i < 3; i++) {
79 p_min[i] = owner_->bmin_[i];
80 p_max[i] = owner_->bmax_[i];
81 }
82 return true;
83 }
84 };
85 Bridge bridge_{ this };
86
87 void _recompile();
88
89protected:
90 static void _bind_methods();
91
92public:
93 DCGenerator *get_native_generator() override { return &bridge_; }
94
95 void set_graph(const Ref<Resource> &p_graph);
96 Ref<Resource> get_graph() const { return graph_; }
97
98 // Bound so a headless gate can assert on them: "visible" has to mean
99 // answerable, not printed.
100 String get_last_compile_error() const { return last_compile_error_; }
101 bool has_compiled_shader() const { return !cached_glsl_.empty(); }
102 String get_compiled_glsl() const { return String(cached_glsl_.c_str()); }
103 bool has_bounds() const { return has_bounds_; }
104 Vector3 get_bounds_min() const { return Vector3(bmin_[0], bmin_[1], bmin_[2]); }
105 Vector3 get_bounds_max() const { return Vector3(bmax_[0], bmax_[1], bmax_[2]); }
106 float get_authored_voxel_size() const { return authored_voxel_size_; }
107};
108
109} // namespace godot
110
111#endif // DC_TERRAIN_GENERATOR_PROCNODES_BOUNDED_H
src/register_types.cpp +2 -0
……@@ -2,6 +2,7 @@
22#include "dc_terrain.h"
33#include "dc_terrain_generator.h"
44#include "dc_terrain_generator_procnodes.h"
5#include "dc_terrain_generator_procnodes_bounded.h"
56#include "dc_terrain_utils.h"
67#include "dc_level.h"
78#include <gdextension_interface.h>
……@@ -18,6 +19,7 @@ void initialize_dc_terrain_module(ModuleInitializationLevel p_level) {
1819 GDREGISTER_CLASS(DCTerrainGeneratorScript);
1920 GDREGISTER_CLASS(DCTerrainGeneratorGeiss);
2021 GDREGISTER_CLASS(DCTerrainGeneratorProcNodes);
22 GDREGISTER_CLASS(DCTerrainGeneratorProcNodesBounded);
2123 GDREGISTER_CLASS(DCTerrain);
2224 GDREGISTER_CLASS(DCTerrainUtils);
2325 GDREGISTER_CLASS(DCLevel);