#!/usr/bin/env python3
"""Turn a composed density GLSL dump into a Godot spatial shader.
The dump from DCTerrainGeneratorProcNodes::get_compiled_glsl() is pure math --
no layout/buffer/uniform/#version/gl_* -- so nearly all of it drops in verbatim.
Two things do not:
1. Godot's shading language rejects MUTABLE FILE-SCOPE GLOBALS ("Global
non-constant variables are not supported"). The dump uses 22 of them to
carry state from evaluate_density into proc_density. They are hoisted into
locals of evaluate_density and threaded through as `inout` parameters.
Semantics are unchanged: the dataflow is a single hop, writer -> reader.
2. `voxel_scale` is the dump's one free variable, supplied by the compute
shaders it is normally injected into. It becomes an explicit parameter, so
the fragment shader can pass the PIXEL FOOTPRINT and let the existing
nyquist_octaves calls stop adding octaves the moment they drop below what
a pixel can resolve.
The transform is derived from the text, not from a hardcoded list of names, so
a generator change surfaces as a failed gate rather than as silent drift.
Usage: gen_density_probe.py <dump.glsl> <out.gdshader>
"""
import sys, re
src, dst = sys.argv[1], sys.argv[2]
body = open(src).read()
# --- 1. collect and strip file-scope pn_* globals -------------------------
GLOBAL_RE = re.compile(
r'^(int|float|vec2|vec3|vec4)[ \t]+(pn_\w+)[ \t]*(?:=[ \t]*([^;]+?))?[ \t]*;[ \t]*$',
re.M)
globals_found = []
for m in GLOBAL_RE.finditer(body):
globals_found.append((m.group(1), m.group(2), (m.group(3) or "").strip()))
if not globals_found:
sys.exit("FAIL: found no pn_* file-scope globals -- dump format changed")
body = GLOBAL_RE.sub("", body)
names = [n for _, n, _ in globals_found]
params = ", ".join(f"inout {t} {n}" for t, n, _ in globals_found)
args = ", ".join(names)
decls = "\n".join(
f" {t} {n} = {init if init else ('0' if t == 'int' else t + '(0.0)' if t.startswith('vec') else '0.0')};"
for t, n, init in globals_found)
# --- 2. thread them through proc_density ---------------------------------
sig = re.search(r'^float[ \t]+proc_density[ \t]*\([^)]*\)[ \t]*\{', body, re.M)
if not sig:
sys.exit("FAIL: proc_density signature not found")
body = body[:sig.start()] + f"float proc_density(vec3 pos, {params}) {{" + body[sig.end():]
call = re.search(r'proc_density\((vec3\([^;]*?\))\)', body)
if not call:
sys.exit("FAIL: proc_density call site not found")
body = body[:call.start()] + f"proc_density({call.group(1)}, {args})" + body[call.end():]
# --- 3. voxel_scale becomes a parameter of evaluate_density --------------
ev = re.search(r'^float[ \t]+evaluate_density[ \t]*\(([^)]*)\)[ \t]*\{', body, re.M)
if not ev:
sys.exit("FAIL: evaluate_density signature not found")
# EGRESS globals (pn_out_*) are what the node graph computes and the CALLER
# wants -- the ridge value and, since the gradient egress landed, the analytic
# surface slope. They must survive past evaluate_density's frame, so they become
# inout parameters of it too rather than locals that die on return.
egress = [(t, n, i) for t, n, i in globals_found if n.startswith("pn_out_")]
if not egress:
sys.exit("FAIL: no pn_out_* egress globals -- expected at least pn_out_ridge")
decls = "\n".join(d for d in decls.split("\n")
if not any(f" {n} =" in d for _, n, _ in egress))
ev_params = ", ".join(f"inout {t} {n}" for t, n, _ in egress)
body = (body[:ev.start()]
+ f"float evaluate_density({ev.group(1)}, float voxel_scale, {ev_params}) {{\n" + decls
+ body[ev.end():])
DET_RE = re.compile(r'(det_offset = )(det\.x \* )')
if not DET_RE.search(body):
sys.exit("FAIL: detail-offset assignment not found -- probe cannot isolate detail")
body = DET_RE.sub(r'\1probe_detail * \2', body)
ERO_RE = re.compile(r'(erosion_node_eval_grad\([\s\S]{0,300}?\n\s*[0-9.]+, )([0-9.]+)(,)')
if not ERO_RE.search(body):
sys.exit("FAIL: erosion_strength literal not found -- probe cannot isolate erosion")
body = ERO_RE.sub(r'\1(\2 * probe_erosion)\3', body, count=1)
shader = f"""// GENERATED by tools/gen_density_probe.py from {src}
// DO NOT EDIT -- regenerate. See that script for what is transformed and why.
shader_type spatial;
render_mode unshaded;
// >0 overrides the derived footprint, so a bench can sweep octave load
// directly instead of inferring it from camera distance.
uniform float force_voxel_scale = 0.0;
// 1 = single evaluation (what an ANALYTIC gradient would cost), 6 = central
// differences. Sizes the prize before the analytic-gradient work is done.
uniform int probe_taps = 6;
// 0 = show the normal. 1 = show ANGULAR ERROR between the analytic normal and a
// central-difference one, so the unit chain can be gated instead of trusted.
uniform int probe_mode = 0;
// eps for the reference central difference. At 200 m this low-passes the 100 m
// DETAIL layer -- which pn_out_grad deliberately excludes -- so what is left to
// compare is the eroded mountain, which is what the egress claims to carry.
uniform float probe_ref_eps = 200.0;
// 0 = detail layer off, which is the surface pn_out_grad describes.
uniform float probe_detail = 1.0;
// Which (component, sign) pairing of pn_out_grad to test. A transposition or a
// sign slip and a genuinely approximate derivative look identical in an
// aggregate error number but need completely different fixes.
uniform int probe_variant = 0;
// Scales erosion_strength. At 0 the erosion filter contributes nothing and the
// surface is mountain+hypso alone, so the analytic error splits cleanly between
// the multifractal's dropped d(weight)/dp and erosion_filter's own delta.yz.
uniform float probe_erosion = 1.0;
uniform vec2 probe_world_origin = vec2(0.0);
uniform float probe_world_span = 40000.0;
uniform float probe_world_y = 0.0;
{body}
void fragment() {{
float ridge = 0.0;
vec2 grad = vec2(0.0);
if (probe_mode >= 1) {{
// GATE: analytic vs central difference over a world patch, independent
// of camera. UV maps to world so every pixel is a distinct sample.
vec2 w = probe_world_origin + (UV - 0.5) * probe_world_span;
float vs = force_voxel_scale > 0.0 ? force_voxel_scale : 1.0;
float y = probe_world_y;
evaluate_density(w.x, y, w.y, vs, ridge, grad);
vec2 gv = grad;
if (probe_variant == 1) gv = vec2(grad.y, grad.x);
else if (probe_variant == 2) gv = -grad;
else if (probe_variant == 3) gv = vec2(-grad.y, -grad.x);
vec3 na = normalize(vec3(-gv.x, 1.0, -gv.y));
float e = probe_ref_eps;
// d = H - y, so dH/dx = -(dd/dx) / (dd/dy). Taking the ratio removes
// the 4200 scale entirely -- the reference cannot inherit the very
// constant it is meant to check.
float dx = evaluate_density(w.x + e, y, w.y, vs, ridge, grad) - evaluate_density(w.x - e, y, w.y, vs, ridge, grad);
float dz = evaluate_density(w.x, y, w.y + e, vs, ridge, grad) - evaluate_density(w.x, y, w.y - e, vs, ridge, grad);
float dy = evaluate_density(w.x, y + e, w.y, vs, ridge, grad) - evaluate_density(w.x, y - e, w.y, vs, ridge, grad);
vec3 nc = normalize(vec3(-dx / (2.0 * e), -dy / (2.0 * e), -dz / (2.0 * e)));
nc = normalize(vec3(nc.x, abs(nc.y), nc.z));
float ang = degrees(acos(clamp(dot(na, nc), -1.0, 1.0)));
// THRESHOLD FLAGS, NOT THE ANGLE. The readback goes through an 8-bit
// sRGB framebuffer; encoding a continuous error into it and decoding it
// back would make the gate's own precision the thing under test. A
// per-pixel 0-or-1 survives any encoding intact.
if (probe_mode == 2) {{
// RATIO DIAGNOSTIC: a wrong CONSTANT gives a tight band away from
// 1.0; a dropped derivative TERM gives scatter. The two failures
// need different fixes, so distinguish them before choosing one.
float sa = length(grad);
float sc = length(vec2(dx, dz)) / (2.0 * e);
float ratio = sa / max(sc, 1e-9);
ALBEDO = vec3(ratio > 0.8 && ratio < 1.25 ? 1.0 : 0.0,
ratio > 0.45 && ratio < 2.2 ? 1.0 : 0.0,
ratio > 2.2 ? 1.0 : 0.0);
}} else {{
ALBEDO = vec3(ang < 1.0 ? 1.0 : 0.0,
ang < 5.0 ? 1.0 : 0.0,
ang < 15.0 ? 1.0 : 0.0);
}}
}} else {{
vec3 wp = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
float vs = force_voxel_scale > 0.0 ? force_voxel_scale : max(length(fwidth(wp)), 0.01);
vec3 n;
if (probe_taps == 1) {{
evaluate_density(wp.x, wp.y, wp.z, vs, ridge, grad);
n = normalize(vec3(-grad.x, 1.0, -grad.y));
}} else {{
float eps = vs;
n.x = evaluate_density(wp.x + eps, wp.y, wp.z, vs, ridge, grad) - evaluate_density(wp.x - eps, wp.y, wp.z, vs, ridge, grad);
n.y = evaluate_density(wp.x, wp.y + eps, wp.z, vs, ridge, grad) - evaluate_density(wp.x, wp.y - eps, wp.z, vs, ridge, grad);
n.z = evaluate_density(wp.x, wp.y, wp.z + eps, vs, ridge, grad) - evaluate_density(wp.x, wp.y, wp.z - eps, vs, ridge, grad);
n = normalize(n);
}}
ALBEDO = n * 0.5 + 0.5;
}}
}}
"""
open(dst, "w").write(shader)
print(f"wrote {dst} ({len(shader)} bytes); hoisted {len(globals_found)} globals, egress: {[n for _,n,_ in egress]}")