OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / src / nodes / math_nodes.cpp

1445 lines · cpp ref:
#include "math_nodes.h"

#include "../shared/blender_colorband.h"
#include "../shared/blender_curvemapping.h"
#include "../shared/blender_math_ops.h"
#include "../shared/blender_ramp_blend.h"
#include "../gpu/blender_ramp_blend_glsl.gen.h"
#include "../graph/graph_executor.h"
#include <godot_cpp/variant/utility_functions.hpp>
#include <cmath>

namespace godot {

// --- MathNode ---

void MathNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_operation", "operation"), &MathNode::set_operation);
	ClassDB::bind_method(D_METHOD("get_operation"), &MathNode::get_operation);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "operation"), "set_operation", "get_operation");
}

MathNode::MathNode() {
	// Blender's three inputs. MULTIPLY_ADD, COMPARE, SMOOTH_MIN, SMOOTH_MAX and
	// WRAP all read the third one, and this node had two -- so a link to it was
	// dropped and those five operations quietly used the socket default.
	input_defs.push_back({ "Value", SocketType::Float, 0.5f });
	input_defs.push_back({ "Value_001", SocketType::Float, 0.5f });
	input_defs.push_back({ "Value_002", SocketType::Float, 0.5f });
	output_defs.push_back({ "Value", SocketType::Float, Variant() });
}

void MathNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	SocketValue a = p_ctx.read_input(p_node_idx, 0);
	SocketValue b = p_ctx.read_input(p_node_idx, 1);
	SocketValue c = p_ctx.read_input(p_node_idx, 2);

	if (!a.is_field && !b.is_field && !c.is_field) {
		p_ctx.write_float(p_node_idx, 0,
				bmath::math_op(operation, a.to_float(), b.to_float(), c.to_float()));
		return;
	}

	int n = MAX(a.field_size, MAX(b.field_size, c.field_size));
	if (n == 0) {
		n = 1;
	}
	AttrDomain domain = a.is_field ? a.domain : (b.is_field ? b.domain : c.domain);
	PackedFloat32Array result;
	result.resize(n);
	for (int i = 0; i < n; i++) {
		result.ptrw()[i] = bmath::math_op(operation, a.float_at(i), b.float_at(i), c.float_at(i));
	}
	p_ctx.write_float_field(p_node_idx, 0, result, domain);
}

// BLENDER'S FLOAT MATH, IN GLSL, AGAINST THE SAME NUMBERING THE CPU USES.
//
// bmath::math_op in src/shared/blender_math_ops.h is the reference, and it is
// the code that SHIPS -- the Blender parity suite gates it at 71/71 over all 41
// operations. This is a second expression of it, which is exactly the shape the
// erosion filter got wrong once ("a transcription used as the reference can only
// ever agree with itself"), so it is not trusted: demo/glsl_op_gate_dump.gd
// pairs every operation's CPU answer with this emitted text and the standalone
// -lvulkan gate runs it on a real device. A transcription CHECKED against the
// shipping code over every branch is a different thing from one used as the
// reference.
//
// The safe_* family is why a degenerate argument gives a number rather than a
// NaN, and each picks a specific value: divide by zero is 0, log of a
// non-positive is 0, sqrt clamps at 0, and a negative base with a FRACTIONAL
// exponent is 0 while an integral one is not.
static const char *MATH_GLSL_PRELUDE = R"GLSL(
// GUARDED, because two nodes need these and a prelude is emitted per node.
// Vector Math's helpers are built on these, so its prelude carries a copy;
// deduplicating by string then either drops them or defines them twice
// depending on which node came first. GLSL HAS A PREPROCESSOR, so the
// duplicate is simply dropped and no emitter has to reason about it.
#ifndef PN_MATH_HELPERS
#define PN_MATH_HELPERS
float pn_safe_divide(float a, float b) { return (b != 0.0) ? a / b : 0.0; }
// ⛔ NOT mod(): GLSL's mod is FLOORED (a - b*floor(a/b)) and C's fmodf is
// TRUNCATED. Measured on an RTX 2070: mod(0.5, -1.0) is -0.5 where fmodf gives
// 0.5, and mod(-0.75, 2.0) is 1.25 where fmodf gives -0.75. MATH_FLOORED_MODULO
// is a separate operation precisely because these differ.
float pn_safe_mod(float a, float b) { return (b != 0.0) ? a - b * trunc(a / b) : 0.0; }
float pn_safe_floored_mod(float a, float b) { return (b != 0.0) ? a - floor(a / b) * b : 0.0; }
float pn_safe_log(float a, float base) {
    if (a <= 0.0 || base <= 0.0) { return 0.0; }
    return pn_safe_divide(log(a), log(base));
}
float pn_safe_sqrt(float a) { return sqrt(max(a, 0.0)); }
float pn_safe_inv_sqrt(float a) { return (a > 0.0) ? 1.0 / sqrt(a) : 0.0; }
float pn_safe_asin(float a) { return asin(clamp(a, -1.0, 1.0)); }
float pn_safe_acos(float a) { return acos(clamp(a, -1.0, 1.0)); }
float pn_safe_pow(float base, float expo) {
    // A negative base with a fractional exponent is 0, an integral one is not.
    // ⛔ AND GLSL's pow() IS UNDEFINED FOR A NEGATIVE BASE -- measured, it
    // returns NaN for pow(-0.75, 2.0) where C's powf gives 0.5625. The integral
    // case has to be done by hand: magnitude from the absolute value, sign from
    // whether the exponent is odd.
    if (base < 0.0) {
        if (expo != float(int(expo))) { return 0.0; }
        float m = pow(abs(base), expo);
        return (mod(abs(expo), 2.0) == 1.0) ? -m : m;
    }
    return pow(base, expo);
}
float pn_wrap(float value, float maxv, float minv) {
    float range = maxv - minv;
    return (range != 0.0) ? value - (range * floor((value - minv) / range)) : minv;
}
float pn_pingpong(float value, float scale) {
    if (scale == 0.0) { return 0.0; }
    float f = (value - scale) / (scale * 2.0);
    return abs((f - floor(f)) * scale * 2.0 - scale);
}
float pn_smoothmin(float a, float b, float c) {
    if (c != 0.0) {
        float h = max(c - abs(a - b), 0.0) / c;
        return min(a, b) - h * h * h * c * (1.0 / 6.0);
    }
    return min(a, b);
}
float pn_compatible_sign(float f) { return (f > 0.0) ? 1.0 : ((f < 0.0) ? -1.0 : 0.0); }
#endif
)GLSL";

String MathNode::compile_glsl_prelude(int p_node_idx) const {
	// Always. GLSL has no nested functions, so helpers belong at file scope,
	// and GpuCompiler drops a prelude it has already emitted -- keying that on
	// the node's INDEX instead meant a graph whose Math node was not node 0
	// emitted no helpers at all and could not compile.
	return String(MATH_GLSL_PRELUDE);
}

String MathNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	const String a = p_ctx.in(p_node_idx, 0);
	const String b = p_ctx.in(p_node_idx, 1);
	const String c = p_ctx.in(p_node_idx, 2);
	String e;
	switch (operation) {
		case bmath::MATH_ADD: e = "(" + a + " + " + b + ")"; break;
		case bmath::MATH_SUBTRACT: e = "(" + a + " - " + b + ")"; break;
		case bmath::MATH_MULTIPLY: e = "(" + a + " * " + b + ")"; break;
		case bmath::MATH_DIVIDE: e = "pn_safe_divide(" + a + ", " + b + ")"; break;
		case bmath::MATH_SINE: e = "sin(" + a + ")"; break;
		case bmath::MATH_COSINE: e = "cos(" + a + ")"; break;
		case bmath::MATH_TANGENT: e = "tan(" + a + ")"; break;
		case bmath::MATH_ARCSINE: e = "pn_safe_asin(" + a + ")"; break;
		case bmath::MATH_ARCCOSINE: e = "pn_safe_acos(" + a + ")"; break;
		case bmath::MATH_ARCTANGENT: e = "atan(" + a + ")"; break;
		case bmath::MATH_POWER: e = "pn_safe_pow(" + a + ", " + b + ")"; break;
		case bmath::MATH_LOGARITHM: e = "pn_safe_log(" + a + ", " + b + ")"; break;
		case bmath::MATH_MINIMUM: e = "min(" + a + ", " + b + ")"; break;
		case bmath::MATH_MAXIMUM: e = "max(" + a + ", " + b + ")"; break;
		case bmath::MATH_ROUND: e = "floor(" + a + " + 0.5)"; break;
		case bmath::MATH_LESS_THAN: e = "((" + a + " < " + b + ") ? 1.0 : 0.0)"; break;
		case bmath::MATH_GREATER_THAN: e = "((" + a + " > " + b + ") ? 1.0 : 0.0)"; break;
		case bmath::MATH_MODULO: e = "pn_safe_mod(" + a + ", " + b + ")"; break;
		case bmath::MATH_ABSOLUTE: e = "abs(" + a + ")"; break;
		case bmath::MATH_ARCTAN2: e = "atan(" + a + ", " + b + ")"; break;
		case bmath::MATH_FLOOR: e = "floor(" + a + ")"; break;
		case bmath::MATH_CEIL: e = "ceil(" + a + ")"; break;
		case bmath::MATH_FRACTION: e = "(" + a + " - floor(" + a + "))"; break;
		case bmath::MATH_SQRT: e = "pn_safe_sqrt(" + a + ")"; break;
		case bmath::MATH_INV_SQRT: e = "pn_safe_inv_sqrt(" + a + ")"; break;
		case bmath::MATH_SIGN: e = "pn_compatible_sign(" + a + ")"; break;
		case bmath::MATH_EXPONENT: e = "exp(" + a + ")"; break;
		case bmath::MATH_RADIANS: e = "radians(" + a + ")"; break;
		case bmath::MATH_DEGREES: e = "degrees(" + a + ")"; break;
		case bmath::MATH_SINH: e = "sinh(" + a + ")"; break;
		case bmath::MATH_COSH: e = "cosh(" + a + ")"; break;
		case bmath::MATH_TANH: e = "tanh(" + a + ")"; break;
		case bmath::MATH_TRUNC: e = "((" + a + " >= 0.0) ? floor(" + a + ") : ceil(" + a + "))"; break;
		case bmath::MATH_SNAP: e = "(floor(pn_safe_divide(" + a + ", " + b + ")) * " + b + ")"; break;
		case bmath::MATH_WRAP: e = "pn_wrap(" + a + ", " + b + ", " + c + ")"; break;
		case bmath::MATH_COMPARE:
			// FLT_EPSILON, not 0: the CPU floor is MAX(c, FLT_EPSILON), so a
			// tolerance of 0 still matches exactly-equal values.
			e = "(((" + a + " == " + b + ") || (abs(" + a + " - " + b + ") <= max(" + c +
					", 1.1920929e-7))) ? 1.0 : 0.0)";
			break;
		case bmath::MATH_MULTIPLY_ADD: e = "((" + a + " * " + b + ") + " + c + ")"; break;
		case bmath::MATH_PINGPONG: e = "pn_pingpong(" + a + ", " + b + ")"; break;
		case bmath::MATH_SMOOTH_MIN: e = "pn_smoothmin(" + a + ", " + b + ", " + c + ")"; break;
		case bmath::MATH_SMOOTH_MAX: e = "(-pn_smoothmin(-(" + a + "), -(" + b + "), " + c + "))"; break;
		case bmath::MATH_FLOORED_MODULO: e = "pn_safe_floored_mod(" + a + ", " + b + ")"; break;
		default: e = "0.0"; break;
	}
	return p_ctx.declare(p_node_idx, 0) + " = " + e + ";";
}

void VectorMathNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_operation", "operation"), &VectorMathNode::set_operation);
	ClassDB::bind_method(D_METHOD("get_operation"), &VectorMathNode::get_operation);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "operation"), "set_operation", "get_operation");
}

VectorMathNode::VectorMathNode() {
	// BLENDER'S LAYOUT EXACTLY, and here the index is the only thing that can
	// identify a socket: Blender names all three vector inputs "Vector", so
	// resolution by name is ambiguous and falls through to the index.
	input_defs.push_back({ "Vector", SocketType::Vector, Vector3() });
	input_defs.push_back({ "Vector_001", SocketType::Vector, Vector3() });
	input_defs.push_back({ "Vector_002", SocketType::Vector, Vector3() });
	input_defs.push_back({ "Scale", SocketType::Float, 1.0f });
	output_defs.push_back({ "Vector", SocketType::Vector, Variant() });
	output_defs.push_back({ "Value", SocketType::Float, Variant() });
}

void VectorMathNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	SocketValue a = p_ctx.read_input(p_node_idx, 0);
	SocketValue b = p_ctx.read_input(p_node_idx, 1);
	SocketValue c = p_ctx.read_input(p_node_idx, 2);
	SocketValue sv = p_ctx.read_input(p_node_idx, 3);

	bool is_field = a.is_field || b.is_field || c.is_field || sv.is_field;
	if (!is_field) {
		float fr;
		Vector3 vr = bmath::vector_math_op(operation, a.to_vector(), b.to_vector(),
				c.to_vector(), sv.to_float(), fr);
		p_ctx.write_vector(p_node_idx, 0, vr);
		p_ctx.write_float(p_node_idx, 1, fr);
		return;
	}

	int n = MAX(MAX(a.field_size, b.field_size), MAX(c.field_size, sv.field_size));
	if (n == 0) {
		n = 1;
	}
	AttrDomain domain = a.is_field ? a.domain
								   : (b.is_field ? b.domain : (c.is_field ? c.domain : sv.domain));
	PackedVector3Array vec_arr;
	PackedFloat32Array float_arr;
	vec_arr.resize(n);
	float_arr.resize(n);
	for (int i = 0; i < n; i++) {
		float fr;
		vec_arr.ptrw()[i] = bmath::vector_math_op(operation, a.vec_at(i), b.vec_at(i), c.vec_at(i),
				sv.float_at(i), fr);
		float_arr.ptrw()[i] = fr;
	}
	p_ctx.write_vec_field(p_node_idx, 0, vec_arr, domain);
	p_ctx.write_float_field(p_node_idx, 1, float_arr, domain);
}

// --- CombineXYZNode ---

void CombineXYZNode::_bind_methods() {
}

CombineXYZNode::CombineXYZNode() {
	input_defs.push_back({ "X", SocketType::Float, 0.0f });
	input_defs.push_back({ "Y", SocketType::Float, 0.0f });
	input_defs.push_back({ "Z", SocketType::Float, 0.0f });
	output_defs.push_back({ "Vector", SocketType::Vector, Variant() });
}

void CombineXYZNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	SocketValue xv = p_ctx.read_input(p_node_idx, 0);
	SocketValue yv = p_ctx.read_input(p_node_idx, 1);
	SocketValue zv = p_ctx.read_input(p_node_idx, 2);
	if (xv.is_field || yv.is_field || zv.is_field) {
		int n = MAX(MAX(xv.field_size, yv.field_size), zv.field_size);
		if (n == 0) n = 1;
		PackedVector3Array result;
		result.resize(n);
		for (int i = 0; i < n; i++) {
			result.ptrw()[i] = Vector3(xv.float_at(i), yv.float_at(i), zv.float_at(i));
		}
		p_ctx.write_vec_field(p_node_idx, 0, result);
	} else {
		p_ctx.write_vector(p_node_idx, 0, Vector3(xv.to_float(), yv.to_float(), zv.to_float()));
	}
}

String CombineXYZNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// A STATEMENT, not a fragment. This returned "vec3(x, y, z)" -- undefined
	// identifiers, no assignment, no semicolon -- so any graph using it produced
	// a shader that could not compile. The inputs come from the graph now.
	return p_ctx.declare(p_node_idx, 0) + " = vec3(" + p_ctx.in(p_node_idx, 0) + ", " +
			p_ctx.in(p_node_idx, 1) + ", " + p_ctx.in(p_node_idx, 2) + ");";
}

// --- SeparateXYZNode ---

void SeparateXYZNode::_bind_methods() {
}

SeparateXYZNode::SeparateXYZNode() {
	input_defs.push_back({ "Vector", SocketType::Vector, Vector3() });
	output_defs.push_back({ "X", SocketType::Float, Variant() });
	output_defs.push_back({ "Y", SocketType::Float, Variant() });
	output_defs.push_back({ "Z", SocketType::Float, Variant() });
}

void SeparateXYZNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	SocketValue v = p_ctx.read_input(p_node_idx, 0);
	if (v.is_field) {
		int n = v.field_size;
		PackedFloat32Array x_arr, y_arr, z_arr;
		x_arr.resize(n); y_arr.resize(n); z_arr.resize(n);
		for (int i = 0; i < n; i++) {
			Vector3 vi = v.vec_at(i);
			x_arr.ptrw()[i] = vi.x;
			y_arr.ptrw()[i] = vi.y;
			z_arr.ptrw()[i] = vi.z;
		}
		p_ctx.write_float_field(p_node_idx, 0, x_arr);
		p_ctx.write_float_field(p_node_idx, 1, y_arr);
		p_ctx.write_float_field(p_node_idx, 2, z_arr);
	} else {
		Vector3 vi = v.to_vector();
		p_ctx.write_float(p_node_idx, 0, vi.x);
		p_ctx.write_float(p_node_idx, 1, vi.y);
		p_ctx.write_float(p_node_idx, 2, vi.z);
	}
}

String SeparateXYZNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// Was "v.x; v.y; v.z" -- three expression statements over an identifier
	// nothing declared. Three real outputs now, each its own variable.
	const String v = p_ctx.in(p_node_idx, 0);
	return p_ctx.declare(p_node_idx, 0) + " = (" + v + ").x;\n\t" +
			p_ctx.declare(p_node_idx, 1) + " = (" + v + ").y;\n\t" +
			p_ctx.declare(p_node_idx, 2) + " = (" + v + ").z;";
}

// --- BooleanMathNode ---

void BooleanMathNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_operation", "operation"), &BooleanMathNode::set_operation);
	ClassDB::bind_method(D_METHOD("get_operation"), &BooleanMathNode::get_operation);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "operation"), "set_operation", "get_operation");
}

BooleanMathNode::BooleanMathNode() {
	input_defs.push_back({ "A", SocketType::Bool, false });
	input_defs.push_back({ "B", SocketType::Bool, false });
	output_defs.push_back({ "Result", SocketType::Bool, Variant() });
}

void BooleanMathNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	bool a = p_ctx.read_bool(p_node_idx, 0);
	bool b = p_ctx.read_bool(p_node_idx, 1);
	bool result = false;

	switch (operation) {
		case BooleanMathOp::And: result = a && b; break;
		case BooleanMathOp::Or: result = a || b; break;
		case BooleanMathOp::Not: result = !a; break;
		case BooleanMathOp::Xor: result = a != b; break;
		case BooleanMathOp::Nand: result = !(a && b); break;
		case BooleanMathOp::Nor: result = !(a || b); break;
		case BooleanMathOp::Xnor: result = a == b; break;
	}

	p_ctx.write_bool(p_node_idx, 0, result);
}

String BooleanMathNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// Was an expression fragment. NOT and the negated forms read only A, exactly
	// as the CPU switch does.
	const String a = p_ctx.in(p_node_idx, 0);
	const String b = p_ctx.in(p_node_idx, 1);
	String expr;
	switch (operation) {
		case BooleanMathOp::And: expr = "(" + a + " && " + b + ")"; break;
		case BooleanMathOp::Or: expr = "(" + a + " || " + b + ")"; break;
		case BooleanMathOp::Not: expr = "(!" + a + ")"; break;
		case BooleanMathOp::Xor: expr = "(" + a + " != " + b + ")"; break;
		case BooleanMathOp::Nand: expr = "(!(" + a + " && " + b + "))"; break;
		case BooleanMathOp::Nor: expr = "(!(" + a + " || " + b + "))"; break;
		case BooleanMathOp::Xnor: expr = "(" + a + " == " + b + ")"; break;
		default: expr = "false"; break;
	}
	return p_ctx.declare(p_node_idx, 0) + " = " + expr + ";";
}

// --- CompareNode ---

void CompareNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_operation", "operation"), &CompareNode::set_operation);
	ClassDB::bind_method(D_METHOD("get_operation"), &CompareNode::get_operation);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "operation"), "set_operation", "get_operation");
}

CompareNode::CompareNode() {
	input_defs.push_back({ "A", SocketType::Float, 0.0f });
	input_defs.push_back({ "B", SocketType::Float, 0.0f });
	output_defs.push_back({ "Result", SocketType::Bool, Variant() });
}

static bool _compare_op(CompareOp op, float a, float b) {
	switch (op) {
		case CompareOp::Less: return a < b;
		case CompareOp::LessEqual: return a <= b;
		case CompareOp::Greater: return a > b;
		case CompareOp::GreaterEqual: return a >= b;
		case CompareOp::Equal: return fabsf(a - b) < 1e-6f;
		case CompareOp::NotEqual: return fabsf(a - b) >= 1e-6f;
		default: return false;
	}
}

void CompareNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	SocketValue a_val = p_ctx.read_input(p_node_idx, 0);
	SocketValue b_val = p_ctx.read_input(p_node_idx, 1);

	if (!a_val.is_field && !b_val.is_field) {
		// Scalar path
		p_ctx.write_bool(p_node_idx, 0, _compare_op(operation, a_val.to_float(), b_val.to_float()));
		return;
	}

	// Field path: element-wise, output int field (1=true, 0=false)
	int n = MAX(a_val.field_size, b_val.field_size);
	if (n == 0) n = 1;
	PackedInt32Array result;
	result.resize(n);
	int32_t *out = result.ptrw();
	for (int i = 0; i < n; i++) {
		out[i] = _compare_op(operation, a_val.float_at(i), b_val.float_at(i)) ? 1 : 0;
	}
	p_ctx.write_int_field(p_node_idx, 0, result);
}

String CompareNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// Was an expression fragment. EQUAL AND NOT-EQUAL ARE NOT == AND != : the
	// CPU compares with a 1e-6 tolerance (_compare_op above), and a shader using
	// == on floats would disagree with it on almost every value that matters.
	const String a = p_ctx.in(p_node_idx, 0);
	const String b = p_ctx.in(p_node_idx, 1);
	String expr;
	switch (operation) {
		case CompareOp::Less: expr = "(" + a + " < " + b + ")"; break;
		case CompareOp::LessEqual: expr = "(" + a + " <= " + b + ")"; break;
		case CompareOp::Greater: expr = "(" + a + " > " + b + ")"; break;
		case CompareOp::GreaterEqual: expr = "(" + a + " >= " + b + ")"; break;
		case CompareOp::Equal: expr = "(abs(" + a + " - " + b + ") < 1e-6)"; break;
		case CompareOp::NotEqual: expr = "(abs(" + a + " - " + b + ") >= 1e-6)"; break;
		default: expr = "false"; break;
	}
	return p_ctx.declare(p_node_idx, 0) + " = " + expr + ";";
}

// --- ClampNode ---

void ClampNode::_bind_methods() {
}

ClampNode::ClampNode() {
	input_defs.push_back({ "Value", SocketType::Float, 0.0f });
	input_defs.push_back({ "Min", SocketType::Float, 0.0f });
	input_defs.push_back({ "Max", SocketType::Float, 1.0f });
	output_defs.push_back({ "Result", SocketType::Float, Variant() });
}

void ClampNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	SocketValue val = p_ctx.read_input(p_node_idx, 0);
	float min_val = p_ctx.read_float(p_node_idx, 1);
	float max_val = p_ctx.read_float(p_node_idx, 2);
	if (val.is_field) {
		int n = val.field_size;
		PackedFloat32Array result;
		result.resize(n);
		for (int i = 0; i < n; i++) {
			result.ptrw()[i] = CLAMP(val.float_at(i), min_val, max_val);
		}
		p_ctx.write_float_field(p_node_idx, 0, result);
	} else {
		p_ctx.write_float(p_node_idx, 0, CLAMP(val.to_float(), min_val, max_val));
	}
}

String ClampNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// Was "clamp(value, min_val, max_val)". CLAMP() on the CPU side is Godot's,
	// which is min(max(x, lo), hi) -- the same order GLSL's clamp uses, so the
	// two agree when lo > hi as well.
	return p_ctx.declare(p_node_idx, 0) + " = clamp(" + p_ctx.in(p_node_idx, 0) + ", " +
			p_ctx.in(p_node_idx, 1) + ", " + p_ctx.in(p_node_idx, 2) + ");";
}

// --- MapRangeNode ---

// BLENDER'S VECTOR MATH, IN GLSL. bmath::vector_math_op is the reference and
// the standalone -lvulkan gate compares every operation against it on a real
// device -- which is how MATH's pow() and mod() were caught, and those two
// traps are here too, componentwise.
//
// Three operations return a SCALAR (dot, distance, length) and Blender puts
// them on the node's second output. Both outputs are declared every time: GLSL
// has no way to leave one undefined, and a consumer reading the unused one gets
// the zero the CPU writes into r_value rather than whatever was on the stack.
static const char *VECMATH_GLSL_PRELUDE = R"GLSL(
#ifndef PN_VECMATH_HELPERS
#define PN_VECMATH_HELPERS
vec3 pn_vsafe_divide(vec3 a, vec3 b) {
    return vec3(pn_safe_divide(a.x, b.x), pn_safe_divide(a.y, b.y), pn_safe_divide(a.z, b.z));
}
vec3 pn_vnormalize(vec3 v) {
    // A zero vector normalizes to ZERO, not NaN -- Blender's normalize does the
    // same, and GLSL's does not.
    float l = length(v);
    return (l > 0.0) ? v / l : vec3(0.0);
}
vec3 pn_vproject(vec3 p, vec3 v) {
    if (v.x == 0.0 && v.y == 0.0 && v.z == 0.0) { return vec3(0.0); }
    return v * (dot(p, v) / dot(v, v));
}
vec3 pn_vreflect(vec3 incident, vec3 normal) {
    return incident - normal * (2.0 * dot(normal, incident));
}
vec3 pn_vrefract(vec3 incident, vec3 normal, float eta) {
    float dot_ni = dot(normal, incident);
    float k = 1.0 - eta * eta * (1.0 - dot_ni * dot_ni);
    if (k < 0.0) { return vec3(0.0); }
    return incident * eta - normal * (eta * dot_ni + sqrt(k));
}
vec3 pn_vfaceforward(vec3 vector, vec3 incident, vec3 reference) {
    return (dot(reference, incident) < 0.0) ? vector : -vector;
}
vec3 pn_vsign(vec3 a) {
    return vec3(pn_compatible_sign(a.x), pn_compatible_sign(a.y), pn_compatible_sign(a.z));
}
vec3 pn_vsafe_mod(vec3 a, vec3 b) {
    return vec3(pn_safe_mod(a.x, b.x), pn_safe_mod(a.y, b.y), pn_safe_mod(a.z, b.z));
}
vec3 pn_vsafe_pow(vec3 a, vec3 b) {
    return vec3(pn_safe_pow(a.x, b.x), pn_safe_pow(a.y, b.y), pn_safe_pow(a.z, b.z));
}
vec3 pn_vwrap(vec3 a, vec3 b, vec3 c) {
    return vec3(pn_wrap(a.x, b.x, c.x), pn_wrap(a.y, b.y, c.y), pn_wrap(a.z, b.z, c.z));
}
vec3 pn_vsnap(vec3 a, vec3 b) {
    return vec3(floor(pn_safe_divide(a.x, b.x)) * b.x,
                floor(pn_safe_divide(a.y, b.y)) * b.y,
                floor(pn_safe_divide(a.z, b.z)) * b.z);
}
#endif
)GLSL";

String VectorMathNode::compile_glsl_prelude(int p_node_idx) const {
	// Depends on MathNode's helpers (pn_safe_divide, pn_safe_mod, pn_safe_pow,
	// pn_wrap, pn_compatible_sign), so a graph with a Vector Math node and no
	// Math node needs both. Emitting both here would duplicate them when a Math
	// node IS present, so the float helpers are emitted unconditionally and the
	// vector ones follow.
	// The float helpers first: this node's own use pn_safe_divide and friends,
	// and the emitter drops whichever copy arrives second.
	return String(MATH_GLSL_PRELUDE) + String(VECMATH_GLSL_PRELUDE);
}

String VectorMathNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	const String a = p_ctx.in(p_node_idx, 0);
	const String b = p_ctx.in(p_node_idx, 1);
	const String c = p_ctx.in(p_node_idx, 2);
	const String s = p_ctx.in(p_node_idx, 3);
	String vec = "vec3(0.0)";
	String val = "0.0";
	switch (operation) {
		case bmath::VEC_ADD: vec = "(" + a + " + " + b + ")"; break;
		case bmath::VEC_SUBTRACT: vec = "(" + a + " - " + b + ")"; break;
		case bmath::VEC_MULTIPLY: vec = "(" + a + " * " + b + ")"; break;
		case bmath::VEC_DIVIDE: vec = "pn_vsafe_divide(" + a + ", " + b + ")"; break;
		case bmath::VEC_CROSS_PRODUCT: vec = "cross(" + a + ", " + b + ")"; break;
		case bmath::VEC_PROJECT: vec = "pn_vproject(" + a + ", " + b + ")"; break;
		case bmath::VEC_REFLECT: vec = "pn_vreflect(" + a + ", pn_vnormalize(" + b + "))"; break;
		case bmath::VEC_DOT_PRODUCT: val = "dot(" + a + ", " + b + ")"; break;
		case bmath::VEC_DISTANCE: val = "distance(" + a + ", " + b + ")"; break;
		case bmath::VEC_LENGTH: val = "length(" + a + ")"; break;
		case bmath::VEC_SCALE: vec = "(" + a + " * " + s + ")"; break;
		case bmath::VEC_NORMALIZE: vec = "pn_vnormalize(" + a + ")"; break;
		case bmath::VEC_SNAP: vec = "pn_vsnap(" + a + ", " + b + ")"; break;
		case bmath::VEC_FLOOR: vec = "floor(" + a + ")"; break;
		case bmath::VEC_CEIL: vec = "ceil(" + a + ")"; break;
		case bmath::VEC_MODULO: vec = "pn_vsafe_mod(" + a + ", " + b + ")"; break;
		case bmath::VEC_FRACTION: vec = "(" + a + " - floor(" + a + "))"; break;
		case bmath::VEC_ABSOLUTE: vec = "abs(" + a + ")"; break;
		case bmath::VEC_MINIMUM: vec = "min(" + a + ", " + b + ")"; break;
		case bmath::VEC_MAXIMUM: vec = "max(" + a + ", " + b + ")"; break;
		case bmath::VEC_WRAP: vec = "pn_vwrap(" + a + ", " + b + ", " + c + ")"; break;
		case bmath::VEC_SINE: vec = "sin(" + a + ")"; break;
		case bmath::VEC_COSINE: vec = "cos(" + a + ")"; break;
		case bmath::VEC_TANGENT: vec = "tan(" + a + ")"; break;
		case bmath::VEC_REFRACT:
			vec = "pn_vrefract(" + a + ", pn_vnormalize(" + b + "), " + s + ")"; break;
		case bmath::VEC_FACEFORWARD:
			vec = "pn_vfaceforward(" + a + ", " + b + ", " + c + ")"; break;
		case bmath::VEC_MULTIPLY_ADD: vec = "((" + a + " * " + b + ") + " + c + ")"; break;
		case bmath::VEC_POWER: vec = "pn_vsafe_pow(" + a + ", " + b + ")"; break;
		case bmath::VEC_SIGN: vec = "pn_vsign(" + a + ")"; break;
		case bmath::VEC_ROUND: vec = "floor(" + a + " + vec3(0.5))"; break;
		default: break;
	}
	return p_ctx.declare(p_node_idx, 0) + " = " + vec + ";\n\t" +
			p_ctx.declare(p_node_idx, 1) + " = " + val + ";";
}

void MapRangeNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_data_type", "t"), &MapRangeNode::set_data_type);
	ClassDB::bind_method(D_METHOD("get_data_type"), &MapRangeNode::get_data_type);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "data_type"), "set_data_type", "get_data_type");
	ClassDB::bind_method(D_METHOD("set_interpolation_type", "t"), &MapRangeNode::set_interpolation_type);
	ClassDB::bind_method(D_METHOD("get_interpolation_type"), &MapRangeNode::get_interpolation_type);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "interpolation_type"), "set_interpolation_type", "get_interpolation_type");
	ClassDB::bind_method(D_METHOD("set_clamp", "c"), &MapRangeNode::set_clamp);
	ClassDB::bind_method(D_METHOD("get_clamp"), &MapRangeNode::get_clamp);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "clamp"), "set_clamp", "get_clamp");
}

MapRangeNode::MapRangeNode() {
	// Blender's twelve inputs: the float set, then the vector set. Both are
	// always present on the node; `data_type` chooses which is read.
	input_defs.push_back({ "Value", SocketType::Float, 0.0f });
	input_defs.push_back({ "From Min", SocketType::Float, 0.0f });
	input_defs.push_back({ "From Max", SocketType::Float, 1.0f });
	input_defs.push_back({ "To Min", SocketType::Float, 0.0f });
	input_defs.push_back({ "To Max", SocketType::Float, 1.0f });
	input_defs.push_back({ "Steps", SocketType::Float, 4.0f });
	input_defs.push_back({ "Vector", SocketType::Vector, Vector3() });
	input_defs.push_back({ "From_Min_FLOAT3", SocketType::Vector, Vector3(0, 0, 0) });
	input_defs.push_back({ "From_Max_FLOAT3", SocketType::Vector, Vector3(1, 1, 1) });
	input_defs.push_back({ "To_Min_FLOAT3", SocketType::Vector, Vector3(0, 0, 0) });
	input_defs.push_back({ "To_Max_FLOAT3", SocketType::Vector, Vector3(1, 1, 1) });
	input_defs.push_back({ "Steps_FLOAT3", SocketType::Vector, Vector3(4, 4, 4) });
	output_defs.push_back({ "Result", SocketType::Float, Variant() });
	output_defs.push_back({ "Vector", SocketType::Vector, Variant() });
}

// node_shader_map_range.cc. safe_divide returns 0 rather than infinity, which is
// what makes from_min == from_max give the start of the output range and
// steps == 0 give it too.
static inline float _mr_safe_divide(float a, float b) { return b != 0.0f ? a / b : 0.0f; }

// NOT a plain clamp: an INVERTED range is handled by swapping the bounds, so a
// node mapping to (1, 0) clamps between 0 and 1 rather than collapsing.
static inline float _mr_clamp_range(float value, float lo, float hi) {
	return (lo > hi) ? CLAMP(value, hi, lo) : CLAMP(value, lo, hi);
}

static float _mr_eval(int interp, bool do_clamp, float value, float from_min, float from_max,
		float to_min, float to_max, float steps) {
	float factor = _mr_safe_divide(value - from_min, from_max - from_min);
	switch (interp) {
		case 1: { // STEPPED
			factor = _mr_safe_divide(floorf(factor * (steps + 1.0f)), steps);
			float result = to_min + factor * (to_max - to_min);
			return do_clamp ? _mr_clamp_range(result, to_min, to_max) : result;
		}
		case 2: // SMOOTHSTEP -- clamps the FACTOR, and ignores the clamp flag,
				// which is why Blender hides the checkbox for it.
			factor = CLAMP(factor, 0.0f, 1.0f);
			factor = (3.0f - 2.0f * factor) * (factor * factor);
			return to_min + factor * (to_max - to_min);
		case 3: // SMOOTHERSTEP
			factor = CLAMP(factor, 0.0f, 1.0f);
			factor = factor * factor * factor * (factor * (factor * 6.0f - 15.0f) + 10.0f);
			return to_min + factor * (to_max - to_min);
		default: { // LINEAR
			float result = to_min + factor * (to_max - to_min);
			return do_clamp ? _mr_clamp_range(result, to_min, to_max) : result;
		}
	}
}

void MapRangeNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	const int base = (data_type == 1) ? 6 : 0;
	SocketValue in[6];
	bool is_field = false;
	int n = 1;
	AttrDomain domain = AttrDomain::Point;
	for (int i = 0; i < 6; i++) {
		in[i] = p_ctx.read_input(p_node_idx, base + i);
		if (in[i].is_field) {
			if (!is_field) {
				domain = in[i].domain;
			}
			is_field = true;
			n = MAX(n, in[i].field_size);
		}
	}

	if (data_type == 1) {
		auto at = [&](int e) -> Vector3 {
			Vector3 v = in[0].vec_at(e), fmin = in[1].vec_at(e), fmax = in[2].vec_at(e);
			Vector3 tmin = in[3].vec_at(e), tmax = in[4].vec_at(e), st = in[5].vec_at(e);
			return Vector3(
					_mr_eval(interpolation_type, clamp_result, v.x, fmin.x, fmax.x, tmin.x, tmax.x, st.x),
					_mr_eval(interpolation_type, clamp_result, v.y, fmin.y, fmax.y, tmin.y, tmax.y, st.y),
					_mr_eval(interpolation_type, clamp_result, v.z, fmin.z, fmax.z, tmin.z, tmax.z, st.z));
		};
		if (!is_field) {
			p_ctx.write_vector(p_node_idx, 1, at(0));
			return;
		}
		PackedVector3Array out;
		out.resize(n);
		for (int e = 0; e < n; e++) {
			out.ptrw()[e] = at(e);
		}
		p_ctx.write_vec_field(p_node_idx, 1, out, domain);
		return;
	}

	auto at = [&](int e) -> float {
		return _mr_eval(interpolation_type, clamp_result, in[0].float_at(e), in[1].float_at(e),
				in[2].float_at(e), in[3].float_at(e), in[4].float_at(e), in[5].float_at(e));
	};
	if (!is_field) {
		p_ctx.write_float(p_node_idx, 0, at(0));
		return;
	}
	PackedFloat32Array out;
	out.resize(n);
	for (int e = 0; e < n; e++) {
		out.ptrw()[e] = at(e);
	}
	p_ctx.write_float_field(p_node_idx, 0, out, domain);
}


// --- MixNode ---

// node_shader_map_range.cc, in GLSL. Two behaviours here are not what "map a
// range" suggests and both are in the CPU beside this:
//
//   * the clamp handles an INVERTED range by swapping the bounds, so a node
//     mapping to (1, 0) clamps between 0 and 1 rather than collapsing;
//   * SMOOTHSTEP and SMOOTHERSTEP clamp the FACTOR and ignore the clamp flag
//     entirely, which is why Blender hides the checkbox for them.
//
// safe_divide returning 0 rather than infinity is what makes from_min ==
// from_max give the start of the output range, and steps == 0 give it too.
static const char *MAPRANGE_GLSL_PRELUDE = R"GLSL(
#ifndef PN_MAPRANGE_HELPERS
#define PN_MAPRANGE_HELPERS
float pn_mr_safe_divide(float a, float b) { return (b != 0.0) ? a / b : 0.0; }
float pn_mr_clamp_range(float v, float lo, float hi) {
    return (lo > hi) ? clamp(v, hi, lo) : clamp(v, lo, hi);
}
float pn_map_range(int interp, bool do_clamp, float value, float from_min, float from_max,
                   float to_min, float to_max, float steps) {
    float factor = pn_mr_safe_divide(value - from_min, from_max - from_min);
    if (interp == 1) {
        factor = pn_mr_safe_divide(floor(factor * (steps + 1.0)), steps);
        float r = to_min + factor * (to_max - to_min);
        return do_clamp ? pn_mr_clamp_range(r, to_min, to_max) : r;
    }
    if (interp == 2) {
        factor = clamp(factor, 0.0, 1.0);
        factor = (3.0 - 2.0 * factor) * (factor * factor);
        return to_min + factor * (to_max - to_min);
    }
    if (interp == 3) {
        factor = clamp(factor, 0.0, 1.0);
        factor = factor * factor * factor * (factor * (factor * 6.0 - 15.0) + 10.0);
        return to_min + factor * (to_max - to_min);
    }
    float r = to_min + factor * (to_max - to_min);
    return do_clamp ? pn_mr_clamp_range(r, to_min, to_max) : r;
}
#endif
)GLSL";

String MapRangeNode::compile_glsl_prelude(int p_node_idx) const {
	return String(MAPRANGE_GLSL_PRELUDE);
}

String MapRangeNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// SIX SOCKETS, AND WHICH SIX DEPENDS ON data_type: the vector variant reads
	// 6..11, which is the same shape the CPU's `base` handles. Both outputs are
	// declared because GLSL cannot leave one undefined.
	const int base = (data_type == 1) ? 6 : 0;
	String in[6];
	for (int i = 0; i < 6; i++) {
		in[i] = p_ctx.in(p_node_idx, base + i);
	}
	const String interp = itos(interpolation_type);
	const String docl = clamp_result ? "true" : "false";

	if (data_type == 1) {
		String parts[3];
		const char *comp[3] = { ".x", ".y", ".z" };
		for (int k = 0; k < 3; k++) {
			parts[k] = "pn_map_range(" + interp + ", " + docl + ", " + in[0] + comp[k] + ", " +
					in[1] + comp[k] + ", " + in[2] + comp[k] + ", " + in[3] + comp[k] + ", " +
					in[4] + comp[k] + ", " + in[5] + comp[k] + ")";
		}
		return p_ctx.declare(p_node_idx, 0) + " = 0.0;\n\t" +
				p_ctx.declare(p_node_idx, 1) + " = vec3(" + parts[0] + ", " + parts[1] + ", " +
				parts[2] + ");";
	}
	return p_ctx.declare(p_node_idx, 0) + " = pn_map_range(" + interp + ", " + docl + ", " +
			in[0] + ", " + in[1] + ", " + in[2] + ", " + in[3] + ", " + in[4] + ", " + in[5] +
			");\n\t" + p_ctx.declare(p_node_idx, 1) + " = vec3(0.0);";
}

void MixNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_data_type", "t"), &MixNode::set_data_type);
	ClassDB::bind_method(D_METHOD("get_data_type"), &MixNode::get_data_type);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "data_type"), "set_data_type", "get_data_type");
	ClassDB::bind_method(D_METHOD("set_blend_type", "t"), &MixNode::set_blend_type);
	ClassDB::bind_method(D_METHOD("get_blend_type"), &MixNode::get_blend_type);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "blend_type"), "set_blend_type", "get_blend_type");
	ClassDB::bind_method(D_METHOD("set_clamp_factor", "c"), &MixNode::set_clamp_factor);
	ClassDB::bind_method(D_METHOD("get_clamp_factor"), &MixNode::get_clamp_factor);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "clamp_factor"), "set_clamp_factor", "get_clamp_factor");
	ClassDB::bind_method(D_METHOD("set_clamp_result", "c"), &MixNode::set_clamp_result);
	ClassDB::bind_method(D_METHOD("get_clamp_result"), &MixNode::get_clamp_result);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "clamp_result"), "set_clamp_result", "get_clamp_result");
	ClassDB::bind_method(D_METHOD("set_factor_mode", "m"), &MixNode::set_factor_mode);
	ClassDB::bind_method(D_METHOD("get_factor_mode"), &MixNode::get_factor_mode);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "factor_mode"), "set_factor_mode", "get_factor_mode");
}

MixNode::MixNode() {
	// Blender's ten inputs and four outputs, in Blender's order. Every data
	// type has its own pair and they all exist on the node at once;
	// `data_type` chooses which pair is read and which output is written.
	input_defs.push_back({ "Factor_Float", SocketType::Float, 0.5f });
	input_defs.push_back({ "Factor_Vector", SocketType::Vector, Vector3(0.5f, 0.5f, 0.5f) });
	input_defs.push_back({ "A_Float", SocketType::Float, 0.0f });
	input_defs.push_back({ "B_Float", SocketType::Float, 0.0f });
	input_defs.push_back({ "A_Vector", SocketType::Vector, Vector3() });
	input_defs.push_back({ "B_Vector", SocketType::Vector, Vector3() });
	input_defs.push_back({ "A_Color", SocketType::Color, Color(0.5f, 0.5f, 0.5f, 1.0f) });
	input_defs.push_back({ "B_Color", SocketType::Color, Color(0.5f, 0.5f, 0.5f, 1.0f) });
	input_defs.push_back({ "A_Rotation", SocketType::Rotation, Quaternion() });
	input_defs.push_back({ "B_Rotation", SocketType::Rotation, Quaternion() });
	output_defs.push_back({ "Result_Float", SocketType::Float, Variant() });
	output_defs.push_back({ "Result_Vector", SocketType::Vector, Variant() });
	output_defs.push_back({ "Result_Color", SocketType::Color, Variant() });
	output_defs.push_back({ "Result_Rotation", SocketType::Rotation, Variant() });
}

static const char *MIX_QUAT_GLSL_PRELUDE = R"GLSL(
#ifndef PN_QUAT_SLERP
#define PN_QUAT_SLERP
// A PORT OF godot's Quaternion::slerp, not a shared text.
//
// Every other .inc in src/shared/ exists so one algorithm is compiled twice.
// This one cannot be: the CPU side calls godot-cpp's Quaternion::slerp, which
// is godot's C++ and not ours to dual-compile. So this IS a second
// implementation, and the emitted-op gate is what holds it to the first --
// which is the same instrument, and the same argument, that the erosion
// filter's history says a transcription needs.
//
// Read off godot-cpp/src/variant/quaternion.cpp:115-153, including
// CMP_EPSILON = 0.00001 and the sign flip when the two point away from each
// other. ⚠ godot computes scale0 through a DOUBLE literal -- `Math::sin((1.0 -
// p_weight) * omega)` -- and scale1 in float. That asymmetry cannot be
// reproduced here and is why Rotation is compared within tolerance rather
// than exactly.
vec4 pn_quat_slerp(vec4 from, vec4 to, float t) {
	float cosom = dot(from, to);
	vec4 to1;
	if (cosom < 0.0) {
		cosom = -cosom;
		to1 = -to;
	} else {
		to1 = to;
	}
	float scale0;
	float scale1;
	if ((1.0 - cosom) > 0.00001) {
		float omega = acos(cosom);
		float sinom = sin(omega);
		scale0 = sin((1.0 - t) * omega) / sinom;
		scale1 = sin(t * omega) / sinom;
	} else {
		scale0 = 1.0 - t;
		scale1 = t;
	}
	return from * scale0 + to1 * scale1;
}

// godot's Quaternion::normalized() is `*this / length()`, with no guard.
vec4 pn_quat_normalize(vec4 q) { return q / length(q); }
#endif
)GLSL";

String MixNode::compile_glsl_prelude(int p_node_idx) const {
	if (data_type == 2) {
		return String(BLENDER_RAMP_BLEND_GLSL);
	}
	if (data_type == 14) {
		return String(MIX_QUAT_GLSL_PRELUDE);
	}
	return String();
}

String MixNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// The socket LAYOUT depends on data_type, exactly as execute() reads it:
	// Float takes 2/3, Vector 4/5, and a NON-UNIFORM vector factor reads socket
	// 1 rather than 0. Getting that wrong reads someone else's socket and looks
	// like a wrong blend rather than a wrong address.
	const bool is_vector = (data_type == 1);
	const bool is_color = (data_type == 2);
	const bool is_rotation = (data_type == 14);
	const bool vector_factor = is_vector && factor_mode != 0;
	// Same ladder execute() uses: Rotation 8/9, Colour 6/7, Vector 4/5,
	// Float 2/3. Getting it wrong reads someone else's socket and looks like a
	// wrong blend rather than a wrong address.
	const int a_sock = is_rotation ? 8 : (is_color ? 6 : (is_vector ? 4 : 2));

	String t = p_ctx.in(p_node_idx, vector_factor ? 1 : 0);
	const String a = p_ctx.in(p_node_idx, a_sock);
	const String b = p_ctx.in(p_node_idx, a_sock + 1);

	if (clamp_factor) {
		t = (vector_factor ? String("clamp(") + t + ", vec3(0.0), vec3(1.0))"
						   : String("clamp(") + t + ", 0.0, 1.0)");
	}

	// Declared in socket order so a consumer of any output gets a defined
	// variable; the ones this configuration does not compute are zero, which is
	// what the CPU leaves them as too.
	String out;
	const String f_decl = p_ctx.declare(p_node_idx, 0);
	const String v_decl = p_ctx.declare(p_node_idx, 1);
	if (is_color) {
		// ramp_blend's nineteen modes. clamp_result clamps ALL FOUR channels,
		// alpha included, which is what the CPU does.
		String e = String("br_ramp_blend(") + itos(blend_type) + String(", ") + a +
				String(", ") + t + String(", ") + b + String(")");
		if (clamp_result) {
			e = String("clamp(") + e + String(", vec4(0.0), vec4(1.0))");
		}
		out = f_decl + String(" = 0.0;\n\t") + v_decl + String(" = vec3(0.0);\n\t");
		out += p_ctx.declare(p_node_idx, 2) + String(" = ") + e + String(";\n\t");
		out += p_ctx.declare(p_node_idx, 3) + String(" = vec4(0.0);");
		return out;
	}
	if (is_rotation) {
		// Both operands are NORMALISED first, exactly as execute() does --
		// a.normalized().slerp(b.normalized(), t). clamp_result does not apply
		// to a rotation on either side.
		out = f_decl + String(" = 0.0;\n\t") + v_decl + String(" = vec3(0.0);\n\t");
		out += p_ctx.declare(p_node_idx, 2) + String(" = vec4(0.0);\n\t");
		out += p_ctx.declare(p_node_idx, 3) + String(" = pn_quat_slerp(pn_quat_normalize(") +
				a + String("), pn_quat_normalize(") + b + String("), ") + t + String(");");
		return out;
	}
	if (is_vector) {
		// a * (1 - t) + b * t componentwise -- the same algebra as mix() and a
		// different rounding, which is what the CPU does and therefore what the
		// gate compares against.
		const String tv = vector_factor ? t : (String("vec3(") + t + ")");
		out = f_decl + String(" = 0.0;\n\t") + v_decl + " = (" + a + " * (vec3(1.0) - " + tv +
				") + " + b + " * " + tv + ");";
	} else {
		out = f_decl + String(" = (") + a + " * (1.0 - " + t + ") + " + b + " * " + t +
				");\n\t" + v_decl + " = vec3(0.0);";
	}
	// The colour and rotation outputs exist on the node whatever the data type,
	// and a graph may read them; they are zero here because this configuration
	// does not produce them.
	out += "\n\t" + p_ctx.declare(p_node_idx, 2) + " = vec4(0.0);";
	out += "\n\t" + p_ctx.declare(p_node_idx, 3) + " = vec4(0.0);";
	return out;
}

void MixNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	// RNA's data_type numbering: FLOAT 0, VECTOR 1, RGBA 2, ROTATION 14.
	const bool is_vector = (data_type == 1);
	const bool is_color = (data_type == 2);
	const bool is_rotation = (data_type == 14);
	const bool vector_factor = is_vector && factor_mode != 0;

	SocketValue fac = p_ctx.read_input(p_node_idx, vector_factor ? 1 : 0);
	int a_sock = is_rotation ? 8 : (is_color ? 6 : (is_vector ? 4 : 2));
	SocketValue a = p_ctx.read_input(p_node_idx, a_sock);
	SocketValue b = p_ctx.read_input(p_node_idx, a_sock + 1);

	bool is_field = fac.is_field || a.is_field || b.is_field;
	int n = MAX(fac.field_size, MAX(a.field_size, b.field_size));
	if (n == 0) {
		n = 1;
	}
	AttrDomain domain = fac.is_field ? fac.domain : (a.is_field ? a.domain : b.domain);
	const int out_sock = is_rotation ? 3 : (is_color ? 2 : (is_vector ? 1 : 0));

	// A NON-UNIFORM vector factor is not `interpolate` with a scalar: Blender
	// writes it out as a * (1 - t) + b * t per component, which is the same
	// algebra and a different rounding.
	auto vec_at = [&](int e) -> Vector3 {
		Vector3 av = a.vec_at(e), bv = b.vec_at(e);
		if (vector_factor) {
			Vector3 t = fac.vec_at(e);
			if (clamp_factor) {
				t = Vector3(CLAMP(t.x, 0.0f, 1.0f), CLAMP(t.y, 0.0f, 1.0f), CLAMP(t.z, 0.0f, 1.0f));
			}
			return Vector3(av.x * (1.0f - t.x) + bv.x * t.x, av.y * (1.0f - t.y) + bv.y * t.y,
					av.z * (1.0f - t.z) + bv.z * t.z);
		}
		float t = fac.float_at(e);
		if (clamp_factor) {
			t = CLAMP(t, 0.0f, 1.0f);
		}
		return av * (1.0f - t) + bv * t;
	};
	auto float_at = [&](int e) -> float {
		float t = fac.float_at(e);
		if (clamp_factor) {
			t = CLAMP(t, 0.0f, 1.0f);
		}
		return a.float_at(e) * (1.0f - t) + b.float_at(e) * t;
	};
	auto color_at = [&](int e) -> Color {
		float t = fac.float_at(e);
		if (clamp_factor) {
			t = CLAMP(t, 0.0f, 1.0f);
		}
		Color r = bramp::ramp_blend(blend_type, a.color_at(e), t, b.color_at(e));
		if (clamp_result) {
			r = Color(CLAMP(r.r, 0.0f, 1.0f), CLAMP(r.g, 0.0f, 1.0f), CLAMP(r.b, 0.0f, 1.0f),
					CLAMP(r.a, 0.0f, 1.0f));
		}
		return r;
	};
	auto rot_at = [&](int e) -> Quaternion {
		float t = fac.float_at(e);
		if (clamp_factor) {
			t = CLAMP(t, 0.0f, 1.0f);
		}
		return a.quat_at(e).normalized().slerp(b.quat_at(e).normalized(), t);
	};

	if (!is_field) {
		if (is_color) {
			p_ctx.write_color(p_node_idx, out_sock, color_at(0));
		} else if (is_vector) {
			p_ctx.write_vector(p_node_idx, out_sock, vec_at(0));
		} else if (is_rotation) {
			p_ctx.write_rotation(p_node_idx, out_sock, rot_at(0));
		} else {
			p_ctx.write_float(p_node_idx, out_sock, float_at(0));
		}
		return;
	}

	if (is_color) {
		PackedColorArray out;
		out.resize(n);
		for (int e = 0; e < n; e++) {
			out.ptrw()[e] = color_at(e);
		}
		p_ctx.write_color_field(p_node_idx, out_sock, out, domain);
	} else if (is_vector) {
		PackedVector3Array out;
		out.resize(n);
		for (int e = 0; e < n; e++) {
			out.ptrw()[e] = vec_at(e);
		}
		p_ctx.write_vec_field(p_node_idx, out_sock, out, domain);
	} else if (is_rotation) {
		PackedVector4Array out;
		out.resize(n);
		for (int e = 0; e < n; e++) {
			Quaternion q = rot_at(e);
			out.ptrw()[e] = Vector4(q.x, q.y, q.z, q.w);
		}
		p_ctx.write_rot_field(p_node_idx, out_sock, out, domain);
	} else {
		PackedFloat32Array out;
		out.resize(n);
		for (int e = 0; e < n; e++) {
			out.ptrw()[e] = float_at(e);
		}
		p_ctx.write_float_field(p_node_idx, out_sock, out, domain);
	}
}

// --- ColorRampNode ---

void ColorRampNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_ramp_stops", "stops"), &ColorRampNode::set_ramp_stops);
	ClassDB::bind_method(D_METHOD("get_ramp_stops"), &ColorRampNode::get_ramp_stops);
	ADD_PROPERTY(PropertyInfo(Variant::PACKED_FLOAT32_ARRAY, "ramp_stops"),
			"set_ramp_stops", "get_ramp_stops");
	ClassDB::bind_method(D_METHOD("set_interpolation", "i"), &ColorRampNode::set_interpolation);
	ClassDB::bind_method(D_METHOD("get_interpolation"), &ColorRampNode::get_interpolation);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "interpolation"),
			"set_interpolation", "get_interpolation");
	ClassDB::bind_method(D_METHOD("set_color_mode", "m"), &ColorRampNode::set_color_mode);
	ClassDB::bind_method(D_METHOD("get_color_mode"), &ColorRampNode::get_color_mode);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "color_mode"), "set_color_mode", "get_color_mode");
	ClassDB::bind_method(D_METHOD("set_hue_interpolation", "h"),
			&ColorRampNode::set_hue_interpolation);
	ClassDB::bind_method(D_METHOD("get_hue_interpolation"),
			&ColorRampNode::get_hue_interpolation);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "hue_interpolation"),
			"set_hue_interpolation", "get_hue_interpolation");
}

ColorRampNode::ColorRampNode() {
	// Blender calls this socket "Factor" and gives it the identifier "Fac".
	// blender-bridge resolves sockets BY NAME, so "Fac" matched nothing and fell
	// back to the index. And its default is 0.5, not 0 -- which is what an
	// unconnected Factor evaluates to in a bridged graph.
	input_defs.push_back({ "Factor", SocketType::Float, 0.5f });
	output_defs.push_back({ "Color", SocketType::Color, Color(1, 1, 1, 1) });
	output_defs.push_back({ "Alpha", SocketType::Float, Variant() });

	// A FRESH BLENDER COLOR RAMP, so a node created here and one created there
	// start from the same gradient: two stops, black at 0 and white at 1, RGB,
	// LINEAR, hue NEAR. An empty ramp would evaluate to nothing at all, which
	// is the failure this node used to have in a different costume.
	ramp_stops = PackedFloat32Array();
	ramp_stops.push_back(0.0f);
	ramp_stops.push_back(0.0f);
	ramp_stops.push_back(0.0f);
	ramp_stops.push_back(0.0f);
	ramp_stops.push_back(1.0f);
	ramp_stops.push_back(1.0f);
	ramp_stops.push_back(1.0f);
	ramp_stops.push_back(1.0f);
	ramp_stops.push_back(1.0f);
	ramp_stops.push_back(1.0f);
}

void ColorRampNode::set_ramp_stops(const PackedFloat32Array &p_stops) {
	// Validate HERE and not in execute(): execute runs per evaluation and a
	// warning in it would either spam or be swallowed, while a set happens once
	// and is where the bad data actually arrives.
	const int n = p_stops.size();
	if (n % 5 != 0) {
		UtilityFunctions::push_warning(
				"ColorRampNode: ramp_stops holds ", n,
				" floats, which is not a whole number of 5-float stops "
				"(position, r, g, b, a). The trailing ", n % 5, " will be ignored.");
	}
	if (n / 5 > BCOBA_MAX_STOPS) {
		UtilityFunctions::push_warning(
				"ColorRampNode: ramp_stops holds ", n / 5,
				" stops; Blender's own limit is ", BCOBA_MAX_STOPS,
				" (MAXCOLORBAND) and the rest will be ignored.");
	}
	ramp_stops = p_stops;
}

void ColorRampNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	bcoba::ColorBand coba;
	coba.ipotype = interpolation;
	coba.ipotype_hue = hue_interpolation;
	coba.color_mode = color_mode;

	const int stop_count = MIN(ramp_stops.size() / 5, BCOBA_MAX_STOPS);
	const float *sp = ramp_stops.ptr();
	for (int s = 0; s < stop_count; s++) {
		bcoba::Stop &st = coba.data[s];
		st.pos = sp[s * 5 + 0];
		st.r = sp[s * 5 + 1];
		st.g = sp[s * 5 + 2];
		st.b = sp[s * 5 + 3];
		st.a = sp[s * 5 + 4];
	}
	coba.tot = stop_count;

	// THE BAND IS BUILT ONCE, outside the per-element loop. Blender's node is a
	// FUNCTION NODE, so it is evaluated per element on the consumer's domain --
	// `read_float` took element 0, which gave every vertex the first one's
	// colour. A constant, which looks like a result. The rotation family failed
	// the same way before #62 and it read as "the voronoi contributes nothing".
	SocketValue fac = p_ctx.read_input(p_node_idx, 0);

	// An EMPTY band is the one case Blender declines to evaluate. It leaves the
	// caller's buffer alone, so opaque black is ours -- and it is only reachable
	// by clearing the property, since the constructor seeds Blender's default.
	auto eval = [&](int e) -> Color {
		float out[4] = { 0.0f, 0.0f, 0.0f, 1.0f };
		bcoba::colorband_evaluate(coba, fac.float_at(e), out);
		return Color(out[0], out[1], out[2], out[3]);
	};

	if (!fac.is_field) {
		// Alpha is the ramp's interpolated alpha, which is what Blender's
		// ColorBandFunction writes: `alphas[i] = color.a`.
		const Color c = eval(0);
		p_ctx.write_color(p_node_idx, 0, c);
		p_ctx.write_float(p_node_idx, 1, c.a);
		return;
	}

	int n = fac.field_size;
	if (n == 0) {
		n = 1;
	}
	PackedColorArray cols;
	PackedFloat32Array alphas;
	cols.resize(n);
	alphas.resize(n);
	for (int e = 0; e < n; e++) {
		const Color c = eval(e);
		cols.ptrw()[e] = c;
		alphas.ptrw()[e] = c.a;
	}
	p_ctx.write_color_field(p_node_idx, 0, cols, fac.domain);
	p_ctx.write_float_field(p_node_idx, 1, alphas, fac.domain);
}

// --- The curve decoder shared by Float Curve and RGB Curves ---
//
// Not in shared/, because shared/ holds ports of Blender's own code and this is
// ours: the flat three-array encoding the bridge and the .tres use.
static void _fill_curve_mapping(bcuma::CurveMapping &r_cumap,
		const PackedFloat32Array &p_points, const PackedInt32Array &p_handles,
		const PackedInt32Array &p_counts, int p_extend, bool p_use_clip,
		const PackedFloat32Array &p_clip) {
	r_cumap.flag = (p_use_clip ? bcuma::CUMA_DO_CLIP : 0) |
			(p_extend & bcuma::CUMA_EXTEND_EXTRAPOLATE);
	if (p_clip.size() >= 4) {
		r_cumap.clip_xmin = p_clip[0];
		r_cumap.clip_ymin = p_clip[1];
		r_cumap.clip_xmax = p_clip[2];
		r_cumap.clip_ymax = p_clip[3];
	}

	const int ncurves = MIN(p_counts.size(), BCUMA_MAX_CURVES);
	r_cumap.totcurve = MAX(ncurves, 1);

	int at = 0; // running point index across the concatenated curves
	for (int c = 0; c < ncurves; c++) {
		bcuma::CurveMap &cm = r_cumap.cm[c];
		const int count = p_counts[c];
		int kept = 0;
		for (int i = 0; i < count; i++) {
			const int pi = at + i;
			if (pi * 2 + 1 >= p_points.size() || kept >= BCUMA_MAX_POINTS) {
				break;
			}
			cm.points[kept].x = p_points[pi * 2 + 0];
			cm.points[kept].y = p_points[pi * 2 + 1];
			// The bridge sends Blender's handle_type, which is the POINT FLAG
			// (AUTO 0, VECTOR 2, AUTO_CLAMPED 4), not the HD_* the bezier code
			// wants (HD_AUTO 1, HD_VECT 2, HD_AUTO_ANIM 4). Two of the three
			// numbers collide, so passing it through unconverted looks right
			// for VECTOR and turns AUTO into HD_FREE -- which calchandle skips
			// entirely, leaving both handles on the knot.
			cm.points[kept].handle = bcuma::handle_from_point_flag(
					pi < p_handles.size() ? p_handles[pi] : 0);
			kept++;
		}
		cm.totpoint = kept;
		cm.built = false;
		at += count;
	}
	bcuma::curvemapping_init(r_cumap);
}

// --- FloatCurveNode ---

void FloatCurveNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_curve_points", "p"), &FloatCurveNode::set_curve_points);
	ClassDB::bind_method(D_METHOD("get_curve_points"), &FloatCurveNode::get_curve_points);
	ADD_PROPERTY(PropertyInfo(Variant::PACKED_FLOAT32_ARRAY, "curve_points"),
			"set_curve_points", "get_curve_points");
	ClassDB::bind_method(D_METHOD("set_curve_handles", "p"), &FloatCurveNode::set_curve_handles);
	ClassDB::bind_method(D_METHOD("get_curve_handles"), &FloatCurveNode::get_curve_handles);
	ADD_PROPERTY(PropertyInfo(Variant::PACKED_INT32_ARRAY, "curve_handles"),
			"set_curve_handles", "get_curve_handles");
	ClassDB::bind_method(D_METHOD("set_curve_counts", "p"), &FloatCurveNode::set_curve_counts);
	ClassDB::bind_method(D_METHOD("get_curve_counts"), &FloatCurveNode::get_curve_counts);
	ADD_PROPERTY(PropertyInfo(Variant::PACKED_INT32_ARRAY, "curve_counts"),
			"set_curve_counts", "get_curve_counts");
	ClassDB::bind_method(D_METHOD("set_extend", "e"), &FloatCurveNode::set_extend);
	ClassDB::bind_method(D_METHOD("get_extend"), &FloatCurveNode::get_extend);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "extend"), "set_extend", "get_extend");
	ClassDB::bind_method(D_METHOD("set_use_clip", "c"), &FloatCurveNode::set_use_clip);
	ClassDB::bind_method(D_METHOD("get_use_clip"), &FloatCurveNode::get_use_clip);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_clip"), "set_use_clip", "get_use_clip");
	ClassDB::bind_method(D_METHOD("set_clip_rect", "r"), &FloatCurveNode::set_clip_rect);
	ClassDB::bind_method(D_METHOD("get_clip_rect"), &FloatCurveNode::get_clip_rect);
	ADD_PROPERTY(PropertyInfo(Variant::PACKED_FLOAT32_ARRAY, "clip_rect"),
			"set_clip_rect", "get_clip_rect");
}

FloatCurveNode::FloatCurveNode() {
	// Blender's Factor comes FIRST and blends the curve's result against the
	// input. Ours had only Value, so a graph wiring the factor drove the value.
	// Value's default is 1.0 in Blender, not 0 -- measured, not read off the
	// declaration.
	input_defs.push_back({ "Factor", SocketType::Float, 1.0f });
	input_defs.push_back({ "Value", SocketType::Float, 1.0f });
	output_defs.push_back({ "Value", SocketType::Float, Variant() });

	// A FRESH BLENDER FLOAT CURVE: the identity from (0,0) to (1,1), auto
	// handles, extrapolating, clipped to the unit square.
	curve_points.push_back(0.0f);
	curve_points.push_back(0.0f);
	curve_points.push_back(1.0f);
	curve_points.push_back(1.0f);
	curve_handles.push_back(0);
	curve_handles.push_back(0);
	curve_counts.push_back(2);
	clip_rect.push_back(0.0f);
	clip_rect.push_back(0.0f);
	clip_rect.push_back(1.0f);
	clip_rect.push_back(1.0f);
}

void FloatCurveNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	bcuma::CurveMapping cumap;
	_fill_curve_mapping(cumap, curve_points, curve_handles, curve_counts,
			extend, use_clip, clip_rect);

	// THE TABLE IS BUILT ONCE, outside the per-element loop -- 257 entries per
	// curve, so building it per vertex would be ruinous as well as wrong.
	SocketValue fs = p_ctx.read_input(p_node_idx, 0);
	SocketValue vs = p_ctx.read_input(p_node_idx, 1);

	auto eval = [&](int e) -> float {
		const float value = vs.float_at(e);
		const float factor = fs.float_at(e);
		float out = bcuma::curvemapping_evaluateF(cumap, 0, value);
		// Blender blends only when fac differs from 1, and toward the INPUT.
		if (factor != 1.0f) {
			out = (1.0f - factor) * value + factor * out;
		}
		return out;
	};

	// EITHER input can be the field. Factor being one is not exotic: masking a
	// curve's effect per vertex is the ordinary reason to wire it.
	if (!fs.is_field && !vs.is_field) {
		p_ctx.write_float(p_node_idx, 0, eval(0));
		return;
	}

	int n = MAX(fs.field_size, vs.field_size);
	if (n == 0) {
		n = 1;
	}
	const AttrDomain domain = fs.is_field ? fs.domain : vs.domain;
	PackedFloat32Array out;
	out.resize(n);
	for (int e = 0; e < n; e++) {
		out.ptrw()[e] = eval(e);
	}
	p_ctx.write_float_field(p_node_idx, 0, out, domain);
}

// --- RGBCurvesNode ---

void RGBCurvesNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_curve_points", "p"), &RGBCurvesNode::set_curve_points);
	ClassDB::bind_method(D_METHOD("get_curve_points"), &RGBCurvesNode::get_curve_points);
	ADD_PROPERTY(PropertyInfo(Variant::PACKED_FLOAT32_ARRAY, "curve_points"),
			"set_curve_points", "get_curve_points");
	ClassDB::bind_method(D_METHOD("set_curve_handles", "p"), &RGBCurvesNode::set_curve_handles);
	ClassDB::bind_method(D_METHOD("get_curve_handles"), &RGBCurvesNode::get_curve_handles);
	ADD_PROPERTY(PropertyInfo(Variant::PACKED_INT32_ARRAY, "curve_handles"),
			"set_curve_handles", "get_curve_handles");
	ClassDB::bind_method(D_METHOD("set_curve_counts", "p"), &RGBCurvesNode::set_curve_counts);
	ClassDB::bind_method(D_METHOD("get_curve_counts"), &RGBCurvesNode::get_curve_counts);
	ADD_PROPERTY(PropertyInfo(Variant::PACKED_INT32_ARRAY, "curve_counts"),
			"set_curve_counts", "get_curve_counts");
	ClassDB::bind_method(D_METHOD("set_extend", "e"), &RGBCurvesNode::set_extend);
	ClassDB::bind_method(D_METHOD("get_extend"), &RGBCurvesNode::get_extend);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "extend"), "set_extend", "get_extend");
	ClassDB::bind_method(D_METHOD("set_use_clip", "c"), &RGBCurvesNode::set_use_clip);
	ClassDB::bind_method(D_METHOD("get_use_clip"), &RGBCurvesNode::get_use_clip);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "use_clip"), "set_use_clip", "get_use_clip");
	ClassDB::bind_method(D_METHOD("set_clip_rect", "r"), &RGBCurvesNode::set_clip_rect);
	ClassDB::bind_method(D_METHOD("get_clip_rect"), &RGBCurvesNode::get_clip_rect);
	ADD_PROPERTY(PropertyInfo(Variant::PACKED_FLOAT32_ARRAY, "clip_rect"),
			"set_clip_rect", "get_clip_rect");
}

RGBCurvesNode::RGBCurvesNode() {
	// Blender NAMES this socket "Factor" and gives it the identifier "Fac";
	// blender-bridge resolves by NAME, so "Fac" matched nothing.
	input_defs.push_back({ "Factor", SocketType::Float, 1.0f });
	input_defs.push_back({ "Color", SocketType::Color, Color(1, 1, 1, 1) });
	output_defs.push_back({ "Color", SocketType::Color, Variant() });

	// FOUR identity curves, as a fresh Blender node has: R, G, B, then the
	// combined one.
	for (int c = 0; c < 4; c++) {
		curve_points.push_back(0.0f);
		curve_points.push_back(0.0f);
		curve_points.push_back(1.0f);
		curve_points.push_back(1.0f);
		curve_handles.push_back(0);
		curve_handles.push_back(0);
		curve_counts.push_back(2);
	}
	clip_rect.push_back(0.0f);
	clip_rect.push_back(0.0f);
	clip_rect.push_back(1.0f);
	clip_rect.push_back(1.0f);
}

void RGBCurvesNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	bcuma::CurveMapping cumap;
	_fill_curve_mapping(cumap, curve_points, curve_handles, curve_counts,
			extend, use_clip, clip_rect);

	// Four tables, built ONCE outside the per-element loop.
	SocketValue fs = p_ctx.read_input(p_node_idx, 0);
	SocketValue cs = p_ctx.read_input(p_node_idx, 1);

	auto eval = [&](int e) -> Color {
		const Color in = cs.color_at(e);
		const float src[3] = { in.r, in.g, in.b };
		float out[3];
		// The COMBINED curve runs first, per channel, and its result goes
		// through that channel's own curve. This path is UNCLIPPED -- Float
		// Curve clamps to clipr and this does not, from the same flag.
		bcuma::curvemapping_evaluateRGBF(cumap, out, src);

		// Blender's interp_v3_v3v3(out, in, out, fac): only when fac differs
		// from 1, and only the RGB.
		const float factor = fs.float_at(e);
		if (factor != 1.0f) {
			for (int c = 0; c < 3; c++) {
				out[c] = src[c] + factor * (out[c] - src[c]);
			}
		}
		// ALPHA IS FORCED TO 1, unconditionally and after the blend. Not
		// carried from the input and not blended -- `col_out[i].a = 1.0f`.
		return Color(out[0], out[1], out[2], 1.0f);
	};

	if (!fs.is_field && !cs.is_field) {
		p_ctx.write_color(p_node_idx, 0, eval(0));
		return;
	}

	int n = MAX(fs.field_size, cs.field_size);
	if (n == 0) {
		n = 1;
	}
	const AttrDomain domain = fs.is_field ? fs.domain : cs.domain;
	PackedColorArray out;
	out.resize(n);
	for (int e = 0; e < n; e++) {
		out.ptrw()[e] = eval(e);
	}
	p_ctx.write_color_field(p_node_idx, 0, out, domain);
}

} // namespace godot