| … | … | @@ -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> | |
| 9 | extends SceneTree | |
| 10 | ||
| 11 | var _pass := 0 | |
| 12 | var _fail := 0 | |
| 13 | ||
| 14 | func _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 | ||
| 22 | func _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) |
| … | … | @@ -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 | ||
| 7 | using namespace godot; | |
| 8 | ||
| 9 | void 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 | ||
| 28 | void DCTerrainGeneratorProcNodesBounded::set_graph(const Ref<Resource> &p_graph) { | |
| 29 | graph_ = p_graph; | |
| 30 | _recompile(); | |
| 31 | emit_changed(); | |
| 32 | } | |
| 33 | ||
| 34 | void 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 | } |
| … | … | @@ -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 | ||
| 9 | namespace 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. | |
| 45 | class 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 | ||
| 89 | protected: | |
| 90 | static void _bind_methods(); | |
| 91 | ||
| 92 | public: | |
| 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 |
| … | … | @@ -2,6 +2,7 @@ |
| 2 | 2 | #include "dc_terrain.h" |
| 3 | 3 | #include "dc_terrain_generator.h" |
| 4 | 4 | #include "dc_terrain_generator_procnodes.h" |
| 5 | #include "dc_terrain_generator_procnodes_bounded.h" | |
| 5 | 6 | #include "dc_terrain_utils.h" |
| 6 | 7 | #include "dc_level.h" |
| 7 | 8 | #include <gdextension_interface.h> |
| … | … | @@ -18,6 +19,7 @@ void initialize_dc_terrain_module(ModuleInitializationLevel p_level) { |
| 18 | 19 | GDREGISTER_CLASS(DCTerrainGeneratorScript); |
| 19 | 20 | GDREGISTER_CLASS(DCTerrainGeneratorGeiss); |
| 20 | 21 | GDREGISTER_CLASS(DCTerrainGeneratorProcNodes); |
| 22 | GDREGISTER_CLASS(DCTerrainGeneratorProcNodesBounded); | |
| 21 | 23 | GDREGISTER_CLASS(DCTerrain); |
| 22 | 24 | GDREGISTER_CLASS(DCTerrainUtils); |
| 23 | 25 | GDREGISTER_CLASS(DCLevel); |