OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / tools / gen_density_shader.py

178 lines · python ref:
#!/usr/bin/env python3
"""Turn a composed density GLSL dump into Godot shader source.

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 two dozen 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.
     Names starting pn_out_ are EGRESS -- the caller wants them, so they become
     inout parameters of evaluate_density too rather than dying with its frame.

  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
     a fragment shader can pass a PIXEL FOOTPRINT and let the existing
     nyquist_octaves calls limit octaves to what is resolvable on screen.

The transform is derived from the text, not from a list of names, so a
generator change surfaces as a failed gate rather than as silent drift.

  --emit inc     a .gdshaderinc: the transformed body only, for #include
  --emit probe   a standalone spatial shader, for gates and benches

Usage: gen_density_shader.py <dump.glsl> <out> [--emit inc|probe]
"""
import sys, re

src, dst = sys.argv[1], sys.argv[2]
emit = "inc"
if "--emit" in sys.argv:
    emit = sys.argv[sys.argv.index("--emit") + 1]
body = open(src).read()

# --- 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 = [(m.group(1), m.group(2), (m.group(3) or "").strip())
                 for m in GLOBAL_RE.finditer(body)]
if not globals_found:
    sys.exit("FAIL: found no pn_* file-scope globals -- dump format changed")
body = GLOBAL_RE.sub("", body)

def zero(t):
    return "0" if t == "int" else (t + "(0.0)" if t.startswith("vec") else "0.0")

names = [n for _, n, _ in globals_found]
params = ", ".join(f"inout {t} {n}" for t, n, _ in globals_found)
args = ", ".join(names)

# --- 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():]

# --- voxel_scale and the egress become parameters of evaluate_density -----
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 pn_out_ridge at least")
decls = "\n".join(f"    {t} {n} = {i if i else zero(t)};"
                  for t, n, i in globals_found if not n.startswith("pn_out_"))
ev_params = ", ".join(f"inout {t} {n}" for t, n, _ in egress)

ev = re.search(r'^float[ \t]+evaluate_density[ \t]*\(([^)]*)\)[ \t]*\{', body, re.M)
if not ev:
    sys.exit("FAIL: evaluate_density signature not found")
body = (body[:ev.start()]
        + f"float evaluate_density({ev.group(1)}, float voxel_scale, {ev_params}) {{\n"
        + decls + body[ev.end():])

HEAD = f"""// GENERATED by tools/gen_density_shader.py from {src}
// DO NOT EDIT -- regenerate. That script documents what is transformed and why.
//
// This is the SAME TEXT the GPU mesher compiles for the density field, so the
// material cannot drift from the geometry: there is one source, and it is
// DCTerrainGeneratorProcNodes::get_compiled_glsl().
"""

if emit == "inc":
    out = HEAD + "\n" + body + "\n"
else:
    out = HEAD + f"""shader_type spatial;
render_mode unshaded;
#include "res://shaders/terrain_material.gdshaderinc"
uniform float force_voxel_scale = 0.0;
uniform int probe_taps = 4;
// PREVIEW. >0 maps UV across a world patch of this many metres, so the probe
// shows actual terrain instead of the two-metre cube it is painted on. Without
// it the probe samples a 4 m patch of a 60 km world -- one flat spot, one flat
// colour, which is correct output and a useless picture.
uniform float probe_world_span = 0.0;
uniform vec2 probe_world_origin = vec2(0.0);
uniform float probe_world_y = 0.0;
// 0 = normal as RGB (what a gate wants). 1 = lambert against a fixed sun,
// which is what actually reads as relief to a human eye.
uniform int probe_view = 1;
uniform vec3 probe_sun = vec3(0.45, 0.55, 0.7);
// Surface elevation band, for the material's altitude term. Defaults match
// map_dc_erosion.gd's SURFACE_RANGE.
uniform vec2 probe_field_range = vec2(-7800.0, 10600.0);
uniform float probe_curv_gain = 0.5;

{body}

void fragment() {{
    vec3 wp = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
    if (probe_world_span > 0.0) {{
        vec2 w = probe_world_origin + (UV - 0.5) * probe_world_span;
        wp = vec3(w.x, probe_world_y, w.y);
    }}
    float vs = force_voxel_scale > 0.0
        ? force_voxel_scale
        : (probe_world_span > 0.0 ? probe_world_span / 1024.0
                                  : max(length(fwidth(wp)), 0.01));
    float ridge = 0.0;
    vec2 grad = vec2(0.0);
    float curv_k = 0.5;
    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 {{
        // FOUR taps, not six. d = H(x,z) - y, so dd/dy is analytically exact
        // and constant -- sampling it was two evaluations spent rediscovering
        // a number the wrapper already emits. Only x and z need differencing.
        float e = vs;
        float dc0 = evaluate_density(wp.x, wp.y, wp.z, vs, ridge, grad);
        float dxp = evaluate_density(wp.x + e, wp.y, wp.z, vs, ridge, grad);
        float dxm = evaluate_density(wp.x - e, wp.y, wp.z, vs, ridge, grad);
        float dzp = evaluate_density(wp.x, wp.y, wp.z + e, vs, ridge, grad);
        float dzm = evaluate_density(wp.x, wp.y, wp.z - e, vs, ridge, grad);
        n = normalize(vec3(-(dxp - dxm), 2.0 * e, -(dzp - dzm)));
        // erosion_filter's OWN crease/ridge map, not a numerical second
        // derivative. A Laplacian at pixel scale is swamped by the 100 m detail
        // layer -- second differences amplify exactly what detail has most of --
        // and came out as salt-and-pepper everywhere det_mask was not zero.
        curv_k = clamp(0.5 + probe_curv_gain * ridge, 0.0, 1.0);
    }}
    float lam = clamp(dot(n, normalize(probe_sun)), 0.0, 1.0);
    if (probe_view == 4) {{
        // Curvature alone: black creases, white ridges. Saturated to pure black
        // and white means the gain is too high to carry information.
        ALBEDO = vec3(curv_k);
    }} else if (probe_view == 0) {{
        ALBEDO = n * 0.5 + 0.5;
    }} else if (probe_view == 1) {{
        ALBEDO = vec3(0.05 + 0.95 * lam);
    }} else {{
        // d = H(x,z) - y in world metres, so the SURFACE elevation under this
        // sample is just y + d. One extra evaluation, and it is what both the
        // material's altitude term and the hypsometric ramp key off.
        float dc = evaluate_density(wp.x, wp.y, wp.z, vs, ridge, grad);
        float h = wp.y + dc;
        float ny = clamp(abs(n.y), 0.001, 1.0);
        float slope = sqrt(max(1.0 - ny * ny, 0.0)) / ny;
        float t = clamp((h - probe_field_range.x)
                      / max(probe_field_range.y - probe_field_range.x, 1.0), 0.0, 1.0);
        // Curvature now comes from the field's Laplacian, the same way the
        // map's shader derives it -- so this preview shows the real material.
        vec3 base = probe_view == 2 ? lookdev_material(t, slope, curv_k)
                                    : hypso_ramp(h);
        ALBEDO = base * (0.15 + 0.85 * lam);
    }}
}}
"""
open(dst, "w").write(out)
print(f"wrote {dst} ({len(out)} bytes, emit={emit}); "
      f"hoisted {len(globals_found)} globals, egress {[n for _,n,_ in egress]}")