#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