OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / src / nodes / noise_nodes.cpp

2198 lines · cpp ref:
#include "noise_nodes.h"
#include "../core/proc_parallel.h"

#include "../shared/blender_noise.h"
#include "../shared/blender_perlin.h"
#include "../shared/blender_voronoi.h"
#include "erosion_cpp_shim.h"
#include "erosion_wrapper_glsl.h"   // the shared Catmull-Rom sampler
#include <godot_cpp/classes/file_access.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include "../graph/graph_executor.h"
#include "../graph/proc_node_graph.h"
#include <cmath>
#include "../gpu/blender_hash_glsl.gen.h"
#include "../gpu/blender_perlin_glsl.gen.h"
#include "../gpu/blender_voronoi_glsl.gen.h"
#include "../gpu/proc_simplex_glsl.gen.h"

namespace godot {

// The Ashima simplex and Blender's Perlin used to live here, ~375 lines of
// them, with their own Jenkins Lookup3 hash -- a FOURTH copy of it. Both were
// paired with hand-written GLSL twins in GpuCompiler::get_noise_library_glsl()
// and nothing compared either pair.
//
// They are src/shared/proc_simplex.inc and src/shared/blender_perlin.inc now,
// compiled as C++ here through blender_noise_cpp_shim.h and as GLSL for the
// shader. Deleting them was a RELOCATION, not a change, and that is measured
// rather than asserted: the code removed here agreed with the .inc that
// replaced it on 660000/660000 comparisons for Perlin and 240000/240000 for
// simplex, bit-for-bit.

// --- PerlinNoiseNode ---

void PerlinNoiseNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_noise_dimensions", "dimensions"), &PerlinNoiseNode::set_noise_dimensions);
	ClassDB::bind_method(D_METHOD("get_noise_dimensions"), &PerlinNoiseNode::get_noise_dimensions);
	ClassDB::bind_method(D_METHOD("set_noise_type", "type"), &PerlinNoiseNode::set_noise_type);
	ClassDB::bind_method(D_METHOD("get_noise_type"), &PerlinNoiseNode::get_noise_type);
	ClassDB::bind_method(D_METHOD("set_normalize", "normalize"), &PerlinNoiseNode::set_normalize);
	ClassDB::bind_method(D_METHOD("get_normalize"), &PerlinNoiseNode::get_normalize);

	ADD_PROPERTY(PropertyInfo(Variant::INT, "noise_dimensions"), "set_noise_dimensions", "get_noise_dimensions");
	ADD_PROPERTY(PropertyInfo(Variant::INT, "noise_type"), "set_noise_type", "get_noise_type");
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "normalize"), "set_normalize", "get_normalize");
}

PerlinNoiseNode::PerlinNoiseNode() {
	// Blender's sockets and Blender's NAMES -- the first output is "Factor",
	// not "Fac", and the bridge resolves by name.
	input_defs.push_back({ "Vector", SocketType::Vector, Vector3() });
	input_defs.push_back({ "W", SocketType::Float, 0.0f });
	input_defs.push_back({ "Scale", SocketType::Float, 5.0f });
	input_defs.push_back({ "Detail", SocketType::Float, 2.0f });
	input_defs.push_back({ "Roughness", SocketType::Float, 0.5f });
	input_defs.push_back({ "Lacunarity", SocketType::Float, 2.0f });
	input_defs.push_back({ "Offset", SocketType::Float, 0.0f });
	input_defs.push_back({ "Gain", SocketType::Float, 1.0f });
	input_defs.push_back({ "Distortion", SocketType::Float, 0.0f });
	output_defs.push_back({ "Factor", SocketType::Float, Variant() });
	output_defs.push_back({ "Color", SocketType::Color, Variant() });
}

// node_shader_tex_noise.cc's dispatch over src/shared/blender_perlin.h.
//
// Color is a THREE-COMPONENT noise at different seeds, not the factor repeated
// in grey, and the seeds differ per dimension. Ours wrote grey, so every
// Color-driven graph across the bridge was wrong even where the Factor was
// exact -- which it was, for 3D FBM, and only for 3D FBM.
String PerlinNoiseNode::compile_glsl_prelude(int p_node_idx) const {
	// BOTH, in dependency order: perlin's lattice gradients ARE the hash, and
	// GLSL has no #include to say so. Each carries its own #ifndef, so a graph
	// holding this node and a White Noise node emits the hash once.
	return String(BLENDER_HASH_GLSL) + String(BLENDER_PERLIN_GLSL);
}

String PerlinNoiseNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// Mirrors execute() case for case: node_shader_tex_noise.cc's dispatch
	// over blender_perlin.inc. The dimension picks BOTH the coordinate built
	// from Vector and W and the per-dimension function -- 1D takes W alone
	// scaled, 2D drops z, 4D appends W -- and each has its own scale constant
	// inside perlin_signed, so a dimension mix-up is a scale error and not
	// merely different noise.
	const String v = p_ctx.in(p_node_idx, 0);
	const String w = p_ctx.in(p_node_idx, 1);
	const String scale = p_ctx.in(p_node_idx, 2);
	const String args = String(", ") + p_ctx.in(p_node_idx, 3) + String(", ") +
			p_ctx.in(p_node_idx, 4) + String(", ") + p_ctx.in(p_node_idx, 5) + String(", ") +
			p_ctx.in(p_node_idx, 6) + String(", ") + p_ctx.in(p_node_idx, 7) + String(", ") +
			p_ctx.in(p_node_idx, 8) + String(", ") + itos(noise_type) + String(", ") +
			String(normalize ? "true" : "false") + String(")");

	// The coordinate is named, not inlined: the Factor and the Colour both
	// need it, and Blender's scale is applied to the coordinate rather than
	// inside the noise.
	const String pv = String("pn") + itos(p_node_idx) + String("_p");
	String decl_p;
	String fn;
	switch (noise_dimensions) {
		case 1:
			decl_p = String("float ") + pv + String(" = (") + w + String(") * (") + scale +
					String(");");
			fn = "bp_perlin_fractal_distorted_1(";
			break;
		case 2:
			decl_p = String("vec2 ") + pv + String(" = vec2((") + v + String(").x, (") + v +
					String(").y) * (") + scale + String(");");
			fn = "bp_perlin_fractal_distorted_2(";
			break;
		case 4:
			decl_p = String("vec4 ") + pv + String(" = vec4(") + v + String(", ") + w +
					String(") * (") + scale + String(");");
			fn = "bp_perlin_fractal_distorted_4(";
			break;
		default:
			decl_p = String("vec3 ") + pv + String(" = (") + v + String(") * (") + scale +
					String(");");
			fn = "bp_perlin_fractal_distorted_3(";
			break;
	}

	return decl_p + String("\n\t") + p_ctx.declare(p_node_idx, 0) + String(" = ") + fn + pv +
			args + String(";\n\t") + p_ctx.declare(p_node_idx, 1) +
			String(" = vec4(bp_perlin_float3_fractal_distorted(") + pv + args + String(", 1.0);");
}

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

	auto at = [&](int e, float &r_fac, Color &r_col) {
		Vector3 v = in[0].vec_at(e);
		float w = in[1].float_at(e);
		float scale = in[2].float_at(e);
		float detail = in[3].float_at(e);
		float rough = in[4].float_at(e);
		float lac = in[5].float_at(e);
		float off = in[6].float_at(e);
		float gain = in[7].float_at(e);
		float dist = in[8].float_at(e);
		Vector3 c;
		switch (noise_dimensions) {
			case 1: {
				float p = w * scale;
				r_fac = bperlin::perlin_fractal_distorted<float>(p, detail, rough, lac, off,
						gain, dist, noise_type, normalize);
				c = bperlin::perlin_float3_fractal_distorted(p, detail, rough, lac, off, gain,
						dist, noise_type, normalize);
				break;
			}
			case 2: {
				Vector2 p = Vector2(v.x, v.y) * scale;
				r_fac = bperlin::perlin_fractal_distorted<Vector2>(p, detail, rough, lac, off,
						gain, dist, noise_type, normalize);
				c = bperlin::perlin_float3_fractal_distorted(p, detail, rough, lac, off, gain,
						dist, noise_type, normalize);
				break;
			}
			case 4: {
				Vector4 p = Vector4(v.x, v.y, v.z, w) * scale;
				r_fac = bperlin::perlin_fractal_distorted<Vector4>(p, detail, rough, lac, off,
						gain, dist, noise_type, normalize);
				c = bperlin::perlin_float3_fractal_distorted(p, detail, rough, lac, off, gain,
						dist, noise_type, normalize);
				break;
			}
			default: {
				Vector3 p = v * scale;
				r_fac = bperlin::perlin_fractal_distorted<Vector3>(p, detail, rough, lac, off,
						gain, dist, noise_type, normalize);
				c = bperlin::perlin_float3_fractal_distorted(p, detail, rough, lac, off, gain,
						dist, noise_type, normalize);
				break;
			}
		}
		r_col = Color(c.x, c.y, c.z, 1.0f);
	};

	if (!is_field) {
		float fac;
		Color col;
		at(0, fac, col);
		p_ctx.write_float(p_node_idx, 0, fac);
		p_ctx.write_color(p_node_idx, 1, col);
		return;
	}
	PackedFloat32Array facs;
	PackedColorArray cols;
	facs.resize(n);
	cols.resize(n);
	// Threaded, and the write pointers hoisted: ptrw() is a copy-on-write check
	// and this did two of them per element. Measured on the bridged mountain,
	// NoiseTexture was 9.8 s of an 11.4 s value cone -- the largest single node
	// in it once Voronoi was threaded.
	const bool need_fac = p_ctx.is_output_needed(p_node_idx, 0);
	const bool need_col = p_ctx.is_output_needed(p_node_idx, 1);
	if (!need_fac) { facs.resize(0); }
	if (!need_col) { cols.resize(0); }
	float *facs_p = need_fac ? facs.ptrw() : nullptr;
	Color *cols_p = need_col ? cols.ptrw() : nullptr;
	proc_parallel_for(n, [&](int e) {
		float fac;
		Color col;
		at(e, fac, col);
		if (facs_p) facs_p[e] = fac;
		if (cols_p) cols_p[e] = col;
	});
	p_ctx.write_float_field(p_node_idx, 0, facs, domain);
	p_ctx.write_color_field(p_node_idx, 1, cols, domain);
}


// --- NoiseTextureNode ---

void NoiseTextureNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_scale", "scale"), &NoiseTextureNode::set_scale);
	ClassDB::bind_method(D_METHOD("get_scale"), &NoiseTextureNode::get_scale);
	ClassDB::bind_method(D_METHOD("set_detail", "detail"), &NoiseTextureNode::set_detail);
	ClassDB::bind_method(D_METHOD("get_detail"), &NoiseTextureNode::get_detail);
	ClassDB::bind_method(D_METHOD("set_roughness", "roughness"), &NoiseTextureNode::set_roughness);
	ClassDB::bind_method(D_METHOD("get_roughness"), &NoiseTextureNode::get_roughness);
	ClassDB::bind_method(D_METHOD("set_lacunarity", "lacunarity"), &NoiseTextureNode::set_lacunarity);
	ClassDB::bind_method(D_METHOD("get_lacunarity"), &NoiseTextureNode::get_lacunarity);

	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "scale"), "set_scale", "get_scale");
	ADD_PROPERTY(PropertyInfo(Variant::INT, "detail"), "set_detail", "get_detail");
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "roughness"), "set_roughness", "get_roughness");
	ADD_PROPERTY(PropertyInfo(Variant::FLOAT, "lacunarity"), "set_lacunarity", "get_lacunarity");
}

NoiseTextureNode::NoiseTextureNode() {
	input_defs.push_back({ "Vector", SocketType::Vector, Vector3() });
	input_defs.push_back({ "Scale", SocketType::Float, 5.0f });
	input_defs.push_back({ "Detail", SocketType::Float, 4.0f });
	output_defs.push_back({ "Value", SocketType::Float, Variant() });
	output_defs.push_back({ "Color", SocketType::Color, Variant() });
}

void NoiseTextureNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Vector3 pos = p_ctx.read_vector(p_node_idx, 0);
	float s = p_ctx.read_float(p_node_idx, 1);
	int d = static_cast<int>(p_ctx.read_float(p_node_idx, 2));
	if (s <= 0.0f) s = scale;
	if (d <= 0) d = detail;

	float n = bnoisegl::ps_fbm_noise_3d(pos.x * s, pos.y * s, pos.z * s, d, roughness, lacunarity);
	float remapped = n * 0.5f + 0.5f; // -1..1 → 0..1

	p_ctx.write_float(p_node_idx, 0, remapped);
	p_ctx.write_color(p_node_idx, 1, Color(remapped, remapped, remapped, 1.0f));
}

String NoiseTextureNode::compile_glsl_prelude(int p_node_idx) const {
	return String(PROC_SIMPLEX_GLSL);
}

String NoiseTextureNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// ps_fbm_noise_3d is the SAME TEXT execute() runs. It used to be
	// noise_fbm() from the hand-written library, whose C++ twin was a
	// file-local copy in this file -- 240000/240000 identical, measured, which
	// is what made deleting the copy a relocation and not a change.
	//
	// ⛔ THIS USED TO EMIT `pc.paramN` AND IT COULD NOT COMPILE. The node is
	// not a geometry node, so it never reaches compile_batch and there is no
	// push-constant block anywhere it can appear. In the density path
	// compile_to_function replaced `pc.paramN` with socket N's default, which
	// is a different convention -- so `pc.param0` became socket 0 (Vector,
	// which has no float literal) = 0.0, `pc.param1` became Scale, `pc.param2`
	// became Detail, and `pc.param3` addressed a fourth socket that does not
	// exist and was therefore LEFT VERBATIM. Measured 2026-09-06:
	//
	//   vec3 nsp = pos * 0.0;
	//   float noise_val = ps_fbm_noise_3d(nsp.x, nsp.y, nsp.z, int(5.0), 4.0,
	//                                     pc.param3) * 0.5 + 0.5;
	//
	// A shader containing that cannot be compiled, so no density graph using
	// this node has ever worked -- which is also why fixing it can regress
	// nothing.
	//
	// It reads socket 0, NOT the shader's `pos`. execute() reads
	// read_vector(idx, 0), and the two paths agreeing is the point; an
	// unconnected Vector socket is (0,0,0) on both, which looks surprising and
	// is what the CPU does.
	//
	// Detail is a FLOAT socket truncated by int(), matching
	// `static_cast<int>(read_float(...))`, so it is not subject to the octave
	// clamp -- that applies to Int sockets, and this one is not.
	// ⛔ IT NEVER ASSIGNED ITS OUTPUT SOCKET. This wrote `float noise_val = ...`
	// and stopped, so a consumer resolving its Value output through
	// GlslEmitContext::out() got `n<idx>_s0` -- a name nothing declares. Found
	// 2026-09-06 by compiling a volume graph as a Godot fog shader:
	//
	//     SHADER ERROR: Unknown identifier in expression: 'n1_s0'.
	//
	// It is not a fog problem. Any graph consuming this node's output emits an
	// undeclared identifier, on the density path too; nothing had consumed it.
	//
	// And the locals are PER NODE now. `nsp` was a fixed name, so two Simplex
	// Noise nodes in one graph redefined it -- the same shape one step along.
	const String v = String("n") + itos(p_node_idx) + "_nsp";
	return String("vec3 ") + v + " = " + p_ctx.in(p_node_idx, 0) + " * " +
			p_ctx.in(p_node_idx, 1) + ";\n\t" + p_ctx.declare(p_node_idx, 0) +
			" = ps_fbm_noise_3d(" + v + ".x, " + v + ".y, " + v + ".z, int(" +
			p_ctx.in(p_node_idx, 2) + "), " + String::num(roughness, 9) + ", " +
			String::num(lacunarity, 9) + ") * 0.5 + 0.5;";
}

// --- DisplaceNode ---

void DisplaceNode::_bind_methods() {
}

DisplaceNode::DisplaceNode() {
	input_defs.push_back({ "Geometry",   SocketType::Geometry, Variant() });  // 0
	input_defs.push_back({ "Strength",   SocketType::Float, 1.0f });         // 1
	input_defs.push_back({ "Scale",      SocketType::Float, 5.0f });         // 2
	input_defs.push_back({ "Detail",     SocketType::Int, 4 });              // 3
	input_defs.push_back({ "Roughness",  SocketType::Float, 0.5f });         // 4
	input_defs.push_back({ "Lacunarity", SocketType::Float, 2.0f });         // 5
	output_defs.push_back({ "Geometry",  SocketType::Geometry, Variant() });
}

void DisplaceNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Ref<ProcGeometry> geo = p_ctx.read_geometry(p_node_idx, 0);
	if (geo.is_null()) {
		p_ctx.write_geometry(p_node_idx, 0, geo);
		return;
	}

	float strength = p_ctx.read_float(p_node_idx, 1);
	float noise_scale = p_ctx.read_float(p_node_idx, 2);
	int octaves = p_ctx.read_int(p_node_idx, 3);
	float rough = p_ctx.read_float(p_node_idx, 4);
	float lac = p_ctx.read_float(p_node_idx, 5);
	if (octaves <= 0) octaves = 4;
	if (noise_scale <= 0.0f) noise_scale = 5.0f;
	if (lac <= 0.0f) lac = 2.0f;

	geo = geo->duplicate_geometry();

	// Displace mesh vertices along normals using Perlin fBM noise.
	// Matches Blender's ProcNodes_Displace: normalize [0,1] → remap [-1,1].
	if (geo->get_mesh().is_valid()) {
		Ref<ProcMesh> mesh = geo->get_mesh();
		int count = mesh->positions.size();
		Vector3 *pos = mesh->positions.ptrw();

		bool has_normals = mesh->normals.size() == count;
		const Vector3 *nor = has_normals ? mesh->normals.ptr() : nullptr;

		for (int i = 0; i < count; i++) {
			// Transform Godot Y-up position to Blender Z-up before noise sampling
			// so the same geometric point produces the same noise value in both systems.
			// M conversion: Godot (x,y,z) → Blender (-x, z, y)
			float px = -pos[i].x * noise_scale;
			float py = pos[i].z * noise_scale;
			float pz = pos[i].y * noise_scale;

			float n = bperlin::perlin_fbm<Vector3>(Vector3(px, py, pz), (float)octaves, rough,
									lac, true) *
							2.0f -
					1.0f;

			Vector3 disp_dir = has_normals ? nor[i] : Vector3(0, 1, 0);
			pos[i] += disp_dir * n * strength;
		}

		mesh->recalculate_normals();
	}

	p_ctx.write_geometry(p_node_idx, 0, geo);
}

String DisplaceNode::compile_glsl_prelude(int p_node_idx) const {
	return String(BLENDER_HASH_GLSL) + String(BLENDER_PERLIN_GLSL);
}

String DisplaceNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// bp_perlin_fbm_3 is the SAME TEXT execute() runs, via blender_perlin.inc.
	// It used to be perlin_fbm() from the hand-written library, against a
	// file-local C++ Perlin with its own Jenkins hash -- 660000/660000
	// identical, measured, so this is a relocation and not a change.
	// Sockets: 1 Strength, 2 Scale, 3 Detail, 4 Roughness, 5 Lacunarity, and
	// get_push_constant_sockets() declares them in exactly that order, so in
	// the batch path this emits the same text it always did -- param0 strength,
	// param1 scale, param2 detail, param3 roughness, param4 lacunarity.
	//
	// ⛔ IN THE DENSITY PATH IT DID NOT. compile_to_function replaced
	// `pc.paramN` with socket N's default, one convention against the other, so
	// every argument arrived one socket early and `* pc.param0` -- the strength
	// -- became socket 0, which is GEOMETRY and has no float literal, so it was
	// emitted as `0.0`. Measured 2026-09-06:
	//
	//   vec3 npos = vec3(-pos.x, pos.z, pos.y) * 1.0;     // Strength as Scale
	//   float n = bp_perlin_fbm_3(npos, float(int(5.0)), 4.0, 0.5, true) ...
	//   pos += normal * n * 0.0;                          // displacement x 0
	//
	// Displace was a silent no-op in every emitted density shader.
	// Per-node locals: `npos` and `n` were fixed names, so two Displace nodes in
	// one graph redefined them. Displace's output is GEOMETRY -- it moves the
	// shared `pos` and declares no value socket -- so there is nothing to
	// assign here, unlike Simplex Noise above.
	const String np = String("n") + itos(p_node_idx) + "_npos";
	const String nv = String("n") + itos(p_node_idx) + "_nval";
	return String("vec3 ") + np + " = vec3(-pos.x, pos.z, pos.y) * " + p_ctx.param(p_node_idx, 2) +
			";\n\tfloat " + nv + " = bp_perlin_fbm_3(" + np + ", float(int(" +
			p_ctx.param(p_node_idx, 3) + ")), " + p_ctx.param(p_node_idx, 4) + ", " +
			p_ctx.param(p_node_idx, 5) + ", true) * 2.0 - 1.0;\n\tpos += normal * " + nv +
			" * " + p_ctx.param(p_node_idx, 1) + ";";
}

// --- CPU helper: hash-based random for Voronoi and white noise ---

static inline float hash_float(float x, float y, float z) {
	// fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453)
	float d = x * 127.1f + y * 311.7f + z * 74.7f;
	return fmodf(fabsf(sinf(d) * 43758.5453f), 1.0f);
}

static inline Vector3 hash_vec3(float x, float y, float z) {
	return Vector3(
			hash_float(x, y, z),
			hash_float(x + 37.0f, y + 17.0f, z + 59.0f),
			hash_float(x + 71.0f, y + 43.0f, z + 29.0f));
}

// --- VoronoiTextureNode ---

void VoronoiTextureNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_voronoi_dimensions", "d"), &VoronoiTextureNode::set_voronoi_dimensions);
	ClassDB::bind_method(D_METHOD("get_voronoi_dimensions"), &VoronoiTextureNode::get_voronoi_dimensions);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "voronoi_dimensions"), "set_voronoi_dimensions", "get_voronoi_dimensions");
	ClassDB::bind_method(D_METHOD("set_feature", "f"), &VoronoiTextureNode::set_feature);
	ClassDB::bind_method(D_METHOD("get_feature"), &VoronoiTextureNode::get_feature);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "feature"), "set_feature", "get_feature");
	ClassDB::bind_method(D_METHOD("set_metric", "m"), &VoronoiTextureNode::set_metric);
	ClassDB::bind_method(D_METHOD("get_metric"), &VoronoiTextureNode::get_metric);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "metric"), "set_metric", "get_metric");
	ClassDB::bind_method(D_METHOD("set_normalize", "n"), &VoronoiTextureNode::set_normalize);
	ClassDB::bind_method(D_METHOD("get_normalize"), &VoronoiTextureNode::get_normalize);
	ADD_PROPERTY(PropertyInfo(Variant::BOOL, "normalize"), "set_normalize", "get_normalize");
}

VoronoiTextureNode::VoronoiTextureNode() {
	// Blender's nine inputs and five outputs, in Blender's order and with
	// Blender's declared defaults. Every one past index 1 used to be a link the
	// importer dropped for addressing a socket that did not exist.
	input_defs.push_back({ "Vector", SocketType::Vector, Vector3() });
	input_defs.push_back({ "W", SocketType::Float, 0.0f });
	input_defs.push_back({ "Scale", SocketType::Float, 5.0f });
	input_defs.push_back({ "Detail", SocketType::Float, 0.0f });
	input_defs.push_back({ "Roughness", SocketType::Float, 0.5f });
	input_defs.push_back({ "Lacunarity", SocketType::Float, 2.0f });
	input_defs.push_back({ "Smoothness", SocketType::Float, 1.0f });
	input_defs.push_back({ "Exponent", SocketType::Float, 0.5f });
	input_defs.push_back({ "Randomness", SocketType::Float, 1.0f });
	output_defs.push_back({ "Distance", SocketType::Float, Variant() });
	output_defs.push_back({ "Color", SocketType::Color, Variant() });
	output_defs.push_back({ "Position", SocketType::Vector, Variant() });
	output_defs.push_back({ "W", SocketType::Float, Variant() });
	output_defs.push_back({ "Radius", SocketType::Float, Variant() });
}

// The parameter setup is node_shader_tex_voronoi.cc's, including its clamps and
// its per-dimension max_distance -- which is what `normalize` divides by, so
// getting it from the wrong dimension scales the whole output.
String VoronoiTextureNode::compile_glsl_prelude(int p_node_idx) const {
	return String(BLENDER_HASH_GLSL) + String(BLENDER_VORONOI_GLSL);
}

String VoronoiTextureNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// node_shader_tex_voronoi.cc's parameter setup, including its clamps and
	// its per-dimension max_distance -- which is what `normalize` divides by,
	// so taking it from the wrong dimension scales the whole output.
	//
	// feature, metric, dimensions and normalize are node PROPERTIES, so they
	// are literals here and the branches fold at compile time, exactly as the
	// CPU's switch does at run time.
	const String pre = String("vt") + itos(p_node_idx);
	const String P = pre + String("_p");
	const String v = p_ctx.in(p_node_idx, 0);
	const String w = p_ctx.in(p_node_idx, 1);
	const bool is_1d = (voronoi_dimensions == 1);

	String s = String("BvParams ") + P + String(";\n\t");
	s += P + String(".feature = ") + itos(feature) + String(";\n\t");
	s += P + String(".metric = ") + itos(metric) + String(";\n\t");
	s += P + String(".normalize = ") + String(normalize ? "true" : "false") + String(";\n\t");
	s += P + String(".scale = ") + p_ctx.in(p_node_idx, 2) + String(";\n\t");
	s += P + String(".detail = ") + p_ctx.in(p_node_idx, 3) + String(";\n\t");
	s += P + String(".roughness = ") + p_ctx.in(p_node_idx, 4) + String(";\n\t");
	s += P + String(".lacunarity = ") + p_ctx.in(p_node_idx, 5) + String(";\n\t");
	// Smoothness only exists for SMOOTH_F1, and it is halved and clamped.
	s += P + String(".smoothness = ") +
			(feature == bvoronoi::VORONOI_SMOOTH_F1
							? String("clamp((") + p_ctx.in(p_node_idx, 6) +
									String(") / 2.0, 0.0, 0.5)")
							: String("0.0")) +
			String(";\n\t");
	// Exponent only for Minkowski, and never in 1D.
	s += P + String(".exponent = ") +
			((metric == bvoronoi::VORONOI_MINKOWSKI && !is_1d)
							? p_ctx.in(p_node_idx, 7)
							: String("0.0")) +
			String(";\n\t");
	s += P + String(".randomness = clamp(") + p_ctx.in(p_node_idx, 8) +
			String(", 0.0, 1.0);\n\t");

	// ⚠ max_distance MUST BE SET ON EVERY PATH, and it is NOT the same value
	// on each. A GLSL struct has no defaults, so anything left unset here is
	// garbage that normalize divides by.
	//
	//   N_SPHERE_RADIUS   the CPU leaves VoronoiParams' default of 1.0, and
	//                     n_sphere_radius never reads it
	//   DISTANCE_TO_EDGE  the CPU sets it to r = 0.5 + 0.5 * randomness
	//   f1 / f2 / smooth  the per-dimension cell distance, computed below
	//
	// The middle one is the trap: writing 1.0 there looks like "the default"
	// and is wrong. It cost one failing case in the emitted-op gate --
	// Voronoi.d3.f3, cpu 0.0586412 against gpu 0.0542431 -- which is exactly
	// the kind of small, plausible difference nobody finds by reading.
	const String R = pre + String("_r");
	s += String("float ") + R + String(" = 0.5 + 0.5 * ") + P + String(".randomness;\n\t");
	s += P + String(".max_distance = ") +
			(feature == bvoronoi::VORONOI_DISTANCE_TO_EDGE ? R : String("1.0")) +
			String(";\n\t");

	// The coordinate, built from Vector and W the way the dimension says.
	const String C = pre + String("_c");
	switch (voronoi_dimensions) {
		case 1:
			s += String("float ") + C + String(" = (") + w + String(") * ") + P +
					String(".scale;\n\t");
			break;
		case 2:
			s += String("vec2 ") + C + String(" = vec2((") + v + String(").x, (") + v +
					String(").y) * ") + P + String(".scale;\n\t");
			break;
		case 4:
			s += String("vec4 ") + C + String(" = vec4(") + v + String(", ") + w +
					String(") * ") + P + String(".scale;\n\t");
			break;
		default:
			s += String("vec3 ") + C + String(" = (") + v + String(") * ") + P +
					String(".scale;\n\t");
			break;
	}

	const String sfx = itos(voronoi_dimensions);
	const String D = p_ctx.declare(p_node_idx, 0);   // Distance
	const String COL = p_ctx.declare(p_node_idx, 1); // Color
	const String POS = p_ctx.declare(p_node_idx, 2); // Position
	const String WOUT = p_ctx.declare(p_node_idx, 3);
	const String RAD = p_ctx.declare(p_node_idx, 4);

	if (feature == bvoronoi::VORONOI_N_SPHERE_RADIUS) {
		s += D + String(" = 0.0;\n\t");
		s += COL + String(" = vec4(0.0, 0.0, 0.0, 1.0);\n\t");
		s += POS + String(" = vec3(0.0);\n\t");
		s += WOUT + String(" = 0.0;\n\t");
		s += RAD + String(" = bv_voronoi_n_sphere_radius(") + P + String(", ") + C +
				String(");");
		return s;
	}
	if (feature == bvoronoi::VORONOI_DISTANCE_TO_EDGE) {
		s += D + String(" = bv_fractal_voronoi_distance_to_edge_") + sfx + String("(") + P +
				String(", ") + C + String(");\n\t");
		s += COL + String(" = vec4(0.0, 0.0, 0.0, 1.0);\n\t");
		s += POS + String(" = vec3(0.0);\n\t");
		s += WOUT + String(" = 0.0;\n\t");
		s += RAD + String(" = 0.0;");
		return s;
	}

	// f1 / f2 / smooth_f1. max_distance is the distance across a cell for THIS
	// dimension and metric, doubled for F2.
	const String f2 = String(feature == bvoronoi::VORONOI_F2 ? " * 2.0" : " * 1.0");
	s += P + String(".max_distance = ");
	switch (voronoi_dimensions) {
		case 1: s += R + f2; break;
		case 2:
			s += String("bv_voronoi_distance(vec2(0.0), vec2(") + R + String(", ") + R +
					String("), ") + P + String(")") + f2;
			break;
		case 4:
			s += String("bv_voronoi_distance(vec4(0.0), vec4(") + R + String(", ") + R +
					String(", ") + R + String(", ") + R + String("), ") + P + String(")") + f2;
			break;
		default:
			s += String("bv_voronoi_distance(vec3(0.0), vec3(") + R + String(", ") + R +
					String(", ") + R + String("), ") + P + String(")") + f2;
			break;
	}
	s += String(";\n\t");

	const String O = pre + String("_o");
	s += String("BvOutput ") + O + String(" = bv_fractal_voronoi_x_fx_") + sfx + String("(") +
			P + String(", ") + C + String(", true);\n\t");
	s += D + String(" = ") + O + String(".distance;\n\t");
	s += COL + String(" = vec4(") + O + String(".color, 1.0);\n\t");
	s += POS + String(" = ") + O + String(".position.xyz;\n\t");
	s += WOUT + String(" = ") + O + String(".position.w;\n\t");
	s += RAD + String(" = 0.0;");
	return s;
}

void VoronoiTextureNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	SocketValue vec_v = p_ctx.read_input(p_node_idx, 0);
	SocketValue w_v = p_ctx.read_input(p_node_idx, 1);
	SocketValue scale_v = p_ctx.read_input(p_node_idx, 2);
	SocketValue detail_v = p_ctx.read_input(p_node_idx, 3);
	SocketValue rough_v = p_ctx.read_input(p_node_idx, 4);
	SocketValue lac_v = p_ctx.read_input(p_node_idx, 5);
	SocketValue smooth_v = p_ctx.read_input(p_node_idx, 6);
	SocketValue exp_v = p_ctx.read_input(p_node_idx, 7);
	SocketValue rand_v = p_ctx.read_input(p_node_idx, 8);

	const SocketValue *all[9] = { &vec_v, &w_v, &scale_v, &detail_v, &rough_v,
		&lac_v, &smooth_v, &exp_v, &rand_v };
	bool is_field = false;
	int n = 1;
	AttrDomain domain = AttrDomain::Point;
	for (int i = 0; i < 9; i++) {
		if (all[i]->is_field) {
			if (!is_field) {
				domain = all[i]->domain;
			}
			is_field = true;
			n = MAX(n, all[i]->field_size);
		}
	}

	PackedFloat32Array dist_a;
	PackedColorArray col_a;
	PackedVector3Array pos_a;
	PackedFloat32Array w_a;
	PackedFloat32Array rad_a;
	if (is_field) {
		dist_a.resize(n);
		col_a.resize(n);
		pos_a.resize(n);
		w_a.resize(n);
		rad_a.resize(n);
	}

	float s_dist = 0.0f, s_w = 0.0f, s_rad = 0.0f;
	Color s_col;
	Vector3 s_pos;

	// ⛔ HOIST THE WRITE POINTERS. ptrw() is a copy-on-write check, and this
	// wrote through five of them PER ELEMENT -- 12.9 million CoW checks on the
	// bridged mountain's 2.58M vertices, for five stores.
	// Only the ports something reads. Voronoi has five, and a graph reading one
	// of them still paid for a 41 MB Colour and a 31 MB position over 2.58M
	// elements.
	const bool need_dist = p_ctx.is_output_needed(p_node_idx, 0);
	const bool need_col = p_ctx.is_output_needed(p_node_idx, 1);
	const bool need_pos = p_ctx.is_output_needed(p_node_idx, 2);
	const bool need_w = p_ctx.is_output_needed(p_node_idx, 3);
	const bool need_rad = p_ctx.is_output_needed(p_node_idx, 4);
	if (is_field) {
		if (!need_dist) { dist_a.resize(0); }
		if (!need_col) { col_a.resize(0); }
		if (!need_pos) { pos_a.resize(0); }
		if (!need_w) { w_a.resize(0); }
		if (!need_rad) { rad_a.resize(0); }
	}
	float *dist_p = (is_field && need_dist) ? dist_a.ptrw() : nullptr;
	Color *col_p = (is_field && need_col) ? col_a.ptrw() : nullptr;
	Vector3 *pos_p = (is_field && need_pos) ? pos_a.ptrw() : nullptr;
	float *w_p = (is_field && need_w) ? w_a.ptrw() : nullptr;
	float *rad_p = (is_field && need_rad) ? rad_a.ptrw() : nullptr;

	// Per element and independent: every iteration reads its own inputs and
	// writes its own slot. The scalar path is n == 1 and stays serial.
	proc_parallel_for(n, [&](int e) {
		bvoronoi::VoronoiParams params;
		params.feature = feature;
		params.metric = metric;
		params.normalize = normalize;
		params.scale = scale_v.float_at(e);
		params.detail = detail_v.float_at(e);
		params.roughness = rough_v.float_at(e);
		params.lacunarity = lac_v.float_at(e);
		params.smoothness = (feature == bvoronoi::VORONOI_SMOOTH_F1)
				? CLAMP(smooth_v.float_at(e) / 2.0f, 0.0f, 0.5f)
				: 0.0f;
		params.exponent = (metric == bvoronoi::VORONOI_MINKOWSKI && voronoi_dimensions != 1)
				? exp_v.float_at(e)
				: 0.0f;
		params.randomness = CLAMP(rand_v.float_at(e), 0.0f, 1.0f);

		Vector3 vec = vec_v.vec_at(e);
		float w = w_v.float_at(e);
		float r = 0.5f + 0.5f * params.randomness;

		float dist = 0.0f;
		Vector3 color;
		Vector4 position;
		float radius = 0.0f;

		if (feature == bvoronoi::VORONOI_N_SPHERE_RADIUS) {
			switch (voronoi_dimensions) {
				case 1: radius = bvoronoi::voronoi_n_sphere_radius(params, w * params.scale); break;
				case 2: radius = bvoronoi::voronoi_n_sphere_radius(params,
								Vector2(vec.x, vec.y) * params.scale); break;
				case 4: radius = bvoronoi::voronoi_n_sphere_radius(params,
								Vector4(vec.x, vec.y, vec.z, w) * params.scale); break;
				default: radius = bvoronoi::voronoi_n_sphere_radius(params, vec * params.scale); break;
			}
		} else if (feature == bvoronoi::VORONOI_DISTANCE_TO_EDGE) {
			params.max_distance = r;
			switch (voronoi_dimensions) {
				case 1: dist = bvoronoi::fractal_voronoi_distance_to_edge(params,
								w * params.scale); break;
				case 2: dist = bvoronoi::fractal_voronoi_distance_to_edge(params,
								Vector2(vec.x, vec.y) * params.scale); break;
				case 4: dist = bvoronoi::fractal_voronoi_distance_to_edge(params,
								Vector4(vec.x, vec.y, vec.z, w) * params.scale); break;
				default: dist = bvoronoi::fractal_voronoi_distance_to_edge(params,
								 vec * params.scale); break;
			}
		} else {
			const float f2 = (feature == bvoronoi::VORONOI_F2) ? 2.0f : 1.0f;
			bvoronoi::VoronoiOutput out;
			switch (voronoi_dimensions) {
				case 1: {
					params.max_distance = r * f2;
					out = bvoronoi::fractal_voronoi_x_fx(params, w * params.scale, true);
					break;
				}
				case 2: {
					params.max_distance = bvoronoi::voronoi_distance(Vector2(),
												  Vector2(r, r), params) * f2;
					out = bvoronoi::fractal_voronoi_x_fx(params,
							Vector2(vec.x, vec.y) * params.scale, true);
					break;
				}
				case 4: {
					params.max_distance = bvoronoi::voronoi_distance(Vector4(),
												  Vector4(r, r, r, r), params) * f2;
					out = bvoronoi::fractal_voronoi_x_fx(params,
							Vector4(vec.x, vec.y, vec.z, w) * params.scale, true);
					break;
				}
				default: {
					params.max_distance = bvoronoi::voronoi_distance(Vector3(),
												  Vector3(r, r, r), params) * f2;
					out = bvoronoi::fractal_voronoi_x_fx(params, vec * params.scale, true);
					break;
				}
			}
			dist = out.distance;
			color = out.color;
			position = out.position;
		}

		Color c(color.x, color.y, color.z, 1.0f);
		Vector3 p3(position.x, position.y, position.z);
		if (is_field) {
			if (dist_p) dist_p[e] = dist;
			if (col_p) col_p[e] = c;
			if (pos_p) pos_p[e] = p3;
			if (w_p) w_p[e] = position.w;
			if (rad_p) rad_p[e] = radius;
		} else {
			s_dist = dist;
			s_col = c;
			s_pos = p3;
			s_w = position.w;
			s_rad = radius;
		}
	});

	if (is_field) {
		p_ctx.write_float_field(p_node_idx, 0, dist_a, domain);
		p_ctx.write_color_field(p_node_idx, 1, col_a, domain);
		p_ctx.write_vec_field(p_node_idx, 2, pos_a, domain);
		p_ctx.write_float_field(p_node_idx, 3, w_a, domain);
		p_ctx.write_float_field(p_node_idx, 4, rad_a, domain);
	} else {
		p_ctx.write_float(p_node_idx, 0, s_dist);
		p_ctx.write_color(p_node_idx, 1, s_col);
		p_ctx.write_vector(p_node_idx, 2, s_pos);
		p_ctx.write_float(p_node_idx, 3, s_w);
		p_ctx.write_float(p_node_idx, 4, s_rad);
	}
}

// --- WaveTextureNode ---

void WaveTextureNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_wave_type", "t"), &WaveTextureNode::set_wave_type);
	ClassDB::bind_method(D_METHOD("get_wave_type"), &WaveTextureNode::get_wave_type);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "wave_type"), "set_wave_type", "get_wave_type");
	ClassDB::bind_method(D_METHOD("set_bands_direction", "d"), &WaveTextureNode::set_bands_direction);
	ClassDB::bind_method(D_METHOD("get_bands_direction"), &WaveTextureNode::get_bands_direction);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "bands_direction"), "set_bands_direction", "get_bands_direction");
	ClassDB::bind_method(D_METHOD("set_rings_direction", "d"), &WaveTextureNode::set_rings_direction);
	ClassDB::bind_method(D_METHOD("get_rings_direction"), &WaveTextureNode::get_rings_direction);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "rings_direction"), "set_rings_direction", "get_rings_direction");
	ClassDB::bind_method(D_METHOD("set_wave_profile", "p"), &WaveTextureNode::set_wave_profile);
	ClassDB::bind_method(D_METHOD("get_wave_profile"), &WaveTextureNode::get_wave_profile);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "wave_profile"), "set_wave_profile", "get_wave_profile");
}

WaveTextureNode::WaveTextureNode() {
	// Blender's seven inputs and two outputs, in Blender's order. Note the
	// output order: Color FIRST, then Factor.
	input_defs.push_back({ "Vector", SocketType::Vector, Vector3() });
	input_defs.push_back({ "Scale", SocketType::Float, 5.0f });
	input_defs.push_back({ "Distortion", SocketType::Float, 0.0f });
	input_defs.push_back({ "Detail", SocketType::Float, 2.0f });
	input_defs.push_back({ "Detail Scale", SocketType::Float, 1.0f });
	input_defs.push_back({ "Detail Roughness", SocketType::Float, 0.5f });
	input_defs.push_back({ "Phase Offset", SocketType::Float, 0.0f });
	output_defs.push_back({ "Color", SocketType::Color, Variant() });
	output_defs.push_back({ "Fac", SocketType::Float, Variant() });
}

String WaveTextureNode::compile_glsl_prelude(int p_node_idx) const {
	// The hash and perlin, because Distortion is perlin fBM.
	return String(BLENDER_HASH_GLSL) + String(BLENDER_PERLIN_GLSL);
}

String WaveTextureNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// Mirrors execute(). wave_type, bands_direction, rings_direction and
	// wave_profile are node PROPERTIES, so the branches are chosen here and
	// what reaches the shader is one arm of each.
	const String pre = String("wv") + itos(p_node_idx);
	const String P = pre + String("_p");
	const String N = pre + String("_n");
	const String V = pre + String("_v");

	// Blender's own nudge against precision issues on unit coordinates. It
	// shifts every result slightly, so it is not optional.
	String s = String("vec3 ") + P + String(" = ((") + p_ctx.in(p_node_idx, 0) +
			String(") * (") + p_ctx.in(p_node_idx, 1) +
			String(") + vec3(0.000001)) * 0.999999;\n\t");

	String nexpr;
	if (wave_type == 0) { // BANDS
		switch (bands_direction) {
			case 1: nexpr = P + String(".y * 20.0"); break;
			case 2: nexpr = P + String(".z * 20.0"); break;
			case 3: nexpr = String("(") + P + String(".x + ") + P + String(".y + ") + P +
						String(".z) * 10.0"); break;
			default: nexpr = P + String(".x * 20.0"); break;
		}
	} else { // RINGS -- a masked copy of p, then its length
		String mask;
		switch (rings_direction) {
			case 0: mask = "vec3(0.0, 1.0, 1.0)"; break;
			case 1: mask = "vec3(1.0, 0.0, 1.0)"; break;
			case 2: mask = "vec3(1.0, 1.0, 0.0)"; break;
			default: mask = ""; break; // SPHERICAL: the whole vector
		}
		nexpr = String("length(") + P + (mask.is_empty() ? String("") : String(" * ") + mask) +
				String(") * 20.0");
	}
	s += String("float ") + N + String(" = ") + nexpr + String(";\n\t");
	s += N + String(" += ") + p_ctx.in(p_node_idx, 6) + String(";\n\t");

	// Distortion. Lacunarity is FIXED at 2 and normalize is on -- the node has
	// no socket for either, so they are literals rather than inputs.
	const String D = pre + String("_d");
	s += String("float ") + D + String(" = ") + p_ctx.in(p_node_idx, 2) + String(";\n\t");
	s += String("if (") + D + String(" != 0.0) { ") + N + String(" += ") + D +
			String(" * (bp_perlin_fbm_3(") + P + String(" * (") + p_ctx.in(p_node_idx, 4) +
			String("), ") + p_ctx.in(p_node_idx, 3) + String(", ") + p_ctx.in(p_node_idx, 5) +
			String(", 2.0, true) * 2.0 - 1.0); }\n\t");

	// ⚠ TAU AND PI AS FLOAT LITERALS. The CPU writes `nn / (Math_PI * 2.0f)`,
	// and Math_PI is a DOUBLE, so that division happens in double and is
	// narrowed once. Here both sides are float. The difference is in the last
	// bits -- but SAW and TRI take fract() of the result, which turns a last-bit
	// difference into a whole-unit one for any nn that lands exactly on a
	// period boundary. Gated rather than assumed.
	String vexpr;
	switch (wave_profile) {
		case 1: // SAW
			vexpr = String("fract(") + N + String(" / 6.28318530717958647692)");
			break;
		case 2: { // TRI
			const String t = pre + String("_t");
			s += String("float ") + t + String(" = ") + N +
					String(" / 6.28318530717958647692;\n\t");
			vexpr = String("abs(") + t + String(" - floor(") + t + String(" + 0.5)) * 2.0");
			break;
		}
		default: // SIN
			vexpr = String("0.5 + 0.5 * sin(") + N +
					String(" - 3.14159265358979323846 * 0.5)");
			break;
	}
	s += String("float ") + V + String(" = ") + vexpr + String(";\n\t");

	// Colour FIRST, then Fac -- Blender's output order.
	s += p_ctx.declare(p_node_idx, 0) + String(" = vec4(") + V + String(", ") + V +
			String(", ") + V + String(", 1.0);\n\t");
	s += p_ctx.declare(p_node_idx, 1) + String(" = ") + V + String(";");
	return s;
}

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

	auto at = [&](int e) -> float {
		Vector3 p = in[0].vec_at(e) * in[1].float_at(e);
		// Blender's own nudge against precision issues on unit coordinates.
		// It shifts every result slightly, so it is not optional.
		p = (p + Vector3(0.000001f, 0.000001f, 0.000001f)) * 0.999999f;

		float nn = 0.0f;
		if (wave_type == 0) { // BANDS
			switch (bands_direction) {
				case 1: nn = p.y * 20.0f; break;
				case 2: nn = p.z * 20.0f; break;
				case 3: nn = (p.x + p.y + p.z) * 10.0f; break;
				default: nn = p.x * 20.0f; break;
			}
		} else { // RINGS
			Vector3 rp = p;
			switch (rings_direction) {
				case 0: rp *= Vector3(0.0f, 1.0f, 1.0f); break;
				case 1: rp *= Vector3(1.0f, 0.0f, 1.0f); break;
				case 2: rp *= Vector3(1.0f, 1.0f, 0.0f); break;
				default: break; // SPHERICAL: the whole vector
			}
			nn = rp.length() * 20.0f;
		}

		nn += in[6].float_at(e);

		float distortion = in[2].float_at(e);
		if (distortion != 0.0f) {
			// Lacunarity is FIXED at 2 here and normalize is on -- the node has
			// no socket for either.
			nn += distortion * (bperlin::perlin_fbm<Vector3>(p * in[4].float_at(e),
										in[3].float_at(e), in[5].float_at(e), 2.0f, true) *
										   2.0f -
									   1.0f);
		}

		switch (wave_profile) {
			case 1: { // SAW
				float t = nn / (Math_PI * 2.0f);
				return t - floorf(t);
			}
			case 2: { // TRI
				float t = nn / (Math_PI * 2.0f);
				return fabsf(t - floorf(t + 0.5f)) * 2.0f;
			}
			default: // SIN
				return 0.5f + 0.5f * sinf(nn - (float)Math_PI * 0.5f);
		}
	};

	if (!is_field) {
		float v = at(0);
		p_ctx.write_color(p_node_idx, 0, Color(v, v, v, 1.0f));
		p_ctx.write_float(p_node_idx, 1, v);
		return;
	}
	PackedColorArray cols;
	PackedFloat32Array facs;
	cols.resize(n);
	facs.resize(n);
	for (int e = 0; e < n; e++) {
		float v = at(e);
		cols.ptrw()[e] = Color(v, v, v, 1.0f);
		facs.ptrw()[e] = v;
	}
	p_ctx.write_color_field(p_node_idx, 0, cols, domain);
	p_ctx.write_float_field(p_node_idx, 1, facs, domain);
}

// --- WhiteNoiseNode ---

void WhiteNoiseNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_noise_dimensions", "d"), &WhiteNoiseNode::set_noise_dimensions);
	ClassDB::bind_method(D_METHOD("get_noise_dimensions"), &WhiteNoiseNode::get_noise_dimensions);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "noise_dimensions"), "set_noise_dimensions", "get_noise_dimensions");
}

WhiteNoiseNode::WhiteNoiseNode() {
	// Blender's sockets exactly. W seeds the 1D and 4D forms.
	input_defs.push_back({ "Vector", SocketType::Vector, Vector3() });
	input_defs.push_back({ "W", SocketType::Float, 0.0f });
	output_defs.push_back({ "Value", SocketType::Float, Variant() });
	output_defs.push_back({ "Color", SocketType::Color, Variant() });
}

void WhiteNoiseNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	SocketValue pv = p_ctx.read_input(p_node_idx, 0);
	SocketValue wv = p_ctx.read_input(p_node_idx, 1);
	const int d = noise_dimensions;

	auto value_at = [&](int e) -> float {
		Vector3 v = pv.vec_at(e);
		float w = wv.float_at(e);
		switch (d) {
			case 1: return bnoise::hash_float_to_float(w);
			case 2: return bnoise::hash_float_to_float(Vector2(v.x, v.y));
			case 4: return bnoise::hash_float_to_float(Vector4(v.x, v.y, v.z, w));
			default: return bnoise::hash_float_to_float(v);
		}
	};
	auto color_at = [&](int e) -> Color {
		Vector3 v = pv.vec_at(e);
		float w = wv.float_at(e);
		Vector3 c;
		switch (d) {
			case 1: c = bnoise::hash_float_to_float3(w); break;
			case 2: c = bnoise::hash_float_to_float3(Vector2(v.x, v.y)); break;
			case 4: c = bnoise::hash_float_to_float3(Vector4(v.x, v.y, v.z, w)); break;
			default: c = bnoise::hash_float_to_float3(v); break;
		}
		return Color(c.x, c.y, c.z, 1.0f);
	};

	bool is_field = pv.is_field || wv.is_field;
	if (!is_field) {
		p_ctx.write_float(p_node_idx, 0, value_at(0));
		p_ctx.write_color(p_node_idx, 1, color_at(0));
		return;
	}
	int n = MAX(pv.field_size, wv.field_size);
	if (n == 0) {
		n = 1;
	}
	AttrDomain domain = pv.is_field ? pv.domain : wv.domain;
	PackedFloat32Array vals;
	PackedColorArray cols;
	vals.resize(n);
	cols.resize(n);
	for (int e = 0; e < n; e++) {
		vals.ptrw()[e] = value_at(e);
		cols.ptrw()[e] = color_at(e);
	}
	p_ctx.write_float_field(p_node_idx, 0, vals, domain);
	p_ctx.write_color_field(p_node_idx, 1, cols, domain);
}

String WhiteNoiseNode::compile_glsl_prelude(int p_node_idx) const {
	// The hash layer itself, guarded, so several texture nodes can each ask
	// for it. This is the GENERATED text -- the same characters execute()
	// compiles through blender_noise_cpp_shim.h -- not a GLSL rendering of it.
	return String(BLENDER_HASH_GLSL);
}

String WhiteNoiseNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// Mirrors execute() case for case. Blender's White Noise is
	// hash_float_to_float for the Value and hash_float_to_float3 for the
	// Colour, keyed on noise_dimensions, and that is the whole node.
	const String v = p_ctx.in(p_node_idx, 0);
	const String w = p_ctx.in(p_node_idx, 1);

	// Parenthesised: `in` returns an EXPRESSION, which may be a producer's
	// variable or a literal like vec3(0.0, 0.0, 0.0), and `.x` binds tighter
	// than anything inside it.
	String key;
	switch (noise_dimensions) {
		case 1: key = w; break;
		case 2: key = "vec2((" + v + ").x, (" + v + ").y)"; break;
		case 4: key = "vec4(" + v + ", " + w + ")"; break;
		default: key = v; break;
	}

	// Both outputs, always, as every other multi-output node does. The alpha
	// is 1.0 because the CPU writes Color(c.x, c.y, c.z, 1.0f).
	return p_ctx.declare(p_node_idx, 0) + " = bn_hash_float_to_float(" + key + ");\n\t" +
			p_ctx.declare(p_node_idx, 1) + " = vec4(bn_hash_float_to_float3(" + key + "), 1.0);";
}

// --- GradientTextureNode ---

void GradientTextureNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_gradient_type", "gradient_type"), &GradientTextureNode::set_gradient_type);
	ClassDB::bind_method(D_METHOD("get_gradient_type"), &GradientTextureNode::get_gradient_type);
	ADD_PROPERTY(PropertyInfo(Variant::INT, "gradient_type"), "set_gradient_type", "get_gradient_type");
}

GradientTextureNode::GradientTextureNode() {
	// Blender's sockets: one Vector in, Color and Fac out. This declared ONE
	// output, so a graph reading Fac -- output 1, which is the one anybody
	// uses -- addressed a socket that was not there and read nothing.
	input_defs.push_back({ "Vector", SocketType::Vector, Vector3() });
	output_defs.push_back({ "Color", SocketType::Color, Variant() });
	output_defs.push_back({ "Fac", SocketType::Float, Variant() });
}

// Blender's gradient, in Blender's enum order, saturated exactly where Cycles
// saturates it (svm_node_tex_gradient clamps ONCE, at the end, and both
// outputs carry the same clamped value).
//
// The old SPHERICAL was `length(p)` -- zero at the origin and growing without
// bound -- where Blender's is `max(1 - length(p), 0)`, one at the origin and
// zero past radius 1. That is the cone a mountain silhouette hangs off, and it
// was inverted. RADIAL was `sqrt(x^2 + y^2)`, which is not an angle at all.
static float _gradient(const Vector3 &p, int type) {
	float r = 0.0f;
	switch (type) {
		case 0: // LINEAR
			r = p.x;
			break;
		case 1: // QUADRATIC
			r = MAX(p.x, 0.0f);
			r = r * r;
			break;
		case 2: { // EASING
			float t = CLAMP(p.x, 0.0f, 1.0f);
			r = t * t * (3.0f - 2.0f * t);
			break;
		}
		case 3: // DIAGONAL
			r = (p.x + p.y) * 0.5f;
			break;
		case 4: // SPHERICAL
			r = MAX(1.0f - p.length(), 0.0f);
			break;
		case 5: // QUADRATIC_SPHERE
			r = MAX(1.0f - p.length(), 0.0f);
			r = r * r;
			break;
		case 6: // RADIAL
			r = atan2f(p.y, p.x) / (2.0f * Math_PI) + 0.5f;
			break;
	}
	return CLAMP(r, 0.0f, 1.0f);
}

void GradientTextureNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	SocketValue pv = p_ctx.read_input(p_node_idx, 0);

	if (!pv.is_field) {
		float r = _gradient(pv.to_vector(), gradient_type);
		p_ctx.write_color(p_node_idx, 0, Color(r, r, r, 1.0f));
		p_ctx.write_float(p_node_idx, 1, r);
		return;
	}

	int n = pv.field_size;
	if (n == 0) {
		n = 1;
	}
	PackedColorArray col;
	PackedFloat32Array fac;
	col.resize(n);
	fac.resize(n);
	proc_parallel_for(n, [&](int i) {
		float r = _gradient(pv.vec_at(i), gradient_type);
		col.ptrw()[i] = Color(r, r, r, 1.0f);
		fac.ptrw()[i] = r;
	});
	p_ctx.write_color_field(p_node_idx, 0, col, pv.domain);
	p_ctx.write_float_field(p_node_idx, 1, fac, pv.domain);
}

String GradientTextureNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// ⛔ TWO DEFECTS, and each one alone was enough to break a density shader.
	//
	// 1. It declared `gradient_val`. Consumers name a node's outputs
	//    n<idx>_s0 and n<idx>_s1, so the bridged voronoi mountain -- which
	//    reads Fac, output 1 -- emitted
	//
	//        float n15_s0 = pn_safe_pow(n14_s1, n0_s3);
	//        error: 'n14_s1' : undeclared identifier
	//
	//    and the whole density shader failed to compile. Nothing caught it:
	//    the node reports GPU support and emits real GLSL, so the refusal in
	//    compile_to_glsl_function sees a well-behaved node.
	//
	// 2. It read `pos` and ignored the node's Vector INPUT. execute() reads
	//    socket 0, so a graph that transforms the coordinate before the
	//    gradient -- which is the entire point of the socket -- got the
	//    transform on the CPU and the raw position on the GPU. Two different
	//    fields, silently, from one graph.
	//
	// ⚠ An UNCONNECTED Vector now emits the socket's default, matching
	// execute(). Blender would substitute the implicit position field there
	// instead; that asymmetry is real and is recorded in the blender-bridge
	// skill, but it belongs to read_input, not here -- fixing it in one path
	// only is how the two came to disagree in the first place.
	const String v = String("pn_grad_") + itos(p_node_idx);
	const String fac = p_ctx.declare(p_node_idx, 1);
	const String col = p_ctx.declare(p_node_idx, 0);
	String expr;
	switch (gradient_type) {
		case 1: // QUADRATIC
			expr = String("pow(max(") + v + String(".x, 0.0), 2.0)");
			break;
		case 2: { // EASING
			const String t = String("clamp(") + v + String(".x, 0.0, 1.0)");
			expr = t + String(" * ") + t + String(" * (3.0 - 2.0 * ") + t + String(")");
			break;
		}
		case 3: // DIAGONAL
			expr = String("(") + v + String(".x + ") + v + String(".y) * 0.5");
			break;
		case 4: // SPHERICAL
			expr = String("max(1.0 - length(") + v + String("), 0.0)");
			break;
		case 5: // QUADRATIC_SPHERE
			expr = String("pow(max(1.0 - length(") + v + String("), 0.0), 2.0)");
			break;
		case 6: // RADIAL
			expr = String("atan(") + v + String(".y, ") + v + String(".x) / 6.283185307 + 0.5");
			break;
		default: // LINEAR
			expr = v + String(".x");
			break;
	}
	// The clamp is outside the switch because _gradient applies it to every
	// type on the way out, including the ones whose own arithmetic already
	// bounds the result.
	return String("vec3 ") + v + String(" = ") + p_ctx.in(p_node_idx, 0) + String(";\n\t") +
			fac + String(" = clamp(") + expr + String(", 0.0, 1.0);\n\t") +
			col + String(" = vec4(vec3(") + p_ctx.out(p_node_idx, 1) + String("), 1.0);");
}

// =========================================================================
// Erosion Noise — THE implementation lives in src/shared/erosion_filter.inc
//
// This file used to carry its own C++ transcription of the erosion filter,
// and so did dc-terrain, and the GLSL emitted below was a third. They
// drifted: dc-terrain's passed erosion_filter a `scale` of 5.0 where the
// shader passes 0.08333 — 60x in gully wavelength AND amplitude — and
// skipped the [0,1] remap, the water-line gate and the height offset, so
// the meshed surface and the collidable surface were different functions.
//
// Now: one text, compiled as C++ here and as GLSL by gpu_compiler.cpp from
// erosion_filter_glsl.gen.h. Both are generated from the same .inc.
// =========================================================================

// Boundary between godot's vector types and the shared filter's godot-free
// ones. Conversion happens HERE and nowhere else — the moment a call site
// starts doing its own component shuffling is the moment a transcription
// starts growing back.
static inline dcerosion::vec2 to_e(const Vector2 &v) { return dcerosion::vec2(v.x, v.y); }
static inline dcerosion::vec3 to_e(const Vector3 &v) { return dcerosion::vec3(v.x, v.y, v.z); }
static inline Vector3 from_e(const dcerosion::vec3 &v) { return Vector3(v.x, v.y, v.z); }

// Thin names so the call sites below read the way they did before.
static inline Vector3 fractal_noise_2d(Vector2 p, float freq, int octaves, float lac, float gain) {
	return from_e(dcerosion::fractal_noise_2d(to_e(p), freq, octaves, lac, gain));
}

// Compute per-vertex slope from triangle mesh
static void _compute_slopes(const Vector3 *pos, const int32_t *idx, int idx_count,
		int vert_count, Vector3 *slope_out) {
	PackedFloat32Array weights;
	weights.resize(vert_count);
	float *w = weights.ptrw();
	for (int i = 0; i < vert_count; i++) {
		slope_out[i] = Vector3();
		w[i] = 0.0f;
	}
	for (int t = 0; t + 2 < idx_count; t += 3) {
		int i0 = idx[t], i1 = idx[t + 1], i2 = idx[t + 2];
		Vector3 p0 = pos[i0], p1 = pos[i1], p2 = pos[i2];
		float dx1 = p1.x - p0.x, dz1 = p1.z - p0.z, dy1 = p1.y - p0.y;
		float dx2 = p2.x - p0.x, dz2 = p2.z - p0.z, dy2 = p2.y - p0.y;
		float det = dx1 * dz2 - dx2 * dz1;
		if (fabsf(det) < 1e-8f) continue;
		float inv_det = 1.0f / det;
		float dydx = (dy1 * dz2 - dy2 * dz1) * inv_det;
		float dydz = (dx1 * dy2 - dx2 * dy1) * inv_det;
		Vector3 slope(-dydx, 0.0f, -dydz);
		float area = fabsf(det) * 0.5f;
		slope_out[i0] += slope * area; slope_out[i1] += slope * area; slope_out[i2] += slope * area;
		w[i0] += area; w[i1] += area; w[i2] += area;
	}
	for (int i = 0; i < vert_count; i++) {
		if (w[i] > 0.0f) slope_out[i] /= w[i];
	}
}

void ErosionNoiseNode::_bind_methods() {
}

ErosionNoiseNode::ErosionNoiseNode() {
	input_defs.push_back({ "Geometry",    SocketType::Geometry, Variant() }); // 0
	input_defs.push_back({ "HeightScale", SocketType::Float, 5.0f });         // 1
	input_defs.push_back({ "ErosionStr",  SocketType::Float, 0.16f });        // 2
	// 3 is DEAD. It was SlopePower, the frequency approach's exponent on slope
	// magnitude; the fade approach has no such term. The socket stays so
	// existing graphs keep loading, and nothing reads it.
	input_defs.push_back({ "SlopePower",  SocketType::Float, 0.6f });         // 3 (dead)
	input_defs.push_back({ "CellScale",   SocketType::Float, 1.0f });         // 4
	input_defs.push_back({ "Octaves",     SocketType::Int, 5 });              // 5
	input_defs.push_back({ "Gain",        SocketType::Float, 0.5f });         // 6
	input_defs.push_back({ "Height",      SocketType::Float, Variant() });    // 7 (optional: external height field)
	input_defs.push_back({ "Slope",       SocketType::Vector, Variant() });   // 8 (optional: external slope field)
	// 9, APPENDED so 0..8 keep their indices. What counts as "a peak".
	//
	// erosion_node_eval used to hardcode 0.6 here, with the justification that
	// "the mesh path has no wrapper supplying a terrain field, so gn is the
	// whole height function and 0.6 is runevision's divisor for a field of that
	// order". That premise stopped being true the moment SlopeGradient gained a
	// Height output: the mesh path CAN now be handed a real terrain, and
	// erosion_filter.inc is explicit about what happens if the divisor does not
	// follow it -- "0.6 against that saturates the fade target to +/-1
	// everywhere, which is 'every point is a peak' and erases the fade
	// approach's whole reason to exist. Scale it with the field."
	//
	// Measured on the standard Grid -> Displace -> SlopeGradient -> ErosionNoise
	// graph the moment Height was wired: the field spans -3.009 to 3.263, so
	// fade_target spanned -5.0 to 5.4 and 63.8% of vertices clamped.
	//
	// Rule of thumb, from runevision's 0.6 for a field of order 1:
	//     PeakHeightDivisor ~= 0.6 * (peak height of the incoming field)
	// Default stays 0.6, so the internal-fallback path is bit-identical.
	input_defs.push_back({ "PeakHeightDivisor", SocketType::Float, 0.6f });   // 9

	output_defs.push_back({ "Geometry",   SocketType::Geometry, Variant() }); // 0
	output_defs.push_back({ "Erosion",    SocketType::Float, Variant() });    // 1
}

void ErosionNoiseNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Ref<ProcGeometry> geo = p_ctx.read_geometry(p_node_idx, 0);
	if (geo.is_null()) {
		p_ctx.write_geometry(p_node_idx, 0, geo);
		return;
	}

	float height_scale = p_ctx.read_float(p_node_idx, 1);
	float erosion_strength = p_ctx.read_float(p_node_idx, 2);
	float slope_power = p_ctx.read_float(p_node_idx, 3);
	float cell_scale = p_ctx.read_float(p_node_idx, 4);
	int octaves = p_ctx.read_int(p_node_idx, 5);
	float gain = p_ctx.read_float(p_node_idx, 6);
	SocketValue ext_height = p_ctx.read_input(p_node_idx, 7);
	SocketValue ext_slope = p_ctx.read_input(p_node_idx, 8);
	float peak_height_divisor = p_ctx.read_float(p_node_idx, 9);
	if (peak_height_divisor <= 0.0f) peak_height_divisor = 0.6f;

	if (octaves <= 0) octaves = 1;
	if (octaves > 8) octaves = 8;

	// Check if external height+slope fields are connected
	bool use_external = ext_height.is_field && ext_slope.is_field;

	geo = geo->duplicate_geometry();

	if (geo->get_mesh().is_valid()) {
		Ref<ProcMesh> mesh = geo->get_mesh();
		int count = mesh->positions.size();
		Vector3 *pos = mesh->positions.ptrw();

		// Compute AABB for UV normalization
		float xmin = pos[0].x, xmax = pos[0].x;
		float zmin = pos[0].z, zmax = pos[0].z;
		for (int i = 1; i < count; i++) {
			xmin = MIN(xmin, pos[i].x); xmax = MAX(xmax, pos[i].x);
			zmin = MIN(zmin, pos[i].z); zmax = MAX(zmax, pos[i].z);
		}
		float world_w = MAX(0.001f, xmax - xmin);
		float world_h = MAX(0.001f, zmax - zmin);

		// Base-noise constants only. EROSION_SCALE, WATER_HEIGHT,
		// EROSION_HEIGHT_OFFSET, EROSION_LAC and HEIGHT_AMP used to be duplicated
		// here; they now live inside erosion_node_eval, which is the same code the
		// shader runs. Restating them here would put the divergence back.
		const float HEIGHT_TILES = 3.0f;
		const int HEIGHT_OCTAVES = 3;
		const float HEIGHT_GAIN = 0.1f;
		const float HEIGHT_LAC = 2.0f;

		PackedFloat32Array erosion_field;
		erosion_field.resize(count);
		float *erosion_out = erosion_field.ptrw();

		for (int i = 0; i < count; i++) {
			float u = (pos[i].x - xmin) / world_w;
			float v = (pos[i].z - zmin) / world_h;
			Vector2 uv(u, v);

			// RAW gradient-noise height and slope. erosion_node_eval applies the
			// HEIGHT_AMP scaling and the [0,1] remap itself, so it must be handed the
			// unmodified values. The old code remapped HERE and the shader remapped
			// INSIDE, which is one of the ways the two got out of step.
			Vector3 gn;
			if (use_external) {
				// GradientNoise emits slope as vec3(dh/dx, 0, dh/dz), so .x and .z are
				// the two derivatives. See GradientNoiseNode::execute.
				Vector3 sl = ext_slope.vec_at(i);
				gn = Vector3(ext_height.float_at(i), sl.x, sl.z);
			} else {
				gn = fractal_noise_2d(uv, HEIGHT_TILES, HEIGHT_OCTAVES, HEIGHT_LAC, HEIGHT_GAIN);
			}

			// peak_height_divisor comes from socket 9 now. With no external
			// height it defaults to 0.6 and gn is the whole height function,
			// which is the case that justification described; with one, the
			// caller scales it to the field it is supplying.
			// slope_power dropped: erosion_node_eval no longer takes it, because
			// nothing ever read it. The variable is still parsed off the socket
			// above so existing graphs load unchanged.
			(void)slope_power;
			// 999: the mesh path has no LOD, so there is nothing to taper against.
			dcerosion::vec2 en = dcerosion::erosion_node_eval(
				to_e(uv), to_e(gn), height_scale, erosion_strength,
				cell_scale, octaves, gain, peak_height_divisor, 0.15f, 999.0f);

			erosion_out[i] = en.y;
			pos[i].y = en.x;
		}

		mesh->recalculate_normals();
		p_ctx.write_float_field(p_node_idx, 1, erosion_field);
	}

	p_ctx.write_geometry(p_node_idx, 0, geo);
}

String ErosionNoiseNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// A CALL, not a reimplementation. erosion_node_eval is defined in the noise
	// library (gpu_compiler.cpp emits it from erosion_filter_glsl.gen.h, which is
	// generated from src/shared/erosion_filter.inc) and is the same function
	// ErosionNoiseNode::execute calls on the CPU.
	//
	// What stood here was ~25 lines restating the remap, the water-line gate, the
	// height offset and the magnitude normalisation. dc-terrain carried a third
	// version that had drifted 60x in gully frequency and amplitude. There is one
	// now, and it is not this file.
	//
	// Socket mapping: 0=Geometry, 1=HeightScale, 2=ErosionStr, 3=SlopePower,
	//                 4=CellScale, 5=Octaves, 6=Gain, 7=Height, 8=Slope.
	// gn_height/gn_slope come from the upstream GradientNoiseNode, which emits
	// slope as vec3(dh/dx, 0, dh/dz) -- hence .x and .z below.
	// pn_terrain_offset / pn_fade_div follow the pn_max_octaves pattern: globals
	// the WRAPPER declares and sets before calling, because compile_to_function
	// fixes the signature at float f(vec3 pos) and the mountain layer lives
	// outside the graph. A wrapper that does not set them leaves them at zero /
	// the shipped default, which is the old behaviour exactly.
	//
	// WHY THE TERRAIN IS ADDED TO gn. Until now dc-terrain evaluated the erosion
	// on gn alone and added its mountain to y_mapped AFTERWARDS, so the gullies
	// were aligned to the gradient of a smooth 6.7 km blob rather than to the
	// range they were drawn on. That is decoration over a mountain, not erosion
	// of one, and no amount of filter quality fixes it. Handing the filter the
	// mountain's height AND gradient is the whole point of a slope-aware filter.
	return R"(
	vec4 en_result = erosion_node_eval_grad(
		vec2(pos.x, pos.z),
		vec3(gn_height, gn_slope.x, gn_slope.z) + pn_terrain_offset,
		pc.param1, pc.param2 * pn_erosion_mask, pc.param4, int(pc.param5), pc.param6,
		pn_fade_div, pn_erosion_scale,
		pn_ero_input_height_amplitude, pn_ero_octave_lacunarity,
		pn_ero_gully_depth, pn_ero_fine_gully_slope_restriction,
		pn_ero_gully_normalize_threshold,
		vec2(pn_ero_height_offset, pn_ero_height_offset_follows_altitude),
		vec4(pn_ero_crease_rounding, pn_ero_ridge_rounding,
		     pn_ero_input_feature_rounding, pn_ero_rounding_per_octave),
		vec4(pn_ero_gully_onset_slope, pn_ero_octave_onset_slope,
		     pn_ero_ridge_onset_slope, pn_ero_ridge_octave_onset),
		vec2(pn_ero_assumed_slope, pn_ero_assumed_slope_mix),
		pn_ero_max_octaves);
	float en_erosion = en_result.y;
	pn_out_ridge = en_erosion;
	// SECOND NODE -> WRAPPER egress, same pattern as pn_out_ridge above.
	// erosion_filter already accumulated both slope components; _full returned
	// vec2 and threw them away, so every consumer that wanted a normal rebuilt
	// it with SIX more full evaluations. _grad is the same evaluation with a
	// wider return -- .x and .y are bit-identical to _full's, which is why this
	// swap cannot move the terrain. Measured: 6 taps 16.4 ms vs 1 tap 1.57 ms
	// fullscreen at 1080p, which is the whole reason this egress exists.
	pn_out_grad = vec2(en_result.z, en_result.w);
	pos.y = en_result.x;
)";
}

// =========================================================================
// --- GradientNoiseNode ---

void GradientNoiseNode::_bind_methods() {}

GradientNoiseNode::GradientNoiseNode() {
	input_defs.push_back({ "Position", SocketType::Vector, Vector3() });
	input_defs.push_back({ "Scale",    SocketType::Float, 3.0f });
	input_defs.push_back({ "Detail",   SocketType::Int, 3 });
	input_defs.push_back({ "Gain",     SocketType::Float, 0.5f });
	output_defs.push_back({ "Height",  SocketType::Float, Variant() });
	output_defs.push_back({ "Slope",   SocketType::Vector, Variant() });
}

void GradientNoiseNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	SocketValue pos_val = p_ctx.read_input(p_node_idx, 0);
	float scale = p_ctx.read_float(p_node_idx, 1);
	int detail = p_ctx.read_int(p_node_idx, 2);
	float gain = p_ctx.read_float(p_node_idx, 3);
	if (detail < 1) detail = 1;

	if (pos_val.is_field) {
		int n = pos_val.field_size;
		PackedFloat32Array h_arr;
		PackedVector3Array s_arr;
		h_arr.resize(n);
		s_arr.resize(n);
		proc_parallel_for(n, [&](int i) {
			Vector3 pos = pos_val.vec_at(i);
			Vector3 r = fractal_noise_2d(Vector2(pos.x, pos.z), scale, detail, 2.0f, gain);
			h_arr.ptrw()[i] = r.x;
			s_arr.ptrw()[i] = Vector3(r.y, 0, r.z);
		});
		p_ctx.write_float_field(p_node_idx, 0, h_arr);
		p_ctx.write_vec_field(p_node_idx, 1, s_arr);
	} else {
		Vector3 r = fractal_noise_2d(Vector2(pos_val.vec.x, pos_val.vec.z), scale, detail, 2.0f, gain);
		p_ctx.write_float(p_node_idx, 0, r.x);
		p_ctx.write_vector(p_node_idx, 1, Vector3(r.y, 0, r.z));
	}
}

// ---------------------------------------------------------------------------
// --- HeightfieldNode ---

void HeightfieldNode::_bind_methods() {
	ClassDB::bind_method(D_METHOD("set_tile_path", "path"), &HeightfieldNode::set_tile_path);
	// The tile LIST — incomplete by design, stubbed where absent, growable at runtime.
	ClassDB::bind_method(D_METHOD("add_tile", "path"), &HeightfieldNode::add_tile);
	ClassDB::bind_method(D_METHOD("set_stub_height", "h"), &HeightfieldNode::set_stub_height);
	ClassDB::bind_method(D_METHOD("get_stub_height"), &HeightfieldNode::get_stub_height);
	ClassDB::bind_method(D_METHOD("tile_count"), &HeightfieldNode::tile_count);
	ClassDB::bind_method(D_METHOD("has_tile", "tx", "ty"), &HeightfieldNode::has_tile);
	ClassDB::bind_method(D_METHOD("sample_height", "x", "z"), &HeightfieldNode::sample_height);
	ClassDB::bind_method(D_METHOD("clear_tiles"), &HeightfieldNode::clear_tiles);
	ClassDB::bind_method(D_METHOD("get_tile_path"), &HeightfieldNode::get_tile_path);
	ADD_PROPERTY(PropertyInfo(Variant::STRING, "tile_path", PROPERTY_HINT_FILE, "*.hblk"),
			"set_tile_path", "get_tile_path");
	ClassDB::bind_method(D_METHOD("get_gpu_data"), &HeightfieldNode::get_gpu_data);
	ClassDB::bind_method(D_METHOD("get_grid_info"), &HeightfieldNode::get_grid_info);
	// Bound so a GATE can assert the thing that actually matters: that the
	// emitted text does NOT change when a tile arrives. Checking the grid's
	// scalars instead would only be a proxy for it.
	ClassDB::bind_method(D_METHOD("compile_glsl_prelude", "node_idx"),
		&HeightfieldNode::compile_glsl_prelude);
	ClassDB::bind_method(D_METHOD("has_grid"), &HeightfieldNode::has_grid);
	ClassDB::bind_method(D_METHOD("set_world_span", "m"), &HeightfieldNode::set_world_span);
	ClassDB::bind_method(D_METHOD("get_world_span"), &HeightfieldNode::get_world_span);
	ClassDB::bind_method(D_METHOD("get_tile_size"), &HeightfieldNode::get_tile_size);
	ClassDB::bind_method(D_METHOD("get_tile_mpp"), &HeightfieldNode::get_tile_mpp);
	ClassDB::bind_method(D_METHOD("get_tile_origin"), &HeightfieldNode::get_tile_origin);
}

HeightfieldNode::HeightfieldNode() {
	input_defs.push_back({ "Position",      SocketType::Vector, Vector3() });
	// pos arrives NORMALISED (world / terrain_height); the tile is addressed in
	// world metres, so the node needs the same scale the wrapper divided by.
	input_defs.push_back({ "TerrainHeight", SocketType::Float, 60000.0f });
	// gn units. The erosion filter reads height as roughly [-1,1] and slope as
	// d(height)/d(p) with p = world/terrain_height, so these two convert metres
	// into that space. Defaults are the 21 km band at height_amp 0.25:
	// HeightScale = y_range*0.25/2, MidHeight = band centre.
	input_defs.push_back({ "HeightScale",   SocketType::Float, 2625.0f });
	input_defs.push_back({ "MidHeight",     SocketType::Float, -400.0f });
	// The node is a TERRAIN BY ITSELF, not only a source for another node. With
	// nothing downstream, proc_density returns pos.y - query_y and pos.y is
	// whatever the last node left -- so a graph of just this node has to set it.
	// These two put the tile's metres into the space the wrapper compares
	// against: y_mapped = (y - floor_y) * (height_scale / y_range).
	input_defs.push_back({ "FloorY",        SocketType::Float, -10900.0f });
	input_defs.push_back({ "YScale",        SocketType::Float, 0.000238f });
	output_defs.push_back({ "Height", SocketType::Float, Variant() });
	output_defs.push_back({ "Slope",  SocketType::Vector, Variant() });
}

// Parse one .hblk into an HTile. Shares the on-disk contract with
// set_tile_path() deliberately: one format, one parser, so a per-tile file and
// a stitched block cannot drift.
bool HeightfieldNode::_load_hblk(const String &p_path, HTile &out) const {
	Ref<FileAccess> f = FileAccess::open(p_path, FileAccess::READ);
	if (f.is_null()) {
		UtilityFunctions::push_warning("HeightfieldNode: cannot open ", p_path);
		return false;
	}
	PackedByteArray hdr = f->get_buffer(32);
	if (hdr.size() != 32 || hdr[0] != 'H' || hdr[1] != 'B' || hdr[2] != 'L' || hdr[3] != 'K') {
		UtilityFunctions::push_warning("HeightfieldNode: not an .hblk: ", p_path);
		return false;
	}
	const uint8_t *p = hdr.ptr();
	uint32_t ver, n;
	memcpy(&ver, p + 4, 4); memcpy(&n, p + 8, 4);
	memcpy(&out.mpp, p + 12, 4); memcpy(&out.ox, p + 16, 4); memcpy(&out.oz, p + 20, 4);
	if (ver != 1u || n == 0u) {
		UtilityFunctions::push_warning("HeightfieldNode: unsupported .hblk version/size");
		return false;
	}
	PackedByteArray body = f->get_buffer((int64_t)n * (int64_t)n * 2);
	if (body.size() != (int64_t)n * (int64_t)n * 2) {
		UtilityFunctions::push_warning("HeightfieldNode: short .hblk data");
		return false;
	}
	out.n = (int)n;
	out.h.resize((size_t)n * (size_t)n);
	const uint8_t *b = body.ptr();
	for (size_t i = 0; i < out.h.size(); i++) {
		int16_t v; memcpy(&v, b + i * 2, 2);
		out.h[i] = (float)v;
	}
	return true;
}


// Add one tile to the grid. RUNTIME-SAFE: this is how a world grows without a
// reload, so a tile fetched from a peer mid-session lands here.
bool HeightfieldNode::add_tile(const String &p_path) {
	HTile t;
	if (!_load_hblk(p_path, t)) return false;
	const int payload = t.n - 2;                 // strip the 1 px apron
	if (payload <= 0 || t.mpp <= 0.0f) return false;
	// Recover the tile's grid coords from its own origin. stitch_block.py writes
	// origin_z = (tx*payload - apron) * mpp and origin_x = (ty*payload - apron) * mpp,
	// so this inverts exactly that -- and inherits its axis swap.
	const int ty = (int)llround((double)t.ox / t.mpp + 1.0) / payload;
	const int tx = (int)llround((double)t.oz / t.mpp + 1.0) / payload;
	tiles_[_key(tx, ty)] = std::move(t);
	_grid_upsert(tx, ty);
	return true;
}

// Put ONE tile into the packed buffer without repacking it.
//
// ⛔ A FULL REPACK PER add_tile IS O(n^2) IN BYTES AND IT IS NOT A MICRO-
// OPTIMISATION TO AVOID IT. Loading the 841 generated tiles would repack 841
// times, copying sum(k)*66,564 floats -- about 94 TB. It would not have looked
// like a bug, only like a load that never finished.
//
// Appending is also the RIGHT shape for the case this whole design exists for:
// a tile arriving from a peer mid-session costs one rect and one table entry,
// not a rebuild of the world.
void HeightfieldNode::_grid_upsert(int p_tx, int p_ty) {
	if (!grid_.ok) { _rebuild_grid(); return; }   // first tile establishes the world
	const HeightGridDesc &D = grid_.desc;
	const HTile &t = tiles_[_key(p_tx, p_ty)];
	// A tile whose shape disagrees with the grid cannot be spliced in; repack
	// so the mixed-set check in erosion_heightmap_pack_tiles gets to run.
	if (t.n != D.n || t.mpp != D.mpp) { _rebuild_grid(); return; }
	if (p_tx < D.tx0 || p_tx >= D.tx0 + D.nx || p_ty < D.ty0 || p_ty >= D.ty0 + D.ny) {
		UtilityFunctions::push_warning("HeightfieldNode: tile (", p_tx, ",", p_ty,
			") is outside the ", D.nx, "x", D.ny, " world window - REFUSED, not wrapped");
		return;
	}
	const size_t cell = (size_t)(p_tx - D.tx0) * (size_t)D.ny + (size_t)(p_ty - D.ty0);
	const int existing = (int)grid_.data[cell];
	if (existing >= 0) {
		// Same coordinate again: overwrite the rect in place. The table entry
		// and every other tile's index stay valid.
		const size_t base = (size_t)D.table + (size_t)existing * D.n * D.n;
		memcpy(&grid_.data[base], t.h.data(), t.h.size() * sizeof(float));
		return;
	}
	grid_.data[cell] = (float)grid_.tiles;
	grid_.data.insert(grid_.data.end(), t.h.begin(), t.h.end());
	grid_.tiles++;
}

// Repack. O(tiles) and called only when the set changes -- a tile arrives from
// a peer, or the map clears -- not per frame and not per sample.
//
// ⚠ THE BUFFER IS REBUILT, SO ITS ADDRESS MAY OR MAY NOT MOVE, AND THAT IS
// EXACTLY WHY dc-terrain CARRIES A CONTENT VERSION. std::vector::assign into
// spare capacity keeps data() unchanged, so a pointer comparison would skip the
// upload and leave the old world on screen. See DCGenerator::gpu_custom_data_version.
void HeightfieldNode::_rebuild_grid() {
	grid_ = HeightGridPack();
	if (tiles_.empty()) return;
	std::vector<HeightTileSrc> src;
	src.reserve(tiles_.size());
	for (std::map<int64_t, HTile>::const_iterator it = tiles_.begin(); it != tiles_.end(); ++it) {
		HeightTileSrc t;
		t.tx = (int)(it->first >> 32);
		t.ty = (int)(uint32_t)(it->first & 0xffffffffu);
		t.n = it->second.n;
		t.apron = 1;
		t.mpp = it->second.mpp;
		t.h = it->second.h;
		src.push_back(t);
	}
	const double tile_span = (double)(src[0].n - 2 * src[0].apron) * src[0].mpp;
	const HeightWorld w = erosion_heightmap_world((double)world_span_m_, tile_span);
	grid_ = erosion_heightmap_pack_tiles(src, stub_height_, w);
	if (grid_.rejected > 0) {
		UtilityFunctions::push_warning("HeightfieldNode: ", grid_.rejected,
			" tile(s) sit outside the ", grid_.desc.nx, "x", grid_.desc.ny,
			" world window and were REFUSED, not wrapped");
	}
}

Dictionary HeightfieldNode::get_grid_info() const {
	Dictionary d;
	const HeightGridDesc &g = grid_.desc;
	d["active"] = has_grid();
	d["nx"] = g.nx;   d["ny"] = g.ny;
	d["tx0"] = g.tx0; d["ty0"] = g.ty0;
	d["payload"] = g.payload; d["apron"] = g.apron;
	d["n"] = g.n;     d["table"] = g.table;
	d["mpp"] = g.mpp; d["stub"] = g.stub;
	d["side"] = g.side();
	d["ox"] = g.ox(); d["oz"] = g.oz();
	d["tiles"] = grid_.tiles;
	return d;
}

PackedFloat32Array HeightfieldNode::get_gpu_data() const {
	if (!has_grid()) return data_;
	PackedFloat32Array out;
	out.resize((int64_t)grid_.data.size());
	memcpy(out.ptrw(), grid_.data.data(), grid_.data.size() * sizeof(float));
	return out;
}


// Height at a world position, across the whole grid.
//
// ⛔ AN ABSENT TILE IS A STUB, NOT AN ERROR AND NOT A CLAMP. Clamping would
// smear the nearest loaded row across the hole, which reads as terrain and is
// the silent-seam failure this whole design exists to avoid. A flat stub is
// visibly a stub.
float HeightfieldNode::sample_height(float p_x, float p_z) const {
	if (tiles_.empty()) return stub_height_;
	const HTile &any = tiles_.begin()->second;
	const float mpp = any.mpp;
	const int payload = any.n - 2;
	if (payload <= 0 || mpp <= 0.0f) return stub_height_;

	const double u = (double)p_x / mpp;          // global texel, x
	const double v = (double)p_z / mpp;          // global texel, z
	const int ty = (int)floor(u / (double)payload);
	const int tx = (int)floor(v / (double)payload);
	auto it = tiles_.find(_key(tx, ty));
	if (it == tiles_.end()) return stub_height_;

	const HTile &t = it->second;
	// Local texel INSIDE this tile's own array, which includes its apron.
	double lu = u - (double)t.ox / t.mpp;
	double lv = v - (double)t.oz / t.mpp;
	if (lu < 0.0) lu = 0.0; if (lu > t.n - 1) lu = t.n - 1;
	if (lv < 0.0) lv = 0.0; if (lv > t.n - 1) lv = t.n - 1;
	const int i0 = (int)lu, j0 = (int)lv;
	const int i1 = (i0 + 1 < t.n) ? i0 + 1 : i0;
	const int j1 = (j0 + 1 < t.n) ? j0 + 1 : j0;
	const float fu = (float)(lu - i0), fv = (float)(lv - j0);
	const float h00 = t.h[(size_t)j0 * t.n + i0], h10 = t.h[(size_t)j0 * t.n + i1];
	const float h01 = t.h[(size_t)j1 * t.n + i0], h11 = t.h[(size_t)j1 * t.n + i1];
	return (h00 * (1.0f - fu) + h10 * fu) * (1.0f - fv)
	     + (h01 * (1.0f - fu) + h11 * fu) * fv;
}


void HeightfieldNode::set_tile_path(const String &p_path) {
	if (tile_path_ == p_path) return;
	tile_path_ = p_path;
	data_ = PackedFloat32Array();
	n_ = 0;
	if (p_path.is_empty()) return;

	Ref<FileAccess> f = FileAccess::open(p_path, FileAccess::READ);
	if (f.is_null()) {
		UtilityFunctions::push_warning("HeightfieldNode: cannot open ", p_path);
		return;
	}
	PackedByteArray hdr = f->get_buffer(32);
	if (hdr.size() != 32 || hdr[0] != 'H' || hdr[1] != 'B' || hdr[2] != 'L' || hdr[3] != 'K') {
		UtilityFunctions::push_warning("HeightfieldNode: not an .hblk: ", p_path);
		return;
	}
	const uint8_t *p = hdr.ptr();
	uint32_t ver, n;
	memcpy(&ver, p + 4, 4); memcpy(&n, p + 8, 4);
	memcpy(&mpp_, p + 12, 4); memcpy(&ox_, p + 16, 4); memcpy(&oz_, p + 20, 4);
	if (ver != 1u || n == 0u) {
		UtilityFunctions::push_warning("HeightfieldNode: unsupported .hblk version/size");
		return;
	}
	PackedByteArray body = f->get_buffer((int64_t)n * (int64_t)n * 2);
	if (body.size() != (int64_t)n * (int64_t)n * 2) {
		UtilityFunctions::push_warning("HeightfieldNode: short .hblk data");
		return;
	}
	n_ = (int)n;
	std::vector<float> level0((size_t)n * (size_t)n);
	const uint8_t *b = body.ptr();
	for (size_t i = 0; i < level0.size(); i++) {
		int16_t v; memcpy(&v, b + i * 2, 2);
		level0[i] = (float)v;
	}
	// BAND-LIMIT PER LOD. Without this a coarse chunk point-samples a 30 m field
	// at its own voxel spacing, the surface jumps between neighbouring voxels,
	// and dual contouring meshes that aliasing faithfully -- artefacts on COARSE
	// chunks while fine ones stay clean, which is exactly what was observed.
	// Costs about 1.33x the tile in memory.
	mips_ = erosion_heightmap_build_mips(level0.data(), n_);
	data_.resize((int64_t)mips_.data.size());
	memcpy(data_.ptrw(), mips_.data.data(), mips_.data.size() * sizeof(float));
	UtilityFunctions::print("[HeightfieldNode] mips: ", mips_.levels, " levels, ",
			(int64_t)mips_.data.size(), " floats");
	UtilityFunctions::print("[HeightfieldNode] ", p_path, ": ", (int)n, "x", (int)n,
			" @ ", mpp_, " m/px, origin x ", ox_, " z ", oz_);
}

String HeightfieldNode::compile_glsl_prelude(int p_node_idx) const {
	// THE GRID WINS. When tiles are loaded the storage is the packed
	// [index table | tile rects] and the fetch is the paged one; the constants
	// describe the WORLD, so they do not change as tiles arrive and the density
	// shader is not recompiled for a fetched tile.
	//
	// ⚠ ONE LEVEL. There is no per-tile mip pyramid yet, so HBLK_LEVELS is 1 and
	// hblk_sample's lod clamp pins every distance to level 0. Geometry is right;
	// the far field is not band-limited, which is aliasing, not displacement.
	// Per-tile pyramids are the next step and want their own probes.
	if (has_grid()) {
		const HeightGridDesc &g = grid_.desc;
		String s = String("const int   HBLK_N   = ") + itos(g.side()) + ";\n"
			"const float HBLK_OX  = " + String::num(g.ox(), 4) + ";\n"
			"const float HBLK_OZ  = " + String::num(g.oz(), 4) + ";\n"
			"const float HBLK_MPP = " + String::num(g.mpp, 6) + ";\n"
			"const int HBLK_LEVELS = 1;\n"
			"const int HBLK_OFF[1] = int[1](0);\n"
			"const int HBLK_SIDE[1] = int[1](" + itos(g.side()) + ");\n";
		s += String(erosion_heightmap_grid_glsl(g).c_str());
		s += String(erosion_heightmap_sampler_glsl(true).c_str());
		return s;
	}
	if (n_ <= 0) return String();
	// FILE SCOPE. The tile's geometry and the sampler are declarations, and
	// declarations cannot live in proc_density's body.
	String s = String("const int   HBLK_N   = ") + itos(n_) + ";\n"
		"const float HBLK_OX  = " + String::num(ox_, 4) + ";\n"
		"const float HBLK_OZ  = " + String::num(oz_, 4) + ";\n"
		"const float HBLK_MPP = " + String::num(mpp_, 6) + ";\n";
	s += String(erosion_heightmap_levels_glsl(mips_).c_str());
	s += String(erosion_heightmap_sampler_glsl().c_str());
	return s;
}

String HeightfieldNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// ⛔ THE GRID COUNTS AS DATA. n_ is the SINGLE tile's side and is 0 when only
	// the tile grid is loaded, so gating on it alone returned the flat-field
	// fallback below while 839 tiles sat in the buffer -- 213 MiB uploaded, the
	// prelude emitting a correct paged sampler, and the graph emitting
	// `gn_height = 0.0`. The world was flat and every log line was healthy.
	//
	// Same miss as hblk_n_ in dc_terrain_generator_procnodes: the grid has TWO
	// places that gate on the single tile, and fixing one is not fixing it.
	if (!has_grid() && n_ <= 0) {
		// No tile: a flat field, so the graph still compiles and the failure
		// reads as flatness rather than as a shader that never built.
		return "float gn_height = 0.0;\n\tvec3 gn_slope = vec3(0.0);";
	}
	// STATEMENTS ONLY. pc.paramN are replaced with socket defaults by GpuCompiler.
	return String(
		"vec3 hf_s = hblk_sample(pos.x * pc.param1, pos.z * pc.param1);\n"
		"\tfloat gn_height = (hf_s.x - pc.param3) / pc.param2;\n"
		"\tvec3 gn_slope = vec3(hf_s.y * pc.param1 / pc.param2, 0.0,\n"
		"\t                     hf_s.z * pc.param1 / pc.param2);\n"
		"\tpos.y = (hf_s.x - pc.param4) * pc.param5;");
}

void HeightfieldNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	// CPU path: geometry passthrough. The density-side CPU twin lives in
	// dc-terrain's ProcNodesBridge, which reads this node's data directly.
	Ref<ProcGeometry> geo = p_ctx.read_geometry(p_node_idx, 0);
	p_ctx.write_geometry(p_node_idx, 0, geo);
}

String GradientNoiseNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	// Position input uses XZ as 2D. Scale/Detail/Gain from push constants.
	// Outputs height and slope as local vars for downstream nodes.
	return "vec3 gn_result = fractal_noise_2d(vec2(pos.x, pos.z), pc.param1, int(pc.param2), 2.0, pc.param3);\n"
		   "\tfloat gn_height = gn_result.x;\n"
		   "\tvec3 gn_slope = vec3(gn_result.y, 0.0, gn_result.z);";
}

// --- FractalTerrainNode (convenience: base noise + erosion in one) ---

void FractalTerrainNode::_bind_methods() {}

FractalTerrainNode::FractalTerrainNode() {
	input_defs.push_back({ "Geometry",    SocketType::Geometry, Variant() });
	input_defs.push_back({ "HeightScale", SocketType::Float, 5.0f });
	input_defs.push_back({ "ErosionStr",  SocketType::Float, 0.16f });
	input_defs.push_back({ "SlopePower",  SocketType::Float, 0.6f });
	input_defs.push_back({ "Octaves",     SocketType::Int, 5 });
	output_defs.push_back({ "Geometry",   SocketType::Geometry, Variant() });
	output_defs.push_back({ "Erosion",    SocketType::Float, Variant() });
}

void FractalTerrainNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	// Placeholder — delegates to same code as ErosionNoiseNode
	// TODO: once ErosionNoise is confirmed matching GLSL, copy here
	Ref<ProcGeometry> geo = p_ctx.read_geometry(p_node_idx, 0);
	p_ctx.write_geometry(p_node_idx, 0, geo);
}

// --- AltitudeColorNode ---

void AltitudeColorNode::_bind_methods() {}

AltitudeColorNode::AltitudeColorNode() {
	input_defs.push_back({ "Geometry",    SocketType::Geometry, Variant() }); // 0
	input_defs.push_back({ "HeightScale", SocketType::Float, 5.0f });         // 1
	input_defs.push_back({ "WaterHeight", SocketType::Float, 0.465f });       // 2
	input_defs.push_back({ "Erosion",     SocketType::Float, 0.0f });         // 3 (erosion field)
	output_defs.push_back({ "Geometry",   SocketType::Geometry, Variant() }); // 0
}

void AltitudeColorNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Ref<ProcGeometry> geo = p_ctx.read_geometry(p_node_idx, 0);
	if (geo.is_null() || geo->get_mesh().is_null()) {
		p_ctx.write_geometry(p_node_idx, 0, geo);
		return;
	}

	float height_scale = p_ctx.read_float(p_node_idx, 1);
	float water_h = p_ctx.read_float(p_node_idx, 2);
	SocketValue erosion_val = p_ctx.read_input(p_node_idx, 3);

	if (height_scale <= 0.0f) height_scale = 5.0f;

	geo = geo->duplicate_geometry();
	Ref<ProcMesh> mesh = geo->get_mesh();
	int count = mesh->positions.size();
	const Vector3 *pos = mesh->positions.ptr();
	const Vector3 *normals = mesh->normals.ptr();

	const Color CLIFF_COL(0.22f, 0.2f, 0.2f);
	const Color DIRT_COL(0.6f, 0.5f, 0.4f);
	const Color GRASS1(0.15f, 0.3f, 0.1f);
	const Color GRASS2(0.4f, 0.5f, 0.2f);
	const Color SAND_COL(0.8f, 0.7f, 0.6f);
	const Color WATER_COL(0.0f, 0.15f, 0.3f);
	const Color SNOW_COL(1.0f, 1.0f, 1.0f);
	const Color SHORE_COL(0.0f, 0.25f, 0.25f);

	auto smoothstep = [](float e0, float e1, float x) -> float {
		float t = CLAMP((x - e0) / (e1 - e0), 0.0f, 1.0f);
		return t * t * (3.0f - 2.0f * t);
	};
	auto lerp_color = [](const Color &a, const Color &b, float t) -> Color {
		return Color(a.r+(b.r-a.r)*t, a.g+(b.g-a.g)*t, a.b+(b.b-a.b)*t);
	};

	mesh->colors.resize(count);
	Color *colors = mesh->colors.ptrw();

	for (int i = 0; i < count; i++) {
		float h = pos[i].y / height_scale;
		float ny = (normals != nullptr) ? normals[i].y : 1.0f;
		float erosion = erosion_val.float_at(i);
		float occlusion = CLAMP(erosion + 0.5f, 0.0f, 1.0f);

		Color c = CLIFF_COL * smoothstep(0.4f, 0.52f, h);
		c = lerp_color(c, DIRT_COL, smoothstep(0.3f, 0.0f, occlusion - 0.2f));

		Color grass = lerp_color(GRASS1, GRASS2, smoothstep(0.4f, 0.6f, h));
		c = lerp_color(c, grass,
			smoothstep(water_h + 0.05f, water_h + 0.02f, h) *
			smoothstep(0.8f, 1.0f, ny));

		c = lerp_color(c, SNOW_COL, smoothstep(0.53f, 0.6f, h));
		c = lerp_color(c, SAND_COL, smoothstep(water_h + 0.005f, water_h, h));

		if (h < water_h) {
			float shore = expf(-(water_h - h) * 60.0f);
			c = lerp_color(WATER_COL, SHORE_COL, shore);
		}

		colors[i] = c;
	}

	p_ctx.write_geometry(p_node_idx, 0, geo);
}

String AltitudeColorNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
	return R"(
	float ac_h = pos.y / pc.param1;
	float ac_ny = normal.y;
	float ac_occ = clamp(en_erosion + 0.5, 0.0, 1.0);

	vec3 CLIFF = vec3(0.22, 0.2, 0.2);
	vec3 DIRT = vec3(0.6, 0.5, 0.4);
	vec3 GRASS1 = vec3(0.15, 0.3, 0.1);
	vec3 GRASS2 = vec3(0.4, 0.5, 0.2);
	vec3 SAND = vec3(0.8, 0.7, 0.6);
	vec3 WATER = vec3(0.0, 0.15, 0.3);
	vec3 SNOW = vec3(1.0, 1.0, 1.0);
	vec3 SHORE = vec3(0.0, 0.25, 0.25);

	vec3 ac_c = CLIFF * smoothstep(0.4, 0.52, ac_h);
	ac_c = mix(ac_c, DIRT, smoothstep(0.3, 0.0, ac_occ - 0.2));
	vec3 ac_grass = mix(GRASS1, GRASS2, smoothstep(0.4, 0.6, ac_h));
	ac_c = mix(ac_c, ac_grass, smoothstep(pc.param2 + 0.05, pc.param2 + 0.02, ac_h) * smoothstep(0.8, 1.0, ac_ny));
	ac_c = mix(ac_c, SNOW, smoothstep(0.53, 0.6, ac_h));
	ac_c = mix(ac_c, SAND, smoothstep(pc.param2 + 0.005, pc.param2, ac_h));

	if (ac_h < pc.param2) {
		float ac_shore = exp(-(pc.param2 - ac_h) * 60.0);
		ac_c = mix(WATER, SHORE, ac_shore);
	}
	vec3 vertex_color = ac_c;
)";
}

// SlopeGradient — compute per-vertex slope direction from height field
// =========================================================================

void SlopeGradientNode::_bind_methods() {
}

SlopeGradientNode::SlopeGradientNode() {
	input_defs.push_back({ "Geometry", SocketType::Geometry, Variant() });
	output_defs.push_back({ "Geometry", SocketType::Geometry, Variant() });
	output_defs.push_back({ "Slope",   SocketType::Vector, Variant() });
	// APPENDED at 2, never inserted: 0 and 1 keep their indices so every saved
	// graph and every bridge connection keeps meaning what it meant.
	//
	// Height exists because ErosionNoiseNode reads its external fields only when
	// BOTH are fields -- `use_external = ext_height.is_field && ext_slope.is_field`
	// -- and nothing emitted a height, so a Slope wired on its own was INERT and
	// the node silently generated its own terrain instead. That is what made
	// Displace have no effect at all in the standard Grid -> Displace ->
	// SlopeGradient -> ErosionNoise graph: ErosionNoise assigns `pos[i].y = en.x`
	// from a field keyed on x/z alone, so everything upstream was overwritten.
	//
	// This is the mesh path finally doing what compile_glsl already says is the
	// point: "Handing the filter the mountain's height AND gradient is the whole
	// point of a slope-aware filter."
	output_defs.push_back({ "Height",  SocketType::Float, Variant() });
}

void SlopeGradientNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
	Ref<ProcGeometry> geo = p_ctx.read_geometry(p_node_idx, 0);
	if (geo.is_null() || geo->get_mesh().is_null()) {
		p_ctx.write_geometry(p_node_idx, 0, geo);
		p_ctx.write_vector(p_node_idx, 1, Vector3());
		p_ctx.write_float(p_node_idx, 2, 0.0f);
		return;
	}

	Ref<ProcMesh> mesh = geo->get_mesh();
	int count = mesh->positions.size();
	const Vector3 *pos = mesh->positions.ptr();
	const int32_t *idx = mesh->indices.ptr();
	int idx_count = mesh->indices.size();

	// Compute per-vertex gradient of height (Y) using triangle neighbors.
	// For each triangle, compute dY/dX and dY/dZ, accumulate to vertices.
	PackedVector3Array slopes;
	slopes.resize(count);
	Vector3 *slope_out = slopes.ptrw();
	PackedFloat32Array weights;
	weights.resize(count);
	float *w = weights.ptrw();
	for (int i = 0; i < count; i++) {
		slope_out[i] = Vector3();
		w[i] = 0.0f;
	}

	for (int t = 0; t + 2 < idx_count; t += 3) {
		int i0 = idx[t], i1 = idx[t + 1], i2 = idx[t + 2];
		Vector3 p0 = pos[i0], p1 = pos[i1], p2 = pos[i2];

		// Edges in XZ plane
		float dx1 = p1.x - p0.x, dz1 = p1.z - p0.z, dy1 = p1.y - p0.y;
		float dx2 = p2.x - p0.x, dz2 = p2.z - p0.z, dy2 = p2.y - p0.y;

		float det = dx1 * dz2 - dx2 * dz1;
		if (fabsf(det) < 1e-8f) continue;

		float inv_det = 1.0f / det;
		// Gradient of Y in the XZ plane: dY/dX, dY/dZ
		float dydx = (dy1 * dz2 - dy2 * dz1) * inv_det;
		float dydz = (dx1 * dy2 - dx2 * dy1) * inv_det;

		// Slope direction = downhill = negative gradient
		Vector3 slope(-dydx, 0.0f, -dydz);

		// Accumulate to all 3 vertices (area-weighted by triangle)
		float area = fabsf(det) * 0.5f;
		slope_out[i0] += slope * area;
		slope_out[i1] += slope * area;
		slope_out[i2] += slope * area;
		w[i0] += area;
		w[i1] += area;
		w[i2] += area;
	}

	// Normalize accumulated slopes
	for (int i = 0; i < count; i++) {
		if (w[i] > 0.0f) {
			slope_out[i] /= w[i];
		}
	}

	// The incoming height, as a FIELD, so a downstream consumer can be handed
	// the terrain it is meant to act on. Raw world Y: this node reports what the
	// mesh is, it does not rescale it. A consumer whose parameters assume a
	// field of a different order has to say so -- see peak_height_divisor in
	// erosion_filter.inc, which exists for exactly that reason.
	PackedFloat32Array heights;
	heights.resize(count);
	float *h_out = heights.ptrw();
	for (int i = 0; i < count; i++) {
		h_out[i] = pos[i].y;
	}

	p_ctx.write_geometry(p_node_idx, 0, geo);
	p_ctx.write_vec_field(p_node_idx, 1, slopes);
	p_ctx.write_float_field(p_node_idx, 2, heights);
}

// Defined HERE, not in proc_node_graph.cpp, because erosion_cpp_shim.h defines
// its functions non-inline: including it in a second translation unit gives a
// multiple-definition link error. This file already has it.
Vector4 ProcNodeGraph::debug_phacelle_noise(Vector2 p, Vector2 dir, float freq,
		float offset, float normalization) {
	dcerosion::vec4 r = dcerosion::phacelle_noise(
			dcerosion::vec2(p.x, p.y), dcerosion::vec2(dir.x, dir.y),
			freq, offset, normalization);
	return Vector4(r.x, r.y, r.z, r.w);
}

PackedFloat32Array ProcNodeGraph::debug_erosion_filter(Vector2 p, Vector3 height_and_slope,
		float fade_target, float strength, float gully_weight, float detail,
		Vector4 rounding, Vector4 onset, Vector2 assumed_slope,
		float scale, int octaves, float lacunarity, float gain,
		float cell_scale, float normalization, float max_octaves) {
	dcerosion::ErosionResult r = dcerosion::erosion_filter(
			dcerosion::vec2(p.x, p.y),
			dcerosion::vec3(height_and_slope.x, height_and_slope.y, height_and_slope.z),
			fade_target, strength, gully_weight, detail,
			dcerosion::vec4(rounding.x, rounding.y, rounding.z, rounding.w),
			dcerosion::vec4(onset.x, onset.y, onset.z, onset.w),
			dcerosion::vec2(assumed_slope.x, assumed_slope.y),
			scale, octaves, lacunarity, gain, cell_scale, normalization, max_octaves);
	PackedFloat32Array out;
	out.resize(5);
	out.set(0, r.delta.x);
	out.set(1, r.delta.y);
	out.set(2, r.delta.z);
	out.set(3, r.magnitude);
	out.set(4, r.ridge);
	return out;
}

Vector2 ProcNodeGraph::debug_erosion_node_eval_full(Vector2 p, Vector3 gn,
		float height_scale, float erosion_strength, float cell_size,
		int octaves, float gain, float peak_height_divisor,
		float gully_band_scale, float input_height_amplitude,
		float octave_lacunarity, float gully_depth,
		float fine_gully_slope_restriction, float gully_normalize_threshold,
		Vector2 height_offset_pair, Vector4 rounding_set, Vector4 onset_set,
		Vector2 assumed_slope_pair, float max_octaves) {
	dcerosion::vec2 r = dcerosion::erosion_node_eval_full(
			dcerosion::vec2(p.x, p.y),
			dcerosion::vec3(gn.x, gn.y, gn.z),
			height_scale, erosion_strength, cell_size, octaves, gain,
			peak_height_divisor, gully_band_scale, input_height_amplitude,
			octave_lacunarity, gully_depth, fine_gully_slope_restriction,
			gully_normalize_threshold,
			dcerosion::vec2(height_offset_pair.x, height_offset_pair.y),
			dcerosion::vec4(rounding_set.x, rounding_set.y, rounding_set.z, rounding_set.w),
			dcerosion::vec4(onset_set.x, onset_set.y, onset_set.z, onset_set.w),
			dcerosion::vec2(assumed_slope_pair.x, assumed_slope_pair.y),
			max_octaves);
	return Vector2(r.x, r.y);
}

} // namespace godot