OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / src / graph / graph_executor.cpp

1761 lines · cpp ref:
#include "graph_executor.h"
#include "../nodes/zone_nodes.h"
#include "domain_adapt.h"
#include <godot_cpp/classes/rd_uniform.hpp>
#include <godot_cpp/classes/rendering_device.hpp>
#include <godot_cpp/classes/rendering_server.hpp>
#include <godot_cpp/classes/time.hpp>
#include <godot_cpp/templates/hash_set.hpp>
#include <godot_cpp/variant/utility_functions.hpp>

namespace godot {

// --- ProcEvalContext ---

SocketValue ProcEvalContext::read_input(int p_node_idx, int p_socket_idx) {
	SocketValue v = _read_input_raw(p_node_idx, p_socket_idx);
	// A promise is redeemed the moment a context exists, WHEREVER it is read.
	// That is what lets intermediate nodes -- SeparateXYZ, Compare -- carry a
	// context field without knowing anything about laziness.
	if (v.is_lazy && context_geometry.is_valid()) {
		return materialize(v, context_geometry);
	}
	return v;
}

Vector<SocketValue> ProcEvalContext::read_input_multi(int p_node_idx, int p_socket_idx) {
	Vector<SocketValue> out;
	const int links = graph->get_input_link_count(p_node_idx, p_socket_idx);
	if (links == 0) {
		// Unconnected: the socket's own default, so a multi-input behaves like
		// an ordinary one when nothing is wired to it.
		out.push_back(read_input(p_node_idx, p_socket_idx));
		return out;
	}
	for (int k = 0; k < links; k++) {
		int from_node = -1;
		int from_socket = 0;
		if (!graph->get_input_link_at(p_node_idx, p_socket_idx, k, from_node, from_socket)) {
			continue;
		}
		const int slot = from_node * max_output_sockets + from_socket;
		if (from_socket < 0 || from_socket >= max_output_sockets ||
				slot < 0 || slot >= output_values.size()) {
			continue;
		}
		out.push_back(output_values[slot]);
	}
	return out;
}

SocketValue ProcEvalContext::_read_input_raw(int p_node_idx, int p_socket_idx) {
	// Check if connected
	int src_node = graph->get_input_source_node(p_node_idx, p_socket_idx);
	if (src_node >= 0) {
		int src_socket = graph->get_input_source_socket(p_node_idx, p_socket_idx);
		// Belt as well as braces: connect_sockets refuses an out-of-range
		// socket now, but this indexing is what turned such a connection into
		// a read of another node's slot, so it is bounds-checked at the point
		// of the arithmetic too.
		const int slot = src_node * max_output_sockets + src_socket;
		if (src_socket < 0 || src_socket >= max_output_sockets ||
				slot < 0 || slot >= output_values.size()) {
			return SocketValue();
		}
		return output_values[slot];
	}

	// Not connected — use default
	Ref<ProcNode> node = graph->get_node(p_node_idx);
	if (node.is_valid() && p_socket_idx < node->get_input_count()) {
		const SocketDef &def = node->get_input_def(p_socket_idx);
		return SocketValue::from_variant(def.default_value, def.type);
	}

	return SocketValue();
}

// Who binds the context, and who waits for one.
//
// A node with a GEOMETRY input is a consumer: it knows what it is operating on,
// so it binds that geometry and every field it reads resolves against it. That
// is Blender's rule -- a field is evaluated on the consumer's domain.
//
// A node without one (Position, SeparateXYZ, Compare) cannot know, so if it is
// fed a promise it defers, handing promises of its own downstream until a
// consumer finally supplies geometry.
//
// A first attempt deferred on ANY lazy input, which caught SetPosition itself --
// it has a lazy input AND the geometry to resolve it, and deferring made it emit
// a promise where a mesh belonged.
//
// Returns true if the node should be SKIPPED this pass.
static bool _bind_context_or_defer(ProcEvalContext &p_ctx, int p_node_idx,
		const Ref<ProcNode> &p_node, Ref<ProcGeometry> &r_prev_context, bool &r_bound) {
	r_bound = false;
	if (p_ctx.context_geometry.is_valid()) {
		return false; // already inside a materialize; this is the real evaluation
	}

	int geo_input = -1;
	for (int i = 0; i < p_node->get_input_count(); i++) {
		if (p_node->get_input_def(i).type == SocketType::Geometry) {
			geo_input = i;
			break;
		}
	}

	if (geo_input >= 0) {
		Ref<ProcGeometry> geo = p_ctx.read_geometry(p_node_idx, geo_input);
		if (geo.is_valid()) {
			r_prev_context = p_ctx.context_geometry;
			p_ctx.context_geometry = geo;
			r_bound = true;
		}
		return false;
	}

	for (int i = 0; i < p_node->get_input_count(); i++) {
		if (p_ctx._read_input_raw(p_node_idx, i).is_lazy) {
			for (int o = 0; o < p_node->get_output_count(); o++) {
				SocketValue sv;
				sv.type = p_node->get_output_def(o).type;
				sv.is_lazy = true;
				sv.lazy_node = p_node_idx;
				sv.lazy_socket = o;
				p_ctx.write_output(p_node_idx, o, sv);
			}
			return true;
		}
	}
	return false;
}

SocketValue ProcEvalContext::materialize(const SocketValue &p_val,
		const Ref<ProcGeometry> &p_geo) {
	if (!p_val.is_lazy || graph == nullptr || p_val.lazy_node < 0) {
		return p_val;
	}
	Ref<ProcNode> producer = graph->get_node(p_val.lazy_node);
	if (producer.is_null()) {
		return p_val;
	}
	// Re-run the producer with the geometry bound, then take what it wrote.
	// Restoring the previous context keeps nesting honest.
	Ref<ProcGeometry> prev = context_geometry;
	context_geometry = p_geo;
	producer->execute(*this, p_val.lazy_node);
	context_geometry = prev;
	int slot = p_val.lazy_node * max_output_sockets + p_val.lazy_socket;
	if (slot < 0 || slot >= output_values.size()) {
		return p_val;
	}
	return output_values[slot];
}

SocketValue ProcEvalContext::materialize_on(const SocketValue &p_val,
		const Ref<ProcGeometry> &p_geo, AttrDomain p_domain) {
	const AttrDomain prev = context_domain;
	context_domain = p_domain;
	SocketValue v = materialize(p_val, p_geo);
	context_domain = prev;
	if (!v.is_field || v.domain == p_domain || p_geo.is_null()) {
		return v;
	}
	bool ok = false;
	SocketValue adapted = adapt_field_to_domain(v, v.domain, p_domain, p_geo->get_mesh(), &ok);
	if (!ok) {
		UtilityFunctions::push_warning(
				"proc-nodes: no domain adaptation from ", (int)v.domain, " to ",
				(int)p_domain, "; the field is used unadapted and element i "
				"is NOT element i of the consumer's domain.");
		return v;
	}
	return adapted;
}

bool ProcEvalContext::is_output_needed(int p_node_idx, int p_socket_idx) const {
	if (output_needed.is_empty() || max_output_sockets <= 0) {
		return true; // no liveness information: behave as before
	}
	const int slot = p_node_idx * max_output_sockets + p_socket_idx;
	if (slot < 0 || slot >= output_needed.size()) {
		return true;
	}
	return output_needed[slot];
}

bool ProcEvalContext::is_input_connected(int p_node_idx, int p_socket_idx) const {
	if (graph == nullptr) {
		return false;
	}
	const Vector<ProcConnection> &conns = graph->get_connections();
	for (int i = 0; i < conns.size(); i++) {
		if (conns[i].to_node == p_node_idx && conns[i].to_socket == p_socket_idx) {
			return true;
		}
	}
	return false;
}

float ProcEvalContext::read_float(int p_node_idx, int p_socket_idx) {
	return read_input(p_node_idx, p_socket_idx).to_float();
}

int32_t ProcEvalContext::read_int(int p_node_idx, int p_socket_idx) {
	return read_input(p_node_idx, p_socket_idx).to_int();
}

bool ProcEvalContext::read_bool(int p_node_idx, int p_socket_idx) {
	return read_input(p_node_idx, p_socket_idx).to_bool();
}

Vector3 ProcEvalContext::read_vector(int p_node_idx, int p_socket_idx) {
	return read_input(p_node_idx, p_socket_idx).to_vector();
}

Color ProcEvalContext::read_color(int p_node_idx, int p_socket_idx) {
	return read_input(p_node_idx, p_socket_idx).to_color();
}

Quaternion ProcEvalContext::read_rotation(int p_node_idx, int p_socket_idx) {
	return read_input(p_node_idx, p_socket_idx).quat_at(0);
}

Projection ProcEvalContext::read_matrix(int p_node_idx, int p_socket_idx) {
	return read_input(p_node_idx, p_socket_idx).mat_at(0);
}

Ref<ProcGeometry> ProcEvalContext::read_geometry(int p_node_idx, int p_socket_idx) {
	SocketValue val = read_input(p_node_idx, p_socket_idx);
	return val.geometry;
}

void ProcEvalContext::write_output(int p_node_idx, int p_socket_idx, const SocketValue &p_val) {
	output_values.ptrw()[p_node_idx * max_output_sockets + p_socket_idx] = p_val;
}

void ProcEvalContext::write_float(int p_node_idx, int p_socket_idx, float p_val) {
	write_output(p_node_idx, p_socket_idx, SocketValue::from_float(p_val));
}

void ProcEvalContext::write_int(int p_node_idx, int p_socket_idx, int32_t p_val) {
	write_output(p_node_idx, p_socket_idx, SocketValue::from_int(p_val));
}

void ProcEvalContext::write_bool(int p_node_idx, int p_socket_idx, bool p_val) {
	write_output(p_node_idx, p_socket_idx, SocketValue::from_bool(p_val));
}

void ProcEvalContext::write_vector(int p_node_idx, int p_socket_idx, const Vector3 &p_val) {
	write_output(p_node_idx, p_socket_idx, SocketValue::from_vector(p_val));
}

void ProcEvalContext::write_color(int p_node_idx, int p_socket_idx, const Color &p_val) {
	write_output(p_node_idx, p_socket_idx, SocketValue::from_color(p_val));
}

void ProcEvalContext::write_rotation(int p_node_idx, int p_socket_idx, const Quaternion &p_val) {
	write_output(p_node_idx, p_socket_idx, SocketValue::from_rotation(p_val));
}

void ProcEvalContext::write_matrix(int p_node_idx, int p_socket_idx, const Projection &p_val) {
	write_output(p_node_idx, p_socket_idx, SocketValue::from_matrix(p_val));
}

void ProcEvalContext::write_geometry(int p_node_idx, int p_socket_idx, const Ref<ProcGeometry> &p_val) {
	write_output(p_node_idx, p_socket_idx, SocketValue::from_geometry(p_val));
}

void ProcEvalContext::write_float_field(int p_node_idx, int p_socket_idx, const PackedFloat32Array &p_arr,
		AttrDomain p_domain) {
	// The factory has no domain parameter, so set it here -- delegating and
	// ignoring p_domain is what made four of these six writers silently
	// discard the caller's domain.
	SocketValue sv = SocketValue::from_float_field(p_arr);
	sv.domain = p_domain;
	write_output(p_node_idx, p_socket_idx, sv);
}

void ProcEvalContext::write_int_field(int p_node_idx, int p_socket_idx, const PackedInt32Array &p_arr,
		AttrDomain p_domain) {
	// The factory has no domain parameter, so set it here -- delegating and
	// ignoring p_domain is what made four of these six writers silently
	// discard the caller's domain.
	SocketValue sv = SocketValue::from_int_field(p_arr);
	sv.domain = p_domain;
	write_output(p_node_idx, p_socket_idx, sv);
}

void ProcEvalContext::write_vec_field(int p_node_idx, int p_socket_idx, const PackedVector3Array &p_arr,
		AttrDomain p_domain) {
	// The factory has no domain parameter, so set it here -- delegating and
	// ignoring p_domain is what made four of these six writers silently
	// discard the caller's domain.
	SocketValue sv = SocketValue::from_vec_field(p_arr);
	sv.domain = p_domain;
	write_output(p_node_idx, p_socket_idx, sv);
}

void ProcEvalContext::write_color_field(int p_node_idx, int p_socket_idx, const PackedColorArray &p_arr,
		AttrDomain p_domain) {
	// The factory has no domain parameter, so set it here -- delegating and
	// ignoring p_domain is what made four of these six writers silently
	// discard the caller's domain.
	SocketValue sv = SocketValue::from_color_field(p_arr);
	sv.domain = p_domain;
	write_output(p_node_idx, p_socket_idx, sv);
}

void ProcEvalContext::write_rot_field(int p_node_idx, int p_socket_idx, const PackedVector4Array &p_arr,
		AttrDomain p_domain) {
	SocketValue sv;
	sv.type = SocketType::Rotation;
	sv.is_field = true;
	sv.domain = p_domain;
	sv.field_size = p_arr.size();
	sv.quat_array = p_arr;
	write_output(p_node_idx, p_socket_idx, sv);
}

void ProcEvalContext::write_mat_field(int p_node_idx, int p_socket_idx, const PackedFloat32Array &p_arr,
		AttrDomain p_domain) {
	SocketValue sv;
	sv.type = SocketType::Matrix;
	sv.is_field = true;
	sv.domain = p_domain;
	sv.field_size = p_arr.size() / 16;
	sv.mat_array = p_arr;
	write_output(p_node_idx, p_socket_idx, sv);
}

// --- ExecutionPlan ---

int ExecutionPlan::find_gpu_batch(int p_exec_order_index) const {
	for (int i = 0; i < gpu_batches.size(); i++) {
		if (gpu_batches[i].first_index == p_exec_order_index) {
			return i;
		}
	}
	return -1;
}

void ExecutionPlan::destroy_gpu_resources(RenderingDevice *p_rd) {
	for (int i = 0; i < gpu_batches.size(); i++) {
		GpuCompiler::destroy_pipeline(p_rd, gpu_batches.ptrw()[i]);
	}
	gpu_batches.clear();
}

bool ExecutionPlan::has_simulation_zones() const {
	for (int i = 0; i < zones.size(); i++) {
		if (zones[i].zone_type == ProcZoneInfo::ZONE_SIMULATION) {
			return true;
		}
	}
	return false;
}

// --- GraphExecutor ---

static bool _is_gpu_geometry_node(const Ref<ProcNode> &p_node) {
	if (p_node.is_null()) {
		return false;
	}
	StringName type = p_node->get_node_type();
	return type == StringName("Displace") || type == StringName("SetPosition") || type == StringName("TransformGeometry");
}

ExecutionPlan GraphExecutor::compile(const ProcNodeGraph &p_graph) {
	ExecutionPlan plan;
	int node_count = p_graph.get_node_count();

	if (node_count == 0) {
		return plan;
	}

	// Kahn's algorithm for topological sort
	const Vector<ProcConnection> &conns = p_graph.get_connections();

	// Count in-degrees
	Vector<int> in_degree;
	in_degree.resize(node_count);
	int *deg = in_degree.ptrw();
	for (int i = 0; i < node_count; i++) {
		deg[i] = 0;
	}
	for (int i = 0; i < conns.size(); i++) {
		deg[conns[i].to_node]++;
	}

	// Seed queue with zero in-degree nodes
	Vector<int> queue;
	for (int i = 0; i < node_count; i++) {
		if (deg[i] == 0) {
			queue.push_back(i);
		}
	}

	// Process
	plan.execution_order.clear();
	int head = 0;
	while (head < queue.size()) {
		int node_idx = queue[head++];
		plan.execution_order.push_back(node_idx);

		for (int i = 0; i < conns.size(); i++) {
			if (conns[i].from_node == node_idx) {
				deg[conns[i].to_node]--;
				if (deg[conns[i].to_node] == 0) {
					queue.push_back(conns[i].to_node);
				}
			}
		}
	}

	if (plan.execution_order.size() != node_count) {
		UtilityFunctions::push_warning("ProcNodes: Graph has cycles! Only acyclic portion will execute.");
	}

	// Find output node
	plan.output_node = p_graph.find_output_node();
	plan.output_socket = 0;

	// Detect zones
	_detect_zones(p_graph, plan);

	// Detect GPU-batchable runs
	_detect_gpu_batches(p_graph, plan);

	return plan;
}

void GraphExecutor::_detect_zones(const ProcNodeGraph &p_graph, ExecutionPlan &p_plan) {
	int node_count = p_graph.get_node_count();
	p_plan.node_zone_map.resize(node_count);
	for (int i = 0; i < node_count; i++) {
		p_plan.node_zone_map.ptrw()[i] = -1;
	}

	// Collect zone inputs and outputs by zone_id
	HashMap<int, int> zone_inputs;  // zone_id → node_idx
	HashMap<int, int> zone_outputs; // zone_id → node_idx

	for (int i = 0; i < node_count; i++) {
		Ref<ProcNode> node = p_graph.get_node(i);
		if (node.is_null()) continue;

		String type = node->get_node_type();
		if (type.ends_with("ZoneInput")) {
			Variant zid = node->get("zone_id");
			if (zid.get_type() == Variant::INT) {
				zone_inputs[zid.operator int()] = i;
			}
		} else if (type.ends_with("ZoneOutput")) {
			Variant zid = node->get("zone_id");
			if (zid.get_type() == Variant::INT) {
				zone_outputs[zid.operator int()] = i;
			}
		}
	}

	// Match pairs
	const Vector<ProcConnection> &conns = p_graph.get_connections();

	for (const KeyValue<int, int> &kv : zone_inputs) {
		int zid = kv.key;
		int input_idx = kv.value;

		if (!zone_outputs.has(zid)) continue;
		int output_idx = zone_outputs[zid];

		// Find body nodes: BFS forward from input through connections
		HashSet<int> reachable_from_input;
		Vector<int> bfs_queue;
		bfs_queue.push_back(input_idx);
		reachable_from_input.insert(input_idx);

		int bfs_head = 0;
		while (bfs_head < bfs_queue.size()) {
			int curr = bfs_queue[bfs_head++];
			// STOP AT THE ZONE OUTPUT. Walking through it swallows the whole
			// downstream tail of the graph into the zone body -- see below.
			if (curr == output_idx) {
				continue;
			}
			for (int c = 0; c < conns.size(); c++) {
				if (conns[c].from_node == curr) {
					int next = conns[c].to_node;
					if (!reachable_from_input.has(next)) {
						reachable_from_input.insert(next);
						bfs_queue.push_back(next);
					}
				}
			}
		}

		// THE BODY IS THE INTERSECTION, not the forward cone. A node belongs to
		// the zone only if it is reachable FROM the input AND reaches the
		// OUTPUT. The forward walk alone put every node downstream of the zone
		// into the body -- Group Output included -- and body nodes are skipped
		// in the main execution loop, so the graph's own output node never ran
		// and a bridged zone baked to NOTHING.
		//
		// That is why zones appeared to be broken end to end: the zone itself
		// iterated correctly the whole time (instrumented: 3 verts in, 3 verts
		// through every iteration, 3 verts out) and its result was then dropped
		// on the floor by a node that had been quietly disabled.
		{
			HashSet<int> reaches_output;
			Vector<int> back_queue;
			back_queue.push_back(output_idx);
			reaches_output.insert(output_idx);
			int back_head = 0;
			while (back_head < back_queue.size()) {
				int curr = back_queue[back_head++];
				if (curr == input_idx) {
					continue;
				}
				for (int c = 0; c < conns.size(); c++) {
					if (conns[c].to_node == curr) {
						int prev = conns[c].from_node;
						if (!reaches_output.has(prev)) {
							reaches_output.insert(prev);
							back_queue.push_back(prev);
						}
					}
				}
			}
			Vector<int> keep;
			for (const int &ni : reachable_from_input) {
				if (reaches_output.has(ni)) {
					keep.push_back(ni);
				}
			}
			reachable_from_input.clear();
			for (int i = 0; i < keep.size(); i++) {
				reachable_from_input.insert(keep[i]);
			}
			// The pair themselves are always the zone's, even with an empty body.
			reachable_from_input.insert(input_idx);
			reachable_from_input.insert(output_idx);
		}

		// Build zone info
		ProcZoneInfo zone;
		zone.zone_id = zid;
		zone.input_node = input_idx;
		zone.output_node = output_idx;

		// Determine zone type from input node type
		Ref<ProcNode> inp_node = p_graph.get_node(input_idx);
		if (inp_node.is_valid()) {
			String inp_type = inp_node->get_node_type();
			if (inp_type == "RepeatZoneInput") {
				zone.zone_type = ProcZoneInfo::ZONE_REPEAT;
			} else if (inp_type == "ForeachZoneInput") {
				zone.zone_type = ProcZoneInfo::ZONE_FOREACH;
			} else if (inp_type == "SimulationZoneInput") {
				zone.zone_type = ProcZoneInfo::ZONE_SIMULATION;
			}
		}

		// Body nodes in execution order (preserving topological sort)
		for (int j = 0; j < p_plan.execution_order.size(); j++) {
			int ni = p_plan.execution_order[j];
			if (reachable_from_input.has(ni)) {
				zone.body_nodes.push_back(ni);
			}
		}

		int zone_idx = p_plan.zones.size();
		p_plan.zones.push_back(zone);

		// Mark body nodes in zone map
		for (int j = 0; j < zone.body_nodes.size(); j++) {
			p_plan.node_zone_map.ptrw()[zone.body_nodes[j]] = zone_idx;
		}
	}

	if (p_plan.zones.size() > 0) {
		UtilityFunctions::print("ProcNodes: Detected ", p_plan.zones.size(), " zone(s)");
	}
}

void GraphExecutor::_execute_zone(ProcEvalContext &p_ctx, const ProcZoneInfo &p_zone, const ExecutionPlan &p_plan) {
	// Dispatch based on zone input node type
	Ref<ProcNode> input_node = p_ctx.graph->get_node(p_zone.input_node);
	if (input_node.is_null()) return;

	StringName type = input_node->get_node_type();
	if (type == StringName("RepeatZoneInput")) {
		_execute_repeat_zone(p_ctx, p_zone, p_plan);
	} else if (type == StringName("ForeachZoneInput")) {
		_execute_foreach_zone(p_ctx, p_zone, p_plan);
	} else if (type == StringName("SimulationZoneInput")) {
		_execute_simulation_zone(p_ctx, p_zone, p_plan);
	} else {
		UtilityFunctions::push_warning("ProcNodes: Unknown zone type: ", type);
	}
}

void GraphExecutor::_execute_repeat_zone(ProcEvalContext &p_ctx, const ProcZoneInfo &p_zone, const ExecutionPlan &p_plan) {
	// Read iterations from ZoneInput's input socket 0
	int iterations = p_ctx.read_int(p_zone.input_node, 0);
	iterations = CLAMP(iterations, 0, 1000); // Safety clamp

	// A ZONE CARRIES A LIST OF STATE ITEMS, NOT ONE GEOMETRY. Blender's repeat
	// zone has an item list; each item is a value threaded through every
	// iteration. This carried socket 1 alone, so a zone with a float or a
	// second geometry item lost everything but the first.
	//
	//   input node   in [Iterations] + items      out [Iteration] + items
	//   output node  in items                     out items
	Ref<ProcNode> in_node = p_ctx.graph->get_node(p_zone.input_node);
	Ref<ProcNode> out_node = p_ctx.graph->get_node(p_zone.output_node);
	const int item_count = in_node.is_valid() ? MAX(in_node->get_input_count() - 1, 0) : 0;

	Vector<SocketValue> state;
	state.resize(item_count);
	for (int k = 0; k < item_count; k++) {
		state.write[k] = p_ctx.read_input(p_zone.input_node, 1 + k);
	}

	UtilityFunctions::print("ProcNodes: RepeatZone executing ", iterations,
			" iterations over ", item_count, " item(s)");

	for (int i = 0; i < iterations; i++) {
		// Set ZoneInput outputs: iteration index, then the current state
		p_ctx.write_int(p_zone.input_node, 0, i);
		for (int k = 0; k < item_count; k++) {
			p_ctx.write_output(p_zone.input_node, 1 + k, state[k]);
		}

		// Execute body nodes in topological order (skip input and output nodes)
		for (int j = 0; j < p_zone.body_nodes.size(); j++) {
			int body_idx = p_zone.body_nodes[j];
			if (body_idx == p_zone.input_node || body_idx == p_zone.output_node) {
				continue;
			}

			Ref<ProcNode> node = p_ctx.graph->get_node(body_idx);
			if (node.is_valid()) {
				node->execute(p_ctx, body_idx);
			}
		}

		// The output node's inputs are the next iteration's state.
		for (int k = 0; k < item_count; k++) {
			state.write[k] = p_ctx.read_input(p_zone.output_node, k);
		}
	}

	for (int k = 0; k < item_count; k++) {
		p_ctx.write_output(p_zone.output_node, k, state[k]);
	}
}

void GraphExecutor::_execute_foreach_zone(ProcEvalContext &p_ctx, const ProcZoneInfo &p_zone, const ExecutionPlan &p_plan) {
	Ref<ProcGeometry> input_geo = p_ctx.read_geometry(p_zone.input_node, 0);
	Ref<ProcNode> in_node = p_ctx.graph->get_node(p_zone.input_node);
	ForeachZoneOutputNode *out_node =
			Object::cast_to<ForeachZoneOutputNode>(p_ctx.graph->get_node(p_zone.output_node).ptr());
	if (input_geo.is_null() || input_geo->get_mesh().is_null() || out_node == nullptr) {
		p_ctx.write_geometry(p_zone.output_node, 0, input_geo);
		return;
	}

	// THREE ITEM LISTS, and each has a different job:
	//   input_items       sampled per element, readable inside the body
	//   main_items        gathered per element, returned as FIELDS on the
	//                     original geometry
	//   generation_items  produced per element and JOINED; Blender seeds the
	//                     list with the geometry itself
	const int input_items = MAX(in_node->get_input_count() - 2, 0);
	const int main_items = out_node->get_main_count();
	const int gen_items = out_node->get_generation_count();

	Ref<ProcMesh> mesh = input_geo->get_mesh();
	int vert_count = mesh->vertex_count();
	vert_count = MIN(vert_count, 10000); // Safety clamp for per-element iteration

	// The SELECTION field, which did not exist on this node before.
	SocketValue selection = p_ctx.materialize(p_ctx.read_input(p_zone.input_node, 1), input_geo);
	// Each input item's field, materialised once against the whole geometry and
	// then read per element -- not re-evaluated inside the loop.
	Vector<SocketValue> in_fields;
	in_fields.resize(input_items);
	for (int k = 0; k < input_items; k++) {
		in_fields.write[k] = p_ctx.materialize(p_ctx.read_input(p_zone.input_node, 2 + k), input_geo);
	}

	Ref<ProcGeometry> joined;
	joined.instantiate();
	// Gathered per SELECTED element, in element order.
	Vector<PackedFloat32Array> main_gathered;
	main_gathered.resize(main_items);
	Vector<PackedFloat32Array> gen_gathered;
	gen_gathered.resize(gen_items);
	PackedInt32Array selected_index;

	UtilityFunctions::print("ProcNodes: ForeachZone over ", vert_count,
			" element(s), items in/main/gen = ", input_items, "/", main_items, "/", gen_items);

	for (int i = 0; i < vert_count; i++) {
		if (!selection.bool_at(i)) {
			continue;
		}
		// THE ELEMENT IS A ONE-VERTEX MESH, not a point cloud. Measured:
		// Blender's Element on a mesh point domain is a MESH, so joining the
		// elements gives a mesh and its vertex count is the element count.
		// Ours made a ProcPointCloud, so the join produced points, the mesh was
		// empty, and a foreach that generated geometry baked to nothing.
		Ref<ProcGeometry> element;
		element.instantiate();
		Ref<ProcMesh> em = element->ensure_mesh();
		em->positions.push_back(mesh->positions[i]);

		// Set ZoneInput outputs: Index, Element, then the input items.
		p_ctx.write_int(p_zone.input_node, 0, i);
		p_ctx.write_geometry(p_zone.input_node, 1, element);
		for (int k = 0; k < input_items; k++) {
			SocketValue one = in_fields[k];
			// Hand the body ONE element's value, not the whole field.
			SocketValue scalar;
			scalar.type = one.type;
			scalar.f = one.float_at(i);
			scalar.i = one.int_at(i);
			scalar.b = one.bool_at(i);
			scalar.vec = one.vec_at(i);
			scalar.color = one.color_at(i);
			p_ctx.write_output(p_zone.input_node, 2 + k, scalar);
		}

		// Execute body nodes
		for (int j = 0; j < p_zone.body_nodes.size(); j++) {
			int body_idx = p_zone.body_nodes[j];
			if (body_idx == p_zone.input_node || body_idx == p_zone.output_node) {
				continue;
			}
			Ref<ProcNode> node = p_ctx.graph->get_node(body_idx);
			if (node.is_valid()) {
				node->execute(p_ctx, body_idx);
			}
		}

		selected_index.push_back(i);
		// Main items: one value per element, kept for the field below.
		for (int k = 0; k < main_items; k++) {
			main_gathered.write[k].push_back(p_ctx.read_input(p_zone.output_node, k).float_at(0));
		}
		// Generation items: the geometry ones are joined, the rest gathered.
		for (int k = 0; k < gen_items; k++) {
			SocketValue v = p_ctx.read_input(p_zone.output_node, main_items + k);
			if (v.type == SocketType::Geometry) {
				if (v.geometry.is_valid()) {
					joined->join(v.geometry);
				}
			} else {
				gen_gathered.write[k].push_back(v.float_at(0));
			}
		}
	}

	// Socket 0 is the MAIN geometry -- the original, carrying the main items as
	// fields over it. Not the joined result; that is a generation item.
	p_ctx.write_geometry(p_zone.output_node, 0, input_geo);
	for (int k = 0; k < main_items; k++) {
		// Gathered over SELECTED elements only, so the field is that long. A
		// full-domain field would need scattering back through selected_index;
		// stated rather than implied.
		p_ctx.write_float_field(p_zone.output_node, 1 + k, main_gathered[k], AttrDomain::Point);
	}
	for (int k = 0; k < gen_items; k++) {
		const int sock = 1 + main_items + k;
		if (out_node->get_output_def(sock).type == SocketType::Geometry) {
			p_ctx.write_geometry(p_zone.output_node, sock, joined);
		} else {
			p_ctx.write_float_field(p_zone.output_node, sock, gen_gathered[k], AttrDomain::Point);
		}
	}
}

void GraphExecutor::_execute_simulation_zone(ProcEvalContext &p_ctx, const ProcZoneInfo &p_zone, const ExecutionPlan &p_plan) {
	int zone_id = p_zone.zone_id;

	//   input node   in items                 out [Delta Time] + items
	//   output node  in [Skip] + items        out items
	Ref<ProcNode> in_node = p_ctx.graph->get_node(p_zone.input_node);
	const int item_count = in_node.is_valid() ? in_node->get_input_count() : 0;

	// State items are carried through the frame; the GEOMETRY item is also
	// cached across frames, which is what `simulation_cache` holds.
	//
	// NOT YET PERSISTED: a non-geometry state item. The cache is keyed
	// zone_id -> one geometry, so a float or vector item restarts from its
	// initial value every frame rather than continuing. Said here rather than
	// left to be discovered; widening the cache reaches into ProcGeometryNode
	// and is its own change.
	Vector<SocketValue> state;
	state.resize(item_count);
	for (int k = 0; k < item_count; k++) {
		state.write[k] = p_ctx.read_input(p_zone.input_node, k);
	}
	int geo_item = -1;
	for (int k = 0; k < item_count; k++) {
		if (in_node.is_valid() && in_node->get_input_def(k).type == SocketType::Geometry) {
			geo_item = k;
			break;
		}
	}
	if (geo_item >= 0 && p_ctx.simulation_cache && p_ctx.simulation_cache->has(zone_id)) {
		state.write[geo_item] = SocketValue::from_geometry((*p_ctx.simulation_cache)[zone_id]);
	}

	// Set ZoneInput outputs: delta_time, then the state
	p_ctx.write_float(p_zone.input_node, 0, p_ctx.delta_time);
	for (int k = 0; k < item_count; k++) {
		p_ctx.write_output(p_zone.input_node, 1 + k, state[k]);
	}

	UtilityFunctions::print("ProcNodes: SimulationZone id=", zone_id,
			" dt=", p_ctx.delta_time, " items=", item_count,
			" has_cache=", (p_ctx.simulation_cache && p_ctx.simulation_cache->has(zone_id)));

	// Execute body nodes in topological order (skip input and output nodes)
	for (int j = 0; j < p_zone.body_nodes.size(); j++) {
		int body_idx = p_zone.body_nodes[j];
		if (body_idx == p_zone.input_node || body_idx == p_zone.output_node) {
			continue;
		}

		Ref<ProcNode> node = p_ctx.graph->get_node(body_idx);
		if (node.is_valid()) {
			node->execute(p_ctx, body_idx);
		}
	}

	// SKIP is Blender's first input on the output node: hold the incoming
	// state rather than taking the body's result.
	const bool skip = p_ctx.read_bool(p_zone.output_node, 0);
	if (!skip) {
		for (int k = 0; k < item_count; k++) {
			state.write[k] = p_ctx.read_input(p_zone.output_node, 1 + k);
		}
	}

	if (geo_item >= 0 && p_ctx.simulation_cache) {
		(*p_ctx.simulation_cache)[zone_id] = state[geo_item].geometry;
	}

	for (int k = 0; k < item_count; k++) {
		p_ctx.write_output(p_zone.output_node, k, state[k]);
	}
}

Dictionary &GraphExecutor::exec_report() {
	static Dictionary report;
	return report;
}

// A node that contributes ARITHMETIC to a batch rather than geometry: it has
// GLSL, it is not one of the geometry ops, and it produces no geometry. Fusing
// these is the whole point -- a field feeding Set Position's Offset used to be
// computed per vertex on the CPU and then squeezed through a push constant that
// can only carry ONE value, which is why it could not come along.
static bool _is_gpu_value_node(const Ref<ProcNode> &p_node) {
	if (p_node.is_null() || _is_gpu_geometry_node(p_node)) {
		return false;
	}
	if (!p_node->supports_gpu() || p_node->gpu_transparent()) {
		return false;
	}
	for (int i = 0; i < p_node->get_output_count(); i++) {
		if (p_node->get_output_def(i).type == SocketType::Geometry) {
			return false;
		}
	}
	return true;
}

void GraphExecutor::_detect_gpu_batches(const ProcNodeGraph &p_graph, ExecutionPlan &p_plan) {
	// Find contiguous runs of GPU-batchable nodes in execution order
	int batch_start = -1;
	int batch_count = 0;

	for (int i = 0; i < p_plan.execution_order.size(); i++) {
		int node_idx = p_plan.execution_order[i];
		Ref<ProcNode> node = p_graph.get_node(node_idx);

		if (_is_gpu_geometry_node(node)) {
			if (batch_start < 0) {
				batch_start = i;
			}
			batch_count++;
		} else {
			if (batch_count > 0) {
				GpuBatch batch;
				batch.first_index = batch_start;
				batch.node_count = batch_count;
				p_plan.gpu_batches.push_back(batch);
			}
			batch_start = -1;
			batch_count = 0;
		}
	}
	// Don't forget trailing batch
	if (batch_count > 0) {
		GpuBatch batch;
		batch.first_index = batch_start;
		batch.node_count = batch_count;
		p_plan.gpu_batches.push_back(batch);
	}

	// ── EXTEND EACH BATCH BACKWARDS OVER THE VALUE NODES FEEDING IT ─────────
	//
	// ⚠ CONTIGUOUS ONLY, and deliberately: a batch is a SPAN of the execution
	// order (first_index + node_count) and the main loop skips it by advancing
	// the index, so a cone with an unrelated node interleaved cannot be
	// expressed without changing that contract. Topological order groups a
	// producer with its consumer in the ordinary case; when it does not, the
	// value node simply stays on the CPU as before. Nothing gets WRONG, only
	// slower, which is the right way round for a restriction.
	for (int b = 0; b < p_plan.gpu_batches.size(); b++) {
		GpuBatch &batch = p_plan.gpu_batches.ptrw()[b];
		int start = batch.first_index;
		while (start > 0) {
			const int prev_order = start - 1;
			// Never swallow a node another batch already owns.
			bool taken = false;
			for (int o = 0; o < p_plan.gpu_batches.size(); o++) {
				const GpuBatch &other = p_plan.gpu_batches[o];
				if (o != b && prev_order >= other.first_index &&
						prev_order < other.first_index + other.node_count) {
					taken = true;
					break;
				}
			}
			if (taken) {
				break;
			}
			const int prev_idx = p_plan.execution_order[prev_order];
			Ref<ProcNode> prev = p_graph.get_node(prev_idx);
			if (!_is_gpu_value_node(prev)) {
				break;
			}
			// ⛔ EVERY CONSUMER MUST BE INSIDE THE BATCH. A fused node is part of
			// the SPAN the main loop skips, so its CPU execute() never runs and
			// it writes no output socket. Absorb one that something downstream
			// also reads and that reader gets nothing -- a silently empty value,
			// which is the worst shape of bug this file can produce.
			bool all_consumers_inside = true;
			const PackedInt32Array conns = p_graph.get_connections_flat();
			for (int c = 0; c + 3 < conns.size(); c += 4) {
				if (conns[c] != prev_idx) {
					continue; // not this node's output
				}
				const int consumer = conns[c + 2];
				bool inside = false;
				for (int k = prev_order; k < batch.first_index + batch.node_count; k++) {
					if (p_plan.execution_order[k] == consumer) {
						inside = true;
						break;
					}
				}
				if (!inside) {
					all_consumers_inside = false;
					break;
				}
			}
			if (!all_consumers_inside) {
				break;
			}
			start = prev_order;
		}
		batch.node_count += batch.first_index - start;
		batch.first_index = start;
		batch.fused_nodes.clear();
		for (int k = 0; k < batch.node_count; k++) {
			batch.fused_nodes.push_back(p_plan.execution_order[batch.first_index + k]);
		}
	}

	if (p_plan.gpu_batches.size() > 0) {
		UtilityFunctions::print("ProcNodes: Detected ", p_plan.gpu_batches.size(), " GPU batch(es)");
	}
}

static bool _batch_contains(const GpuBatch &p_batch, int p_node_idx) {
	for (int i = 0; i < p_batch.fused_nodes.size(); i++) {
		if (p_batch.fused_nodes[i] == p_node_idx) {
			return true;
		}
	}
	return false;
}

// ⛔ A CONNECTED INPUT WHOSE PRODUCER IS NOT IN THE SHADER IS A REFUSAL.
//
// GlslEmitContext::in() falls back to the socket's LITERAL when the producer is
// not emitted here. That keeps the shader COMPILING -- and makes it compute
// something else. The bridged mountain's batch was [CombineXYZ, SetPosition]
// with the Switch that carries the height OUTSIDE it, so Combine XYZ's Z read
// its default 0.0 and the displacement vanished: y -2..28.85 on the CPU against
// y -2..2 on the GPU, with no error anywhere. I turned a loud compile failure
// into a quiet wrong answer when I added that fallback.
//
// Fused VALUE nodes were never checked at all -- the dispatch loop `continue`s
// past them before the representability test. This checks every node in the
// batch, and one failure sends the WHOLE batch to the CPU: a partial batch is
// how two halves of a graph end up computing different functions.
static bool _batch_is_representable(ProcEvalContext &p_ctx, const GpuBatch &p_batch) {
	for (int f = 0; f < p_batch.fused_nodes.size(); f++) {
		const int node_idx = p_batch.fused_nodes[f];
		Ref<ProcNode> node = p_ctx.graph->get_node(node_idx);
		if (node.is_null()) {
			continue;
		}
		const PackedInt32Array pc_map = node->get_push_constant_sockets();
		for (int si = 0; si < node->get_input_count(); si++) {
			if (node->get_input_type(si) == (int)SocketType::Geometry) {
				continue;
			}
			if (!p_ctx.is_input_connected(node_idx, si)) {
				continue;
			}
			// Produced inside this shader: it is a local, and correct.
			if (_batch_contains(p_batch, p_ctx.graph->get_input_source_node(node_idx, si))) {
				continue;
			}
			// Otherwise the only channel is a push constant, and only for a
			// value that is ONE value -- never a field.
			if (!pc_map.has(si)) {
				// ⛔ SAY WHY. A refused batch falls back to the CPU silently,
				// and on a 2.58M-vertex mountain that is 17.8 s of Set Position
				// with nothing in the log but "CPU node [SetPosition]". The
				// whole point of the batch is that it does not happen.
				UtilityFunctions::print("  GPU batch REFUSED: node ", node_idx, " (",
						node->get_node_type(), ") socket ", si, " is connected from outside "
						"the batch and has no push-constant slot");
				return false;
			}
			const SocketValue raw = p_ctx._read_input_raw(node_idx, si);
			if (raw.is_field || raw.is_lazy) {
				UtilityFunctions::print("  GPU batch REFUSED: node ", node_idx, " (",
						node->get_node_type(), ") socket ", si,
						raw.is_lazy ? " is a LAZY field" : " is a FIELD",
						" arriving from outside the batch -- a push constant is one value");
				return false;
			}
		}
	}
	return true;
}

void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p_batch, const ExecutionPlan &p_plan) {
	RenderingDevice *rd = p_ctx.gpu_buffers.get_rd();
	if (!rd) {
		return;
	}



	// For Phase 4, process each node in the batch individually.
	// Future phases will fuse multiple nodes into a single shader dispatch.
	for (int b = 0; b < p_batch.node_count; b++) {
		int exec_idx = p_batch.first_index + b;
		int node_idx = p_plan.execution_order[exec_idx];
		Ref<ProcNode> node = p_ctx.graph->get_node(node_idx);
		if (node.is_null()) {
			continue;
		}

		StringName type = node->get_node_type();

		// A fused VALUE node contributes arithmetic to the shader and nothing
		// else: no dispatch, no buffers, no CPU execution. Its result is a local
		// the geometry node below reads.
		if (!_is_gpu_geometry_node(node)) {
			exec_report()[String(type)] = "gpu (fused)";
			continue;
		}

		// TransformGeometry is too complex for Phase 4 — fall back to CPU
		if (type == StringName("TransformGeometry")) {
			exec_report()[String(type)] = "cpu";
			node->execute(p_ctx, node_idx);
			continue;
		}

		// A VALUE THE SHADER CANNOT SEE MEANS THE CPU RUNS THIS NODE.
		//
		// The batch shader's only channel for a socket is the push-constant
		// block, so a non-geometry input that is CONNECTED and not in the
		// node's map is a value the dispatch would silently ignore. Set
		// Position's Selection and Position are exactly that: the CPU applies
		// `new = Selection ? (Position + Offset) : old` per element, and the
		// shader can express none of it. Falling back is the only answer that
		// is not a different function.
		{
			const PackedInt32Array pc_map = node->get_push_constant_sockets();
			bool unrepresentable = false;
			for (int si = 0; si < node->get_input_count(); si++) {
				if (node->get_input_type(si) == (int)SocketType::Geometry) {
					continue;
				}
				if (!p_ctx.is_input_connected(node_idx, si)) {
					continue;
				}
				if (!pc_map.has(si)) {
					unrepresentable = true;
					break;
				}
				// ⛔ A PUSH CONSTANT IS ONE VALUE FOR EVERY VERTEX.
				//
				// Being in the map was treated as proof a socket could cross,
				// and it is not: the packer reads it with read_vector() and
				// writes the single value it gets. Wire a per-vertex FIELD into
				// it -- which is the ordinary way to drive Set Position's
				// Offset -- and the GPU displaces every vertex by the same
				// vector while the CPU displaces each by its own. Measured on a
				// 16x16 grid with Offset <- Position * 0.25: worst |dp| = 3.19
				// between the two paths, on a graph the batch ACCEPTED. Silent,
				// and the mesh was simply wrong.
				//
				// A connected CONSTANT is still fine -- one value is what it is.
				// ...unless the producer is emitted into this same shader, in
				// which case the socket reads that producer's LOCAL and the
				// push-constant slot behind it is never looked at.
				if (_batch_contains(p_batch, p_ctx.graph->get_input_source_node(node_idx, si))) {
					continue;
				}
				const SocketValue raw = p_ctx._read_input_raw(node_idx, si);
				if (raw.is_field || raw.is_lazy) {
					unrepresentable = true;
					break;
				}
			}
			if (unrepresentable) {
				exec_report()[String(type)] = "cpu (a connected input the shader has no channel for)";
				node->execute(p_ctx, node_idx);
				continue;
			}
		}

		// Read input geometry (socket 0 for Displace and SetPosition)
		Ref<ProcGeometry> input_geom = p_ctx.read_geometry(node_idx, 0);
		if (input_geom.is_null() || input_geom->get_mesh().is_null()) {
			// No geometry — CPU fallback
			exec_report()[String(type)] = "cpu (no input geometry)";
			node->execute(p_ctx, node_idx);
			continue;
		}

		Ref<ProcGeometry> out_geom = input_geom->duplicate_geometry();
		Ref<ProcMesh> mesh = out_geom->get_mesh();
		int vert_count = mesh->vertex_count();
		if (vert_count == 0) {
			node->execute(p_ctx, node_idx);
			continue;
		}

		// Ensure normals exist
		if (mesh->normals.size() != vert_count) {
			mesh->recalculate_normals();
		}

		// Compile shader lazily (first execution only)
		// We need a mutable batch for pipeline creation — use const_cast since
		// the batch is stored in the plan which persists across evaluations
		GpuBatch &mutable_batch_ref = const_cast<GpuBatch &>(p_batch);

		// For Phase 4 single-node batches, compile per node type
		if (!mutable_batch_ref.pipeline.is_valid()) {
			// EVERY fused node, in topological order, ending with this one.
			// GpuCompiler::compile_batch has always looped over a list and
			// threaded values through GlslEmitContext; it was simply never
			// handed more than one node.
			Vector<Ref<ProcNode>> batch_nodes;
			Vector<int> batch_indices;
			for (int f = 0; f < p_batch.fused_nodes.size(); f++) {
				const int fidx = p_batch.fused_nodes[f];
				Ref<ProcNode> fnode = p_ctx.graph->get_node(fidx);
				if (fnode.is_null()) {
					continue;
				}
				batch_nodes.push_back(fnode);
				batch_indices.push_back(fidx);
			}
			if (batch_indices.is_empty()) {
				batch_nodes.push_back(node);
				batch_indices.push_back(node_idx);
			}

			String glsl = GpuCompiler::compile_batch(batch_nodes, batch_indices, p_ctx.graph);
			if (glsl.is_empty()) {
				UtilityFunctions::push_warning("ProcNodes: GPU shader compilation failed for ", type, " — falling back to CPU");
				node->execute(p_ctx, node_idx);
				continue;
			}

			if (!GpuCompiler::create_pipeline(rd, glsl, mutable_batch_ref)) {
				UtilityFunctions::push_warning("ProcNodes: GPU pipeline creation failed for ", type, " — falling back to CPU");
				node->execute(p_ctx, node_idx);
				continue;
			}
		}

		exec_report()[String(type)] = "gpu";
		uint64_t t_upload = Time::get_singleton()->get_ticks_usec();

		// Upload input buffers (positions and normals as vec4-padded)
		RID pos_in = p_ctx.gpu_buffers.upload_vec3_buffer(mesh->positions);
		RID norm_in = p_ctx.gpu_buffers.upload_vec3_buffer(mesh->normals);

		// Create output buffers (vec4-padded: 16 bytes per element)
		RID pos_out = p_ctx.gpu_buffers.create_output_buffer(vert_count * 16);
		RID norm_out = p_ctx.gpu_buffers.create_output_buffer(vert_count * 16);

		uint64_t t_uniform = Time::get_singleton()->get_ticks_usec();

		// Build input uniform set (set 0)
		TypedArray<RDUniform> input_uniforms;
		{
			Ref<RDUniform> u_pos;
			u_pos.instantiate();
			u_pos->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
			u_pos->set_binding(0);
			u_pos->add_id(pos_in);
			input_uniforms.push_back(u_pos);

			Ref<RDUniform> u_norm;
			u_norm.instantiate();
			u_norm->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
			u_norm->set_binding(1);
			u_norm->add_id(norm_in);
			input_uniforms.push_back(u_norm);
		}
		RID input_set = rd->uniform_set_create(input_uniforms, mutable_batch_ref.shader, 0);

		// Build output uniform set (set 1)
		TypedArray<RDUniform> output_uniforms;
		{
			Ref<RDUniform> u_pos;
			u_pos.instantiate();
			u_pos->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
			u_pos->set_binding(0);
			u_pos->add_id(pos_out);
			output_uniforms.push_back(u_pos);

			Ref<RDUniform> u_norm;
			u_norm.instantiate();
			u_norm->set_uniform_type(RenderingDevice::UNIFORM_TYPE_STORAGE_BUFFER);
			u_norm->set_binding(1);
			u_norm->add_id(norm_out);
			output_uniforms.push_back(u_norm);
		}
		RID output_set = rd->uniform_set_create(output_uniforms, mutable_batch_ref.shader, 1);

		// Pack push constants (32 bytes: uint32 element_count + 7 floats)
		PackedByteArray push_data;
		push_data.resize(32);
		uint8_t *push_ptr = push_data.ptrw();
		memset(push_ptr, 0, 32);

		// Element count (uint32 at offset 0)
		uint32_t count = (uint32_t)vert_count;
		memcpy(push_ptr, &count, 4);

		// PACK FROM THE NODE'S OWN MAP, not from a per-type `if`.
		//
		// GlslEmitContext::param() walks this same list to decide which
		// `pc.paramN` the node's GLSL reads, so the slot written here and the
		// slot read there are the same walk over the same list.
		//
		// ⛔ THEY WERE NOT. This block used to name sockets by hand, and Set
		// Position's line read socket 1 and called it "the offset vector". Its
		// sockets have been Blender's -- Geometry, Selection, Position, Offset
		// -- since they were matched to Blender's, so socket 1 is SELECTION and
		// the offset is socket 3. Every GPU-batched Set Position was
		// displacing by a boolean and ignoring Offset entirely, and nothing
		// could see it because the CPU path is a different function.
		//
		// Float, Int and Bool take one slot; Vector takes three. Nothing else
		// can cross, which is what the connectivity refusal above enforces.
		{
			const PackedInt32Array pc_map = node->get_push_constant_sockets();
			int slot = 0;
			for (int m = 0; m < pc_map.size(); m++) {
				const int sock = pc_map[m];
				const int stype = node->get_input_type(sock);
				// A socket whose producer is fused is read from that producer's
				// local, so its slot is never sampled. Skip the READ but keep
				// the slot -- param() walks the same map to compute addresses,
				// and dropping a slot here would shift every one after it.
				const bool fused_src = _batch_contains(
						p_batch, p_ctx.graph->get_input_source_node(node_idx, sock));
				if (stype == (int)SocketType::Vector) {
					if (slot + 3 > 7) {
						break;
					}
					if (fused_src) {
						slot += 3;
						continue;
					}
					const Vector3 v = p_ctx.read_vector(node_idx, sock);
					const float comp[3] = { (float)v.x, (float)v.y, (float)v.z };
					for (int c = 0; c < 3; c++, slot++) {
						memcpy(push_ptr + 4 + slot * 4, &comp[c], 4);
					}
				} else {
					if (slot + 1 > 7) {
						break;
					}
					if (fused_src) {
						slot += 1;
						continue;
					}
					const float f = (stype == (int)SocketType::Int)
							? (float)p_ctx.read_int(node_idx, sock)
							: p_ctx.read_float(node_idx, sock);
					memcpy(push_ptr + 4 + slot * 4, &f, 4);
					slot++;
				}
			}
		}

		uint64_t t_dispatch = Time::get_singleton()->get_ticks_usec();

		// Dispatch compute shader
		int64_t cl = rd->compute_list_begin();
		rd->compute_list_bind_compute_pipeline(cl, mutable_batch_ref.pipeline);
		rd->compute_list_bind_uniform_set(cl, input_set, 0);
		rd->compute_list_bind_uniform_set(cl, output_set, 1);
		rd->compute_list_set_push_constant(cl, push_data, push_data.size());
		rd->compute_list_dispatch(cl, (vert_count + 63) / 64, 1, 1);
		rd->compute_list_end();

		// Submit and sync (blocking)
		rd->submit();
		rd->sync();

		uint64_t t_download = Time::get_singleton()->get_ticks_usec();

		// Download results
		PackedVector3Array new_positions = p_ctx.gpu_buffers.download_vec3_buffer(pos_out, vert_count);
		mesh->positions = new_positions;

		uint64_t t_normals = Time::get_singleton()->get_ticks_usec();

		// Recalculate normals on CPU after displacement
		mesh->recalculate_normals();

		uint64_t t_cleanup = Time::get_singleton()->get_ticks_usec();

		// Free uniform sets (not tracked by buffer manager)
		rd->free_rid(input_set);
		rd->free_rid(output_set);

		UtilityFunctions::print(
				"  GPU breakdown [", vert_count, " verts]: upload=", (t_uniform - t_upload) / 1000.0,
				"ms  uniforms=", (t_dispatch - t_uniform) / 1000.0,
				"ms  dispatch+sync=", (t_download - t_dispatch) / 1000.0,
				"ms  download=", (t_normals - t_download) / 1000.0,
				"ms  normals=", (t_cleanup - t_normals) / 1000.0, "ms");

		// Write output geometry to context
		p_ctx.write_geometry(node_idx, 0, out_geom);
	}
}

Variant GraphExecutor::evaluate_socket_value(const ProcNodeGraph &p_graph,
		int p_node_idx, int p_socket_idx, const Dictionary &p_input_overrides) {
	if (p_node_idx < 0 || p_node_idx >= p_graph.get_node_count()) {
		return Variant();
	}

	ExecutionPlan plan = compile(p_graph);
	if (plan.execution_order.size() == 0) {
		return Variant();
	}

	int max_outputs = 1;
	for (int i = 0; i < p_graph.get_node_count(); i++) {
		Ref<ProcNode> node = p_graph.get_node(i);
		if (node.is_valid() && node->get_output_count() > max_outputs) {
			max_outputs = node->get_output_count();
		}
	}

	ProcEvalContext ctx;
	// The same override execute() takes, applied the same way: a graph reading
	// Position has to be given the points to read.
	if (p_input_overrides.has("geometry")) {
		ctx.input_geometry = p_input_overrides["geometry"];
	}
	ctx.graph = &p_graph;
	ctx.max_output_sockets = max_outputs;
	ctx.output_values.resize(p_graph.get_node_count() * max_outputs);
	ctx.use_gpu = false;

	for (int i = 0; i < plan.execution_order.size(); i++) {
		int idx = plan.execution_order[i];
		if (plan.node_zone_map.size() > idx && plan.node_zone_map[idx] >= 0) {
			continue; // zones need the full executor; value nodes are never in one
		}
		Ref<ProcNode> node = p_graph.get_node(idx);
		if (node.is_valid()) {
			Ref<ProcGeometry> prev;
			bool bound = false;
			if (!_bind_context_or_defer(ctx, idx, node, prev, bound)) {
				node->execute(ctx, idx);
			}
			if (bound) {
				ctx.context_geometry = prev;
			}
		}
	}

	Ref<ProcNode> target = p_graph.get_node(p_node_idx);
	if (target.is_null() || p_socket_idx < 0 || p_socket_idx >= target->get_output_count()) {
		return Variant();
	}
	// ⛔ to_variant() HAS NO FIELD SPELLING. For SocketType::Float it returns the
	// scalar `f` whatever is_field says, so a per-point field collapses to one
	// number -- and for a value cone rooted at Position that number is 0.
	//
	// Measured 2026-09-08 on a graph declaring its density through a Group
	// Output float: the GPU emitted a correct signed 3D field and
	// evaluate_density_batch returned 0.0 at every one of 100 points. Its
	// caller checks for a PACKED_FLOAT32_ARRAY, which to_variant() cannot
	// produce, so the scalar branch cast the promise to zero. Both density
	// entry points read through here, and a graph that declares its density on
	// its own interface makes this the NORMAL path rather than an unused
	// spelling.
	//
	// materialize() returns a non-lazy value unchanged, so the scalar callers --
	// ProcNodeGraph::evaluate_socket and the 770 op-parity cases behind it,
	// which have no geometry and never set is_field -- are untouched.
	SocketValue sv = ctx.output_values[p_node_idx * max_outputs + p_socket_idx];
	sv = ctx.materialize(sv, ctx.input_geometry);
	if (sv.is_field && sv.type == SocketType::Float) {
		return sv.float_array;
	}
	return sv.to_variant();
}

// THE ONE CONVERSION, at the end, the way an exporter does it.
//
// M = (x, y, z) -> (-x, z, y): the bridge's existing mesh conversion, a proper
// rotation (det +1) and self-inverse. Positions and normals both go through it.
//
// Winding is deliberately NOT touched. M has determinant +1, so it preserves
// orientation, and the primitives keep the index order they always had -- the
// mesh comes out wound exactly as it does today. The unconditional winding flip
// on the bridge's SHM path is a RENDER convention (Blender CCW against Godot's
// effective CW under the Vulkan Y-flip) and belongs to that path, not to this
// one.
static Ref<ProcGeometry> _to_godot_frame(const Ref<ProcGeometry> &p_geo,
		const ProcNodeGraph &p_graph) {
	if (!p_graph.is_blender_frame() || p_geo.is_null()) {
		return p_geo;
	}
	Ref<ProcMesh> mesh = p_geo->get_mesh();
	if (mesh.is_null()) {
		return p_geo;
	}
	Vector3 *pos = mesh->positions.ptrw();
	for (int i = 0; i < mesh->positions.size(); i++) {
		pos[i] = Vector3(-pos[i].x, pos[i].z, pos[i].y);
	}
	Vector3 *nor = mesh->normals.ptrw();
	for (int i = 0; i < mesh->normals.size(); i++) {
		nor[i] = Vector3(-nor[i].x, nor[i].z, nor[i].y);
	}
	return p_geo;
}


// ── WHICH NODES CAN REACH THE OUTPUT, GIVEN WHAT THE SWITCHES DECIDE ─────────
//
// The executor ran every node in topological order. On the bridged voronoi
// mountain that meant computing MeshToSDFGrid and GridToMesh -- 4.0 s of an
// 8.3 s evaluation -- and then throwing the result away, because the graph's
// Solidify input is 0 and the geometry Switch takes the other branch. Blender
// never touches it: its geometry nodes are a demand-driven lazy-function graph,
// and node_geo_switch.cc declares both branches lf::ValueUsage::Maybe, reads the
// condition, then calls params.set_input_unused() on the one it does not want,
// so the scheduler never runs that subgraph at all.
//
// This is the same decision made statically. Conditions are settled first --
// each Switch's condition sub-cone is executed on its own, which is cheap
// because a condition that depended on the heavy branch would be a cycle -- and
// then liveness is walked backwards from the output, entering a Switch only
// through the branch it actually takes.
//
// ⚠ A VARYING CONDITION NEEDS BOTH BRANCHES, and Blender says so in the same
// file: execute_field() requests both when the condition is a field, because
// the choice is then per element. A Switch whose condition is not a single
// value is left alone here for exactly that reason.
//
// Nodes that cannot reach the output at all drop out of the same walk, so the
// dead-code case costs nothing extra.
static void _collect_input_cone(const ProcNodeGraph &p_graph, int p_node, int p_socket,
		Vector<bool> &r_live) {
	const int src = p_graph.get_input_source_node(p_node, p_socket);
	if (src < 0 || src >= r_live.size() || r_live[src]) {
		return;
	}
	r_live.write[src] = true;
	Ref<ProcNode> n = p_graph.get_node(src);
	if (n.is_null()) {
		return;
	}
	for (int s = 0; s < n->get_input_count(); s++) {
		_collect_input_cone(p_graph, src, s, r_live);
	}
}

Vector<bool> GraphExecutor::_compute_live_set(const ProcNodeGraph &p_graph,
		ExecutionPlan &p_plan, ProcEvalContext &p_ctx) {
	const int count = p_graph.get_node_count();
	Vector<bool> live;
	live.resize(count);
	for (int i = 0; i < count; i++) {
		live.write[i] = false;
	}
	p_ctx.output_needed.resize(count * p_ctx.max_output_sockets);
	for (int i = 0; i < p_ctx.output_needed.size(); i++) {
		p_ctx.output_needed.write[i] = false;
	}
	if (p_plan.output_node < 0 || p_plan.output_node >= count) {
		for (int i = 0; i < count; i++) {
			live.write[i] = true;
		}
		p_ctx.output_needed.clear(); // no information: is_output_needed says yes
		return live;
	}

	// 1. Settle every Switch's condition, by running only what feeds it.
	HashMap<int, int> taken; // switch node -> the input socket to follow
	for (int i = 0; i < count; i++) {
		Ref<ProcNode> n = p_graph.get_node(i);
		if (n.is_null() || n->get_node_type() != StringName("Switch")) {
			continue;
		}
		Vector<bool> cone;
		cone.resize(count);
		for (int k = 0; k < count; k++) {
			cone.write[k] = false;
		}
		_collect_input_cone(p_graph, i, 0, cone);
		if (p_graph.get_input_source_node(i, 0) < 0) {
			// Unconnected: the socket default decides, and nothing needs running.
			const bool c = (bool)n->get_input_default(0);
			taken[i] = c ? 2 : 1;
			continue;
		}
		// A zone body cannot be evaluated out of order, so leave that Switch be.
		bool in_zone = false;
		for (int k = 0; k < count && !in_zone; k++) {
			if (cone[k] && k < p_plan.node_zone_map.size() && p_plan.node_zone_map[k] >= 0) {
				in_zone = true;
			}
		}
		if (in_zone) {
			continue;
		}
		for (int o = 0; o < p_plan.execution_order.size(); o++) {
			const int idx = p_plan.execution_order[o];
			if (idx < 0 || idx >= count || !cone[idx]) {
				continue;
			}
			Ref<ProcNode> cn = p_graph.get_node(idx);
			Ref<ProcGeometry> prev;
			bool bound = false;
			if (cn.is_valid() && !_bind_context_or_defer(p_ctx, idx, cn, prev, bound)) {
				cn->execute(p_ctx, idx);
				if (bound) {
					p_ctx.context_geometry = prev;
				}
			}
		}
		const SocketValue cond = p_ctx.read_input(i, 0);
		if (cond.is_field || cond.is_lazy) {
			continue; // per element: both branches are live, as in Blender
		}
		taken[i] = cond.to_bool() ? 2 : 1;
	}

	// 2. Liveness backwards from the output, through the taken branch only.
	Vector<int> stack;
	live.write[p_plan.output_node] = true;
	stack.push_back(p_plan.output_node);
	while (!stack.is_empty()) {
		const int idx = stack[stack.size() - 1];
		stack.remove_at(stack.size() - 1);
		Ref<ProcNode> n = p_graph.get_node(idx);
		if (n.is_null()) {
			continue;
		}
		const bool is_switch = n->get_node_type() == StringName("Switch") && taken.has(idx);
		for (int s = 0; s < n->get_input_count(); s++) {
			if (is_switch && s != 0 && s != taken[idx]) {
				continue; // the branch this Switch does not take
			}
			const int src = p_graph.get_input_source_node(idx, s);
			if (src < 0 || src >= count) {
				continue;
			}
			// Which SOCKET of the producer this edge reads. Recorded even when
			// the producer is already live, because a second consumer may want
			// a different port.
			const int ssock = p_graph.get_input_source_socket(idx, s);
			const int slot = src * p_ctx.max_output_sockets + ssock;
			if (slot >= 0 && slot < p_ctx.output_needed.size()) {
				p_ctx.output_needed.write[slot] = true;
			}
			if (live[src]) {
				continue;
			}
			live.write[src] = true;
			stack.push_back(src);
		}
	}

	// A zone's body is reached through its pairing, not its links, so anything
	// inside a live zone stays live.
	for (int i = 0; i < count; i++) {
		if (i < p_plan.node_zone_map.size() && p_plan.node_zone_map[i] >= 0) {
			const ProcZoneInfo &z = p_plan.zones[p_plan.node_zone_map[i]];
			if (z.output_node >= 0 && z.output_node < count && live[z.output_node]) {
				live.write[i] = true;
			}
		}
	}
	return live;
}

Ref<ProcGeometry> GraphExecutor::execute(
		const ProcNodeGraph &p_graph,
		ExecutionPlan &p_plan,
		const Dictionary &p_input_overrides,
		bool p_use_gpu,
		RenderingDevice *p_rd,
		float p_delta_time,
		HashMap<int, Ref<ProcGeometry>> *p_simulation_cache) {
	// The input geometry, if the caller supplied one. Keyed "geometry" so the
	// dictionary can carry named group inputs later without a signature change.
	Ref<ProcGeometry> _external_geo;
	if (p_input_overrides.has("geometry")) {
		_external_geo = p_input_overrides["geometry"];
	}
	if (p_plan.execution_order.size() == 0 || p_plan.output_node < 0) {
		return Ref<ProcGeometry>();
	}

	// Determine max output sockets
	int max_outputs = 1;
	for (int i = 0; i < p_graph.get_node_count(); i++) {
		Ref<ProcNode> node = p_graph.get_node(i);
		if (node.is_valid() && node->get_output_count() > max_outputs) {
			max_outputs = node->get_output_count();
		}
	}

	// Set up evaluation context
	ProcEvalContext ctx;
	ctx.input_geometry = _external_geo;
	ctx.graph = &p_graph;
	ctx.max_output_sockets = max_outputs;
	ctx.output_values.resize(p_graph.get_node_count() * max_outputs);

	ctx.delta_time = p_delta_time;
	ctx.simulation_cache = p_simulation_cache;

	exec_report().clear();

	// Initialize GPU if a local RenderingDevice is provided and batches exist
	bool gpu_available = false;
	if (p_use_gpu && p_rd && p_plan.gpu_batches.size() > 0) {
		ctx.gpu_buffers.init(p_rd);
		ctx.use_gpu = true;
		gpu_available = true;
	}

	// What the output can actually reach, once the switches have decided.
	const Vector<bool> live = _compute_live_set(p_graph, p_plan, ctx);

	// Execute nodes in topological order
	for (int i = 0; i < p_plan.execution_order.size(); i++) {
		int node_idx = p_plan.execution_order[i];

		// Not on any path to the output: Blender's scheduler would never have
		// been asked for it.
		if (node_idx >= 0 && node_idx < live.size() && !live[node_idx]) {
			continue;
		}

		// Skip zone body nodes (executed by zone handler when output node is reached)
		if (node_idx < p_plan.node_zone_map.size() && p_plan.node_zone_map[node_idx] >= 0) {
			int zone_idx = p_plan.node_zone_map[node_idx];
			const ProcZoneInfo &zone = p_plan.zones[zone_idx];
			if (node_idx == zone.output_node) {
				_execute_zone(ctx, zone, p_plan);
			}
			continue;
		}

		// Check for GPU batch at this position
		if (gpu_available) {
			int batch_idx = p_plan.find_gpu_batch(i);
			// ⛔ REFUSE HERE, NOT INSIDE THE BATCH. An unrepresentable batch has
			// to fall through to the ORDINARY per-node path, because that path
			// is where _bind_context_or_defer lives -- the lazy-field binding a
			// bare node->execute() knows nothing about. Running the nodes by
			// hand inside _execute_gpu_batch reported them as CPU and still got
			// the wrong answer: the mountain stayed flat, y -2..2 against the
			// CPU path's y -2..28.85, because the deferred field was never
			// materialised.
			if (batch_idx >= 0 &&
					!_batch_is_representable(ctx, p_plan.gpu_batches[batch_idx])) {
				batch_idx = -1;
			}
			if (batch_idx >= 0) {
				_execute_gpu_batch(ctx, p_plan.gpu_batches[batch_idx], p_plan);
				i += p_plan.gpu_batches[batch_idx].node_count - 1;
				continue;
			}
		}

		Ref<ProcNode> node = p_graph.get_node(node_idx);
		Ref<ProcGeometry> _prev_ctx;
		bool _bound_ctx = false;
		if (node.is_valid() && _bind_context_or_defer(ctx, node_idx, node, _prev_ctx, _bound_ctx)) {
			continue; // fed a context field, and no geometry of its own to resolve it
		}
		if (node.is_valid()) {
			exec_report()[String(node->get_node_type())] = "cpu";
			uint64_t tn0 = Time::get_singleton()->get_ticks_usec();
			node->execute(ctx, node_idx);
			if (_bound_ctx) {
				ctx.context_geometry = _prev_ctx;
			}
			uint64_t tn1 = Time::get_singleton()->get_ticks_usec();
			double ms = (tn1 - tn0) / 1000.0;
			if (ms > 0.5) { // Only print nodes taking > 0.5ms
				UtilityFunctions::print("  CPU node [", node->get_node_type(), "]: ", ms, "ms");
			}
		}
	}

	// Free per-evaluation GPU buffers (pipelines persist in ExecutionPlan for reuse)
	ctx.gpu_buffers.free_all();

	// Read output
	SocketValue result = ctx.output_values[p_plan.output_node * max_outputs + p_plan.output_socket];
	return _to_godot_frame(result.geometry, p_graph);
}

} // namespace godot