OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / bridged_density_material.gd

394 lines · gd ref:
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));
		}
	}
"""