#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