class_name BridgedDensityMaterial
extends RefCounted
## Turn a BRIDGED VisualShader into a plain .gdshader ShaderMaterial that also
## carries the generator's density field, so the material can recover the detail
## normal the mesh's voxel size threw away -- what dc_erosion_color.gdshader
## does for map_dc_erosion, but WITHOUT a generated .gdshaderinc beside it.
##
## NOTHING HERE IS HAND-EDITED. It is three mechanical text transforms over two
## texts the engine already hands us:
##
## 1. `VisualShader.code` -- a VisualShader IS a Shader
## (visual_shader.h:43) and _update_shader() ends in set_code(final_code)
## (visual_shader.cpp:3155), so .code is the finished .gdshader text.
## 2. `generator.get_compiled_glsl()` -- bound to ClassDB
## (dc_terrain_generator_procnodes_bounded.cpp:33), returns proc_density()
## plus `float evaluate_density(float,float,float)`. THE SAME TEXT THE
## MESHER COMPILES, so the material cannot describe a surface the geometry
## does not have. That invariant is the whole reason to do it this way
## rather than port the field into the material by hand.
## 3. Two incompatibilities between compute GLSL and Godot's shader language,
## both mechanical and both MEASURED, not guessed -- see the functions.
##
## ⚠ THE GATE CALLS THIS FILE. scenes/test/bridged_density_splice_gate.gd does
## not keep its own copy of these transforms, because this repo's most expensive
## recurring bug is a transcription used as its own reference: four ports of the
## iq hash, the euler order and the erosion filter were each faithful ports of
## the wrong reference, twice certified by tests written from the same
## transcription. One implementation, one test against it.
## Everything the caller needs, and the diagnostics to judge it by. Never
## returns null: `material` is null on failure and `log` says why, because a
## silently-absent material is how the bridge's material path failed for weeks.
static func build(src: ShaderMaterial, gen: Object, taps: int = 4) -> Dictionary:
var out := {"material": null, "code": "", "log": [] as Array[String],
"macros": 0, "consts": 0, "timings": {}, "object_space_hook": false}
var _t := Time.get_ticks_msec()
if src == null or not (src.shader is VisualShader):
out["log"].append("source is not a ShaderMaterial wrapping a VisualShader")
return out
if gen == null or not gen.has_method("get_compiled_glsl"):
out["log"].append("generator does not expose get_compiled_glsl()")
return out
var vs: VisualShader = src.shader
# _update_shader is call_deferred and dirty-guarded (visual_shader.cpp:3174),
# so ask for it rather than trusting a freshly loaded resource.
if vs.has_method("_update_shader"):
vs.call("_update_shader")
var vcode: String = vs.code
var density: String = gen.get_compiled_glsl()
if vcode.is_empty() or density.is_empty():
out["log"].append("empty text: visualshader %d bytes, density %d bytes"
% [vcode.length(), density.length()])
return out
out["timings"]["fetch_ms"] = Time.get_ticks_msec() - _t
_t = Time.get_ticks_msec()
var exp := expand_function_macros(density)
density = exp["code"]
out["macros"] = exp["count"]
out["timings"]["expand_ms"] = Time.get_ticks_msec() - _t
_t = Time.get_ticks_msec()
var con := constify_globals(density)
density = con["code"]
out["consts"] = con["count"]
if not con["reassigned"].is_empty():
# A reassigned global CANNOT become const -- it would compile and
# compute the wrong field. Refuse rather than ship that.
out["log"].append("globals are reassigned and would need uniforms: "
+ ", ".join(con["reassigned"]))
return out
out["timings"]["constify_ms"] = Time.get_ticks_msec() - _t
_t = Time.get_ticks_msec()
var spliced := splice(vcode, density, taps)
out["timings"]["splice_ms"] = Time.get_ticks_msec() - _t
_t = Time.get_ticks_msec()
if spliced.is_empty():
out["log"].append("could not locate fragment() to splice into")
return out
var sh := Shader.new()
sh.code = spliced
# ⛔ THIS LINE IS THE SUSPECT. On the dummy rasterizer it is a parse; on a
# real RenderingDevice it is GLSL -> SPIR-V -> driver, on the MAIN THREAD.
out["timings"]["shader_set_code_ms"] = Time.get_ticks_msec() - _t
_t = Time.get_ticks_msec()
var uniforms: Array[String] = []
for u in sh.get_shader_uniform_list():
uniforms.append(str(u["name"]))
if uniforms.is_empty():
# get_shader_uniform_list is empty when the shader failed to compile.
# The parse errors themselves are already on stderr.
out["log"].append("spliced shader did not compile (0 uniforms)")
out["code"] = spliced
return out
var mat := ShaderMaterial.new()
mat.shader = sh
# Carry the bridge's own parameter values across -- bb_coord_scale and the
# Generated-bounds uniforms live on the SOURCE material, and a fresh
# ShaderMaterial would silently take every default instead.
for u in src.shader.get_shader_uniform_list():
var n: StringName = u["name"]
var v = src.get_shader_parameter(n)
if v != null:
mat.set_shader_parameter(n, v)
out["material"] = mat
out["code"] = spliced
var framed: bool = uniforms.has("bb_world_to_object")
out["object_space_hook"] = framed
out["log"].append("ok: %d uniforms, %d macro expansions, %d consts, density sampled in %s"
% [uniforms.size(), out["macros"], out["consts"],
"the TERRAIN frame" if framed
else "the WORLD frame (asset predates blender-bridge v0.161.0 -- "
+ "republish it, or the detail normal reads the field at the "
+ "terrain's origin offset)"])
return out
## Put the density field and the detail-normal block into the VisualShader text.
## Returns "" if fragment() cannot be found.
static func splice(vcode: String, density: String, taps: int = 4) -> String:
# The generated header is `shader_type spatial;` then an optional
# `render_mode ...;` (visual_shader.cpp:2694 onward). Globals go after both.
var head_end := vcode.find(";", vcode.find("shader_type")) + 1
var rm := vcode.find("render_mode")
if rm != -1 and rm < vcode.find("void "):
head_end = vcode.find(";", rm) + 1
var spliced := vcode.substr(0, head_end) + "\n\n" + density + "\n" \
+ _detail_normal_helpers() + vcode.substr(head_end)
# Inject at the END of fragment(), after the bridged graph has written
# NORMAL, located by brace-matching rather than by guessing a line.
var fpos := spliced.find("void fragment()")
if fpos == -1:
return ""
var open_brace := spliced.find("{", fpos)
if open_brace == -1:
return ""
var depth := 0
var close_brace := -1
for i in range(open_brace, spliced.length()):
var c := spliced[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
close_brace = i
break
if close_brace == -1:
return ""
# ⛔ WHICH FRAME evaluate_density IS ASKED IN depends on the asset. See
# _detail_normal_body. Detected from the SOURCE text rather than assumed,
# because an asset published before blender-bridge v0.161.0 does not declare
# the uniform and naming it would not compile.
var has_hook := vcode.find("bb_world_to_object") != -1
return spliced.substr(0, close_brace) + _detail_normal_body(taps, has_hook) \
+ spliced.substr(close_brace)
## INCOMPATIBILITY 1 -- mutable globals.
## The generator emits its graph parameters as mutable globals
## (`float pn_fade_div = 0.6;`). Legal in the compute GLSL it was written for,
## and Godot rejects it: "Global non-constant variables are not supported."
## Measured on mountain_voronoi2: 24 such declarations, every one write-once, so
## `const` is sound rather than hopeful. A REASSIGNED one is reported instead of
## constified, because quietly constifying it would compile and then compute a
## different field -- the exact failure shape this repo keeps paying for.
static func constify_globals(code: String) -> Dictionary:
var decl := RegEx.new()
decl.compile("(?m)^(vec[234]|float|int|uint|mat[234]|bool)[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*=")
var reassigned: Array[String] = []
var names: Array[String] = []
for m in decl.search_all(code):
var n := m.get_string(2)
if not names.has(n):
names.append(n)
for n in names:
var asn := RegEx.new()
asn.compile("(^|[^A-Za-z0-9_.])" + n + "[ \t]*=[^=]")
if asn.search_all(code).size() != 1:
reassigned.append(n)
return {"code": decl.sub(code, "const $1 $2 =", true),
"count": names.size(), "reassigned": reassigned}
## INCOMPATIBILITY 2 -- function-like macros.
## Godot's ShaderPreprocessor mis-expands the generator's BV_FRACTALS macro,
## which defines TWO functions in one body: after expansion the closing `}` of
## the first is GONE and the compile dies on "Expected a ',' or ';'". glslang
## expands the same text correctly for the mesher, which is why the mountain
## meshes at all.
##
## Three hypotheses for WHY were tested and all three were wrong -- `} \` on its
## own continuation line expands fine, an empty BN_FN inside the body expands
## fine, and the body has no comments and no stray T/S for argument
## substitution to hit. So this does not USE Godot's macro expansion; it expands
## function-like macros itself, which this pass has to walk the text for anyway.
##
## ⚠ NOT a general C preprocessor. It handles the one shape the generator emits
## -- a multi-line function-like macro invoked at statement level with simple
## arguments -- and it RETURNS ITS COUNT so a generator that starts emitting a
## shape this cannot handle shows up as a number that stopped matching, rather
## than as a shader that quietly lost a function.
static func expand_function_macros(code: String) -> Dictionary:
var lines := code.split("\n")
var defines := {}
var out: Array[String] = []
var m := RegEx.new()
m.compile("^#define[ \t]+([A-Za-z_][A-Za-z0-9_]*)\\(([^)]*)\\)[ \t]*(.*)$")
var i := 0
while i < lines.size():
var ln: String = lines[i]
var r := m.search(ln)
if r == null:
out.append(ln)
i += 1
continue
var name := r.get_string(1)
var args: Array[String] = []
for a in r.get_string(2).split(","):
args.append(a.strip_edges())
var rest := r.get_string(3)
var body_parts: Array[String] = []
var cont := rest.ends_with("\\")
if cont:
rest = rest.substr(0, rest.length() - 1)
if rest.strip_edges() != "":
body_parts.append(rest)
i += 1
while cont and i < lines.size():
var b: String = lines[i]
cont = b.ends_with("\\")
if cont:
b = b.substr(0, b.length() - 1)
body_parts.append(b)
i += 1
defines[name] = {"args": args, "body": "\n".join(body_parts)}
# The #define is dropped: every invocation is expanded below, so
# nothing downstream may rely on it.
var text := "\n".join(out)
var count := 0
# ⛔ TO A FIXED POINT, NOT ONE PASS PER NAME.
# A macro body can INVOKE another macro, and that invocation does not exist
# in the text until the outer one has been expanded. One pass per name in
# definition order therefore misses it whenever the inner macro is defined
# FIRST -- its pass runs before the invocation is there to find.
# MEASURED: proc-nodes' BV_FRACTAL_XFX_AS expands to a body containing
# BV_OCT_F1(...), BV_OCT_F1 is defined earlier in the .inc, and the spliced
# shader failed with "No matching function found for: 'BV_OCT_F1'" -- a
# compile error, not a wrong value, which is the good version of this bug.
# Sweeping until nothing expands removes the ordering dependency entirely.
var sweeps := 0
while sweeps < 16:
sweeps += 1
var before := count
for name in defines.keys():
var d = defines[name]
var inv := RegEx.new()
inv.compile("(^|[^A-Za-z0-9_])" + name + "[ \t]*\\(([^()]*)\\)")
while true:
var hit := inv.search(text)
if hit == null:
break
var actual: Array[String] = []
for a in hit.get_string(2).split(","):
actual.append(a.strip_edges())
if actual.size() != d["args"].size():
break
var body: String = d["body"]
# Longest parameter name first, so a name that is a prefix of
# another cannot be clobbered.
var names: Array = d["args"].duplicate()
names.sort_custom(func(x, y): return x.length() > y.length())
for an in names:
var idx: int = d["args"].find(an)
var sub := RegEx.new()
sub.compile("(^|[^A-Za-z0-9_])" + an + "([^A-Za-z0-9_]|$)")
var guard := 0
while guard < 100000:
var h := sub.search(body)
if h == null:
break
body = body.substr(0, h.get_start()) \
+ h.get_string(1) + actual[idx] + h.get_string(2) \
+ body.substr(h.get_end())
guard += 1
var paste := RegEx.new()
paste.compile("[ \t]*##[ \t]*")
body = paste.sub(body, "", true)
text = text.substr(0, hit.get_start()) + hit.get_string(1) + "\n" \
+ body + "\n" + text.substr(hit.get_end())
count += 1
if count == before:
break
return {"code": text, "count": count, "macros": defines.size(),
"sweeps": sweeps}
static func _detail_normal_helpers() -> String:
return """
// ---- detail normal, recovered from the SAME field the mesher used ----------
// This is NOT new noise: the field is defined at every scale and the voxel mesh
// is a coarse SAMPLING of it, so re-evaluating it recovers the normal the
// geometry would have had at a finer leaf.
// ⚠ THE EPS FLOOR IS HALF A VOXEL, NOT A HUNDREDTH. dc_erosion_color.gdshader
// records that below ~1 m the central difference underflows float32 and the
// horizontal terms come back exactly 0.0, collapsing normals to straight up.
group_uniforms DetailNormal;
uniform float dn_blend : hint_range(0.0, 1.0) = 1.0;
uniform float dn_eps = 0.5;
group_uniforms;
"""
## The number of evaluate_density call sites the detail normal uses: 2, 4 or 6.
##
## ⚠ IT BARELY CHANGES THE TEXT, and I claimed otherwise before measuring.
## MEASURED: 196,345 bytes at 6 taps and 196,235 at 4 -- a 110-byte difference,
## because the 176 KB of function DEFINITIONS dominates and a tap is one call.
## Whether the driver then inlines that chain once or six times is a compiler
## decision this repo has NOT measured, so do not treat this as a cost knob
## until scenes/test/bridged_density_render_cost.gd has been run on a display.
##
## What it definitely changes is CORRECTNESS, and 4 is the right default:
## dc_erosion_color.gdshader takes x+/x-/z+/z- and NOT y, which is correct for a
## heightfield -- and this graph is one (set_heightfield(true) in map_flat), so
## the vertical component of the gradient is not independent information.
##
## 2 uses FORWARD differences: biased by half an eps, which shows as a slight
## directional lean in the lighting. It is there as a comparison point, not as a
## recommendation.
static func _detail_normal_body(taps: int = 4, has_hook: bool = false) -> String:
var grad := ""
match taps:
2:
grad = """ float d0 = evaluate_density(wp.x, wp.y, wp.z);
float dx = evaluate_density(wp.x + e, wp.y, wp.z) - d0;
float dz = evaluate_density(wp.x, wp.y, wp.z + e) - d0;
vec3 g = vec3(dx, 0.0, dz);"""
6:
grad = """ float dxp = evaluate_density(wp.x + e, wp.y, wp.z);
float dxm = evaluate_density(wp.x - e, wp.y, wp.z);
float dyp = evaluate_density(wp.x, wp.y + e, wp.z);
float dym = evaluate_density(wp.x, wp.y - e, wp.z);
float dzp = evaluate_density(wp.x, wp.y, wp.z + e);
float dzm = evaluate_density(wp.x, wp.y, wp.z - e);
vec3 g = vec3(dxp - dxm, dyp - dym, dzp - dzm);"""
_:
grad = """ float dxp = evaluate_density(wp.x + e, wp.y, wp.z);
float dxm = evaluate_density(wp.x - e, wp.y, wp.z);
float dzp = evaluate_density(wp.x, wp.y, wp.z + e);
float dzm = evaluate_density(wp.x, wp.y, wp.z - e);
vec3 g = vec3(dxp - dxm, 2.0 * e, dzp - dzm);"""
# ⛔ evaluate_density IS IN THE TERRAIN'S FRAME, NOT THE WORLD'S.
# DCTerrain meshes from chunk origins that are terrain-LOCAL
# (dc_terrain.cpp: `chunk_origin_x = node->position.x * voxel_size_`) and
# adds terrain_origin() only when it places the finished chunk in the world.
# So a world position handed straight to evaluate_density asks about a point
# terrain_origin() away from the one being shaded. map_flat puts the mountain
# on tile (1, 0) -- FOUR KILOMETRES out -- so this was sampling open air
# beside the mountain, silently, and only stayed unseen because the detail
# normal defaults off. Same defect as #83 §6b, one layer down.
#
# The transform is the one DCTerrain pushes into the material. Without it
# (an asset published before blender-bridge v0.161.0) the world position is
# used as before: wrong for a terrain away from the origin, but it is what
# the shader can name, and build() says so in its log rather than emitting a
# shader that will not compile.
var frame := "vec3 wp = _gwp;"
if has_hook:
frame = ("vec3 wp = (bb_world_to_object_on > 0.5)\n"
+ "\t\t\t\t? (bb_world_to_object * vec4(_gwp, 1.0)).xyz : _gwp;")
return """
// ---- detail normal ----------------------------------------------------
{
vec3 _gwp = (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
""" + frame + """
float e = max(dn_eps, length(fwidth(wp)));
""" + grad + """
if (length(g) > 1e-9) {
vec3 wn = normalize(-g);
vec3 vn = normalize((VIEW_MATRIX * vec4(wn, 0.0)).xyz);
NORMAL = normalize(mix(NORMAL, vn, dn_blend));
}
}
"""