OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / src / nodes / volume_nodes.cpp

281 lines · cpp ref:
#include "volume_nodes.h"

#include "../graph/graph_executor.h"
#include "../graph/proc_node_graph.h"
#include "../core/proc_mesh.h"
#include "dc_density_grid.h"
#include "dc_mesher.h"

#include <godot_cpp/variant/utility_functions.hpp>
#include "../graph/graph_executor.h"

namespace godot {

// ── SphereSdfNode ──────────────────────────────────────────────────────────────

void SphereSdfNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_radius", "radius"), &SphereSdfNode::set_radius);
	ClassDB::bind_method(D_METHOD("get_radius"), &SphereSdfNode::get_radius);
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "radius"), "set_radius", "get_radius");
}

SphereSdfNode::SphereSdfNode() {
	input_defs.push_back({ "Center", SocketType::Vector, Vector3(0, 0, 0) });
	input_defs.push_back({ "Radius", SocketType::Float, 1.0f });
	output_defs.push_back({ "Density", SocketType::Float, 0.0f });
}

void SphereSdfNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Vector3 center = p_ctx.read_vector(p_node_idx, 0);
	float r = p_ctx.read_float(p_node_idx, 1);
	// For CPU evaluation, density at pos = r - length(pos - center)
	// Since we don't have a single eval position in the mesh context,
	// output the radius as a density proxy. Volume evaluation uses GLSL.
	p_ctx.write_float(p_node_idx, 0, r);
}

String SphereSdfNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// Centre is a VECTOR socket. This emitted `length(pos - pc.param0)`, where
	// pc.param0 is one float: GLSL broadcasts it, so the sphere sat on the
	// diagonal x=y=z at the radius' value rather than at the centre the graph
	// asked for. It compiled, which is why nothing caught it.
	return p_ctx.declare(p_node_idx, 0) + " = " + p_ctx.in(p_node_idx, 1) +
			" - length(pos - " + p_ctx.in(p_node_idx, 0) + ");";
}

// ── VolumeBooleanNode ──────────────────────────────────────────────────────────

void VolumeBooleanNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_operation", "operation"), &VolumeBooleanNode::set_operation);
	ClassDB::bind_method(D_METHOD("get_operation"), &VolumeBooleanNode::get_operation);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "operation", PROPERTY_HINT_ENUM, "Union,Intersect,Subtract"), "set_operation", "get_operation");

	BIND_ENUM_CONSTANT(OP_UNION);
	BIND_ENUM_CONSTANT(OP_INTERSECT);
	BIND_ENUM_CONSTANT(OP_SUBTRACT);
}

VolumeBooleanNode::VolumeBooleanNode() {
	input_defs.push_back({ "A", SocketType::Float, 0.0f });
	input_defs.push_back({ "B", SocketType::Float, 0.0f });
	output_defs.push_back({ "Density", SocketType::Float, 0.0f });
}

void VolumeBooleanNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	float a = p_ctx.read_float(p_node_idx, 0);
	float b = p_ctx.read_float(p_node_idx, 1);
	float result = 0.0f;
	switch (operation) {
		case OP_UNION:     result = Math::max(a, b); break;
		case OP_INTERSECT: result = Math::min(a, b); break;
		case OP_SUBTRACT:  result = Math::min(a, -b); break;
	}
	p_ctx.write_float(p_node_idx, 0, result);
}

String VolumeBooleanNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// Read density_%d_a and density_%d_b -- names NOTHING declared, so any graph
	// with this node emitted a shader that could not compile. The operands are
	// its two inputs, resolved through the graph.
	const String a = p_ctx.in(p_node_idx, 0);
	const String b = p_ctx.in(p_node_idx, 1);
	String expr;
	switch (operation) {
		case OP_UNION: expr = "max(" + a + ", " + b + ")"; break;
		case OP_INTERSECT: expr = "min(" + a + ", " + b + ")"; break;
		case OP_SUBTRACT: expr = "min(" + a + ", -(" + b + "))"; break;
		default: expr = a; break;
	}
	return p_ctx.declare(p_node_idx, 0) + " = " + expr + ";";
}

// ── VolumeToMeshNode ───────────────────────────────────────────────────────────

void VolumeToMeshNode::_bind_methods() {
}

VolumeToMeshNode::VolumeToMeshNode() {
	// Socket 0 is the DENSITY and find_density_output() names it by index, so
	// it must stay first. The rest are the meshing controls; Resolution 0 means
	// "carry the volume, do not mesh", which is what every existing graph gets.
	input_defs.push_back({ "Density", SocketType::Float, 0.0f });       // 0
	input_defs.push_back({ "Resolution", SocketType::Int, 0 });         // 1
	input_defs.push_back({ "Bounds Min", SocketType::Vector, Vector3(-1, -1, -1) }); // 2
	input_defs.push_back({ "Bounds Size", SocketType::Vector, Vector3(2, 2, 2) });   // 3
	input_defs.push_back({ "Threshold", SocketType::Float, 0.0f });     // 4
	output_defs.push_back({ "Geometry", SocketType::Geometry, Variant() });
}

namespace {

// THE GRAPH AS A DENSITY FIELD, for libdc's extractor.
//
// dc_mesh_from_grid wants a filled DensityGrid, and DensityGrid::fill_generator
// wants a DCGenerator. The graph's density at a POSITION is only obtainable by
// running the graph, so this runs it -- once per batch, not once per corner,
// through evaluate_density_batch and the evaluate_batch override added to
// DCGenerator for exactly this shape.
class GraphDensityGenerator : public DCGenerator {
	ProcNodeGraph *graph_;
	float threshold_;

public:
	GraphDensityGenerator(ProcNodeGraph *g, float t) : graph_(g), threshold_(t) {}

	float evaluate(float x, float y, float z, float) override {
		return (float)graph_->evaluate_density_at(Vector3(x, y, z)) - threshold_;
	}

	void evaluate_batch(const float *p_xyz, int p_count, float, float *r_out) override {
		PackedVector3Array pts;
		pts.resize(p_count);
		Vector3 *w = pts.ptrw();
		for (int i = 0; i < p_count; i++) {
			w[i] = Vector3(p_xyz[i * 3], p_xyz[i * 3 + 1], p_xyz[i * 3 + 2]);
		}
		PackedFloat32Array d = graph_->evaluate_density_batch(pts);
		const int n = MIN(p_count, d.size());
		for (int i = 0; i < n; i++) {
			r_out[i] = d[i] - threshold_;
		}
		for (int i = n; i < p_count; i++) {
			r_out[i] = -1.0f; // outside, rather than uninitialised
		}
	}
};

} // namespace

void VolumeToMeshNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	// FILL THE VOLUME. This used to write an EMPTY ProcVolume on an empty
	// ProcGeometry -- no mesh, no density, no message -- so a graph ending here
	// baked to nothing and looked like a graph that never ran.
	//
	// ProcVolume has carried `glsl_source` with the signature
	// `float proc_density(vec3 pos)` since it was written; nothing ever put one
	// in it. The graph can emit exactly that, and now does.
	Ref<ProcGeometry> geo;
	geo.instantiate();
	if (meshing_) {
		// THE NESTED PASS. Meshing runs the graph per grid batch, which runs
		// this node again; without this it recurses until the stack ends.
		// Upstream still computes the density -- that is what the extractor is
		// reading -- so returning a bare geometry here costs nothing.
		p_ctx.write_geometry(p_node_idx, 0, geo);
		return;
	}
	Ref<ProcVolume> vol;
	vol.instantiate();
	if (p_ctx.graph != nullptr) {
		vol->glsl_source = p_ctx.graph->compile_to_glsl_function(
				"proc_density", false, PackedStringArray());
		if (vol->glsl_source.is_empty()) {
			// The emitter has already said which node refused. Say what it
			// costs here, the way dc-terrain's _recompile_glsl does: an empty
			// volume is a cloud that does not render, and silence about it is
			// what made this node look finished for as long as it did.
			UtilityFunctions::push_error(
					"proc-nodes: Volume to Mesh could not compile this graph to a "
					"density function, so its ProcVolume carries no GLSL and will "
					"render nothing. See the emission error above for the node "
					"that refused.");
		}
	}
	geo->set_volume(vol);

	// ── AND THE MESH, which is what the node is named for ────────────────────
	//
	// Isosurface extraction: density field -> constructed mesh. The extractor is
	// dc-terrain's libdc -- Dual Contouring, octree, QEF -- compiled into this
	// library rather than reimplemented, because a second copy of an algorithm
	// is the failure this repo keeps paying to undo.
	const int resolution = p_ctx.read_int(p_node_idx, 1);
	if (resolution > 0 && p_ctx.graph != nullptr) {
		const Vector3 bmin = p_ctx.read_vector(p_node_idx, 2);
		const Vector3 bsize = p_ctx.read_vector(p_node_idx, 3);
		const float threshold = p_ctx.read_float(p_node_idx, 4);
		const float span = MAX(MAX(bsize.x, bsize.y), bsize.z);
		if (span > 0.0f) {
			// const_cast: evaluate_density_batch caches a plan and a scratch
			// geometry, so it is non-const for reasons that are bookkeeping.
			// The graph is not modified.
			ProcNodeGraph *g = const_cast<ProcNodeGraph *>(p_ctx.graph);
			GraphDensityGenerator gen(g, threshold);
			const float voxel = span / (float)resolution;
			const float origin[3] = { (float)bmin.x, (float)bmin.y, (float)bmin.z };

			meshing_ = true;
			DensityGrid grid;
			grid.allocate(resolution, voxel, origin);
			grid.fill_generator(&gen, voxel);
			grid.compute_normals();
			DCMesherConfig cfg;
			DCMeshData md = dc_mesh_from_grid(grid, cfg);
			meshing_ = false;

			const int vcount = (int)md.positions.size() / 3;
			if (vcount == 0 || md.indices.empty()) {
				// ⛔ THE EXTRACTOR IS FINE. THE CPU DENSITY IS NOT.
				//
				// SphereSdfNode::execute writes its RADIUS as a "density proxy"
				// -- its own comment says so -- and VolumeBooleanNode combines
				// those proxies, so the field handed to Dual Contouring is a
				// constant and a constant has no zero crossing. The volume
				// nodes have a real compile_glsl and a stub execute(); only the
				// GPU half was ever written.
				//
				// Measured 2026-09-06: a unit-sphere graph returns 1.0 at every
				// point, inside and outside.
				//
				// Fixing it means the volume nodes evaluating per POSITION on
				// the CPU -- taking a Vector input that defaults to the implicit
				// Position field, the way the texture nodes do -- not anything
				// about the extractor, which is wired and reachable.
				UtilityFunctions::push_warning(
						"proc-nodes: Volume to Mesh extracted no surface. The volume "
						"nodes have no CPU implementation -- SphereSDF writes its "
						"radius as a placeholder -- so the density field is constant "
						"and has no zero crossing. The GLSL path is unaffected.");
			}
			if (vcount > 0 && !md.indices.empty()) {
				Ref<ProcMesh> mesh = geo->ensure_mesh();
				mesh->positions.resize(vcount);
				Vector3 *pw = mesh->positions.ptrw();
				for (int i = 0; i < vcount; i++) {
					pw[i] = Vector3(md.positions[i * 3], md.positions[i * 3 + 1],
							md.positions[i * 3 + 2]);
				}
				if ((int)md.normals.size() == vcount * 3) {
					mesh->normals.resize(vcount);
					Vector3 *nw = mesh->normals.ptrw();
					for (int i = 0; i < vcount; i++) {
						nw[i] = Vector3(md.normals[i * 3], md.normals[i * 3 + 1],
								md.normals[i * 3 + 2]);
					}
				}
				mesh->indices.resize((int)md.indices.size());
				int32_t *iw = mesh->indices.ptrw();
				for (size_t i = 0; i < md.indices.size(); i++) {
					iw[i] = md.indices[i];
				}
				// Dual Contouring emits TRIANGLES, so any ngon grouping the
				// input had is gone -- clearing face_offsets is what every
				// topology-changing operation here does.
				mesh->face_offsets.clear();
			}
		}
	}
	p_ctx.write_geometry(p_node_idx, 0, geo);
}

String VolumeToMeshNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// Read density_%d_input, which nothing declared. It is a pass-through, and
	// its output is a GEOMETRY socket with no GLSL spelling, so there is nothing
	// to declare: the value it forwards is its input, and the density function
	// has no way to return it. See docs/glsl-emitter-design.md -- choosing the
	// graph's OUTPUT node is not something this emitter can express yet.
	return String();
}

} // namespace godot