OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / mountain_density_vs_mesh.gd

330 lines · gd ref:
@tool
extends Node3D
## SIDE BY SIDE, IN THE EDITOR: the same graph's two products.
##
## LEFT  (-25): the MESH product -- the evaluated Blender mesh.
## RIGHT (+25): the DENSITY product -- the field dc-terrain would stream, baked
##              to a mesh so it can be looked at without running the game.
##
## ⛔ WHY THIS DOES NOT USE A STREAMING DCTerrain. In the editor
## `force_shared = is_editor_hint() && use_gpu` takes the shared-CPU-worker
## branch and creates NO GPU thread (dc_terrain.cpp:1746), while
## DCTerrainGeneratorProcNodesBounded::evaluate() returns AIR on the CPU by
## contract. Measured with a streaming terrain here: chunks=1408, gpu_density=0,
## empty=1408 -- an empty viewport and no error anywhere.
##
## ✅ bake_volume_mesh() is the path that works: it brings the GPU thread up into
## the same slot the terrain uses ("One device, ever", dc_terrain.cpp:3488) and
## does not require the terrain to be streaming. It is NON-BLOCKING and returns
## null while the device comes up -- seconds -- so this polls.

## Which bridged asset to compare. EXPORTED, not a const, so the same instrument
## serves every asset: mountain_density_vs_mesh_v5.tscn points it at
## mountain_voronoi3.tscn (which now carries voronoi5, a 12 km floating island)
## without disturbing this scene's voronoi2 default.
@export_file("*.tscn") var mountain_scene: String = "res://blender/mountain_voronoi2.tscn"
## Half the gap between the two products. 25 suits a 40 m model; a 12 km island
## needs thousands, or the two sides overlap.
@export var separation: float = 25.0
## 64 cells at 0.625 spans 40 units -- the graph's whole 40 x 40 box.
##
## ⛔ 64 IS A CEILING, NOT A TASTE. dc_gpu_mesh_pipeline.cpp's prefix scan is two
## levels: pass 1 scans blocks of 1024, pass 2 (`scan_single`) scans the block
## sums in ONE workgroup. So it handles at most 1024 blocks = ~1,048,576
## elements, and the edge pass has 3*stride^3 of them:
##
##     stride  32 ->    98k ->   96 blocks   ok  (what streaming submits)
##     stride  64 ->   786k ->  768 blocks   ok
##     stride 128 ->  6.29M -> 6144 blocks   SIX TIMES OVER
##
## Past the ceiling the sums are wrong, stream compaction emits nothing, and the
## bake reports "no zero crossing" -- the same message as an empty volume, with
## no error. Measured: at 128 even a radius-10 SPHERE meshed nothing.
const BAKE_CELLS := 64

## Metres of empty space around the graph's box, in node units.
##
## ⛔ WITHOUT IT THE BOX HAS NO BOTTOM. The clip makes the boundary a zero
## crossing, but dual contouring needs a sign change BETWEEN two samples -- and
## with origin = bounds_min the bottom face lands exactly on the first sample
## plane and the sides on the first columns, so there is nothing outside to
## change sign against. The clip is present and unmeshable: a mountain with its
## base missing, which is not a clipping bug and reads exactly like one.
##
## This matters more than it sounds: a floating island is bounded on EVERY side,
## so a volume whose boundary cannot be meshed loses all of it, not just a base.
const BAKE_MARGIN := 2.0

## Bake a KNOWN field instead of the graph: a sphere of radius 10 at (0, 8, 0),
## well inside the same region.
##
## THIS IS A BISECT, not a feature. The CPU evaluator puts the graph's surface at
## y = 9.90 at the centre, which is inside the baked region -- so "no zero
## crossing" means either the GPU disagrees with the CPU about this graph, or the
## bake call is wrong in a way that has nothing to do with the graph. A sphere
## cannot be got wrong: if it meshes and the graph does not, the bake path is
## sound and the emitted GLSL is the suspect; if neither meshes, the suspect is
## this call -- origin, cell count, or the device itself.
## Evaluate the graph's terrain functions on the GROUND PLANE instead of at the
## query point, then subtract the query height.
##
## THE HYPOTHESIS, TESTABLE IN ONE RELOAD. The density terminal is
## `n26(pos) - pos.z`, and n26 reaches Position -- so the cone
## (`1 - length(pos/Extent)`), the Perlin and the Voronoi all shrink as you climb.
## The surface then solves n26(z) = z instead of z = n26(x,y), which is why the
## right-hand shape is a low dome. The MESH does not have this problem because it
## evaluates the same expression once on a FLAT cage (base z range -2..2), where
## the height term is ~0.
##
## Measured at the centre: n26 = 18.771 at z=0, but the surface lands at 9.90.
## Flattening should therefore raise the peak to ~18.77 and make the two sides
## agree -- which is the check, not the claim.
##
## Now a real generator property (DCTerrainGeneratorProcNodesBounded.heightfield),
## opt-in because flattening a genuine 3D volume would erase caves and overhangs.
## ON by default: this scene exists to show the two products AGREEING. Untick it
## to reproduce the squashed dome the 3D sampling produced.
@export var flatten_heightfield: bool = true:
	set(v):
		flatten_heightfield = v
		if is_inside_tree():
			_build()

@export var bake_test_sphere: bool = false:
	set(v):
		bake_test_sphere = v
		if is_inside_tree():
			_build()

@export var rebuild: bool = false:
	set(v):
		rebuild = false
		if is_inside_tree():
			_build()

var _terrain: Node = null
## ⛔ ONE MATERIAL INSTANCE, ON BOTH SIDES. The density side used to get no
## material at all -- _process built DensityProduct, set .mesh, and stopped --
## so it rendered in Godot's default white and this scene could compare the two
## SHAPES and nothing else. That is the whole reason "does the bridged material
## look wrong on the density product" had no instrument.
##
## Sharing the instance rather than taking a second copy is deliberate: with one
## material and one set of uniform values, ANY difference left between the two
## sides is a property of the SURFACE -- its normals, its coordinates, its
## topology -- and cannot be a material that drifted between them.
var _mat: Material = null
var _glsl := ""
var _voxel := 0.625
var _origin := Vector3.ZERO
var _bounds_min := Vector3.ZERO
var _bounds_max := Vector3.ZERO
var _baked := false
var _polls := 0

func _ready() -> void:
	_build()

func _build() -> void:
	for c in get_children():
		c.queue_free()
	if _terrain != null and not _terrain.is_inside_tree():
		_terrain.free()   # never entered the tree, so nothing else will free it
	_baked = false
	_polls = 0
	_terrain = null
	_mat = null
	_build_mesh_side()
	_prepare_density_side()
	set_process(true)

## LEFT: the mesh, GENERATED. Never loaded from disk, and never written to it.
##
## The evaluated result is 2,582,018 verts of DERIVED data; what the file carries
## is the 10,088-vert authored cage, in `input_mesh_data`. That property only
## started persisting with the proc-nodes fix beside this change -- before it, the
## bridge fed the cage into a setter with no backing property (exactly like
## GroupOutputNode::interface before bcb11f7), so a published scene had no
## geometry at all and evaluate() returned a null mesh.
##
## evaluate() is called explicitly rather than trusting `auto_update`: that flag
## is only read inside NOTIFICATION_PROCESS, and the bridge itself drives
## evaluation by hand for the same reason.
func _build_mesh_side() -> void:
	if not ResourceLoader.exists(mountain_scene):
		push_warning("[DensityVsMesh] %s missing" % mountain_scene)
		return
	var side: Node3D = (load(mountain_scene) as PackedScene).instantiate()
	side.name = "MeshProduct"
	add_child(side)
	side.position = Vector3(-separation, 0.0, 0.0)
	# The bridge applies the material as material_override on the Mountain
	# MeshInstance3D, and proc-nodes copies it down to MeshOutput -- so this is
	# the same Ref the mesh product actually renders with, not a lookalike.
	var mi_src: Node = side.get_node_or_null(^"Mountain")
	if mi_src != null:
		_mat = mi_src.get("material_override")
	var pg: Node = side.get_node_or_null(^"Mountain/ProcGeoNodes")
	if pg == null:
		push_warning("[DensityVsMesh] no ProcGeoNodes in the asset")
		return
	var cage: Dictionary = pg.get("input_mesh_data")
	if cage.is_empty() and pg.get("input_mesh") == null:
		push_warning("[DensityVsMesh] the asset carries no input cage, so there is " +
				"nothing to evaluate. Re-publish from Blender with proc-nodes' " +
				"input_mesh_data fix in place.")
		return
	pg.call("evaluate")
	var mo: Node = pg.get_node_or_null(^"MeshOutput")
	var m: Mesh = mo.get("mesh") if mo != null else null
	if m == null:
		push_warning("[DensityVsMesh] evaluate() produced no mesh")
		return
	var n := 0
	if m.get_surface_count() > 0:
		n = (m.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] as PackedVector3Array).size()
	print("[DensityVsMesh] mesh product: generated %d verts from a %d-point cage, aabb %s"
			% [n, (cage.get("points", PackedVector3Array()) as PackedVector3Array).size(), m.get_aabb()])


func _prepare_density_side() -> void:
	if not ClassDB.class_exists("DCTerrainGeneratorProcNodesBounded"):
		push_warning("[DensityVsMesh] dc-terrain extension missing or stale")
		return
	if not ResourceLoader.exists(mountain_scene):
		push_warning("[DensityVsMesh] %s missing" % mountain_scene)
		return
	var inst: Node = (load(mountain_scene) as PackedScene).instantiate()
	var holder: Node = inst.get_node_or_null(^"Mountain/ProcGeoNodes")
	var graph: Resource = holder.get("graph") if holder != null else null
	# _build_mesh_side has several early returns, so it may not have got there.
	if _mat == null:
		var mi_src: Node = inst.get_node_or_null(^"Mountain")
		if mi_src != null:
			_mat = mi_src.get("material_override")
	inst.free()
	if graph == null:
		push_warning("[DensityVsMesh] no graph in the asset")
		return
	var gen: Resource = ClassDB.instantiate("DCTerrainGeneratorProcNodesBounded")
	# The REAL path now, not a rewrite of the emitted text here: dc-terrain owns
	# this, because it already owns the wrapper that carries world_scale and the
	# bounds clip.
	gen.set_heightfield(flatten_heightfield)
	gen.set_graph(graph)
	if not gen.has_compiled_shader():
		push_warning("[DensityVsMesh] the graph did not compile: %s" % gen.get_last_compile_error())
		return
	# ⛔ RENAME, DO NOT TRIM. bake_volume_mesh appends its own adapter --
	# `float evaluate_density(x,y,z) { return proc_density(vec3(x,y,z)); }`
	# (dc_terrain.cpp:3540) -- and the generator emits an evaluate_density of its
	# own, so passing the text unaltered gives "function already has a body".
	#
	# Cutting the generator's wrapper fixes that and throws away what the wrapper
	# IS: world_scale and the BOUNDS CLIP. Without the clip the field is
	# unbounded, so the volume has no box to mesh -- no base and no sides, which
	# is visible as a mountain with its bottom missing beside a mesh that sits on
	# its cage.
	#
	# So rename instead, and let the bake's adapter call the generator's wrapper
	# with everything it carries.
	var full: String = gen.get_compiled_glsl()
	if full.find("float proc_density(") == -1 or full.find("float evaluate_density(") == -1:
		push_warning("[DensityVsMesh] the emitter changed shape; cannot rewire the entry point")
		return
	_glsl = full.replace("float proc_density(", "float graph_density(")
	_glsl = _glsl.replace("proc_density(p)", "graph_density(p)")
	_glsl = _glsl.replace("float evaluate_density(", "float gen_density(")
	_glsl += "\n\nfloat proc_density(vec3 p) { return gen_density(p.x, p.y, p.z); }\n"
	# Fit the box PLUS its margin into the cube of cells, and place the origin so
	# the box is strictly inside.
	_bounds_min = gen.get_bounds_min()
	_bounds_max = gen.get_bounds_max()
	var span: Vector3 = gen.get_bounds_max() - gen.get_bounds_min()
	var need: float = maxf(span.x, maxf(span.y, span.z)) + 2.0 * BAKE_MARGIN
	_voxel = need / float(BAKE_CELLS)
	_origin = gen.get_bounds_min() - Vector3(BAKE_MARGIN, BAKE_MARGIN, BAKE_MARGIN)
	# ⛔ NOT IN THE TREE. This exists only to reach bake_volume_mesh(), which
	# brings up its own GPU thread and needs no tree. Adding it started a whole
	# terrain: ENTER_TREE with use_gpu=0 takes the GAME-MODE branch (force_shared
	# is is_editor_hint() && use_gpu), spawns workers, and streams its DEFAULT
	# density -- measured, 1152 solid chunks, 100% CPU-meshed. That is the
	# "default noise field" that appeared instead of this graph.
	_terrain = ClassDB.instantiate("DCTerrain")
	# Reported HERE, not after the bake: this is the diagnostic, and gating it
	# on a successful GPU bake would hide it exactly when the bake is what is
	# wrong -- and it needs no GPU, so it runs headless.
	_report_uniforms()
	print("[DensityVsMesh] density: %d chars of GLSL (clip + world_scale KEPT), origin %v, %d cells at %.4f (span %.1f)"
			% [_glsl.length(), _origin, BAKE_CELLS, _voxel, BAKE_CELLS * _voxel])

func _process(_dt: float) -> void:
	if _baked or _terrain == null or _glsl.is_empty():
		return
	_polls += 1
	var mesh: Mesh = _terrain.call("bake_volume_mesh", _glsl, _origin, _voxel, BAKE_CELLS)
	if mesh == null:
		# Expected for the first seconds: the GPU device is coming up.
		if _polls % 120 == 0:
			print("[DensityVsMesh] waiting on the GPU device (poll %d)" % _polls)
		return
	_baked = true
	var mi := MeshInstance3D.new()
	mi.name = "DensityProduct"
	add_child(mi)
	mi.position = Vector3(separation, 0.0, 0.0)
	# NOT corrected here any more. bake_volume_mesh negates the gradient at the
	# source, because "the mesher writes a gradient and a gradient points into
	# the solid" is a property of the mesher, not of this scene.
	mi.mesh = mesh
	# THE FIX. material_override, not surface_set_material: the override has no
	# dependency on the mesh already having surface 0, so it cannot silently
	# no-op on a bake that came back with a different surface count.
	if _mat != null:
		mi.material_override = _mat
		print("[DensityVsMesh] density material applied: %s" % _mat)
	else:
		push_warning("[DensityVsMesh] the asset carries no material_override on " +
				"Mountain, so the density side renders untextured and the two " +
				"products can only be compared by SHAPE. Re-bridge from Blender.")
	var n := 0
	if mesh.get_surface_count() > 0:
		n = (mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] as PackedVector3Array).size()
	print("[DensityVsMesh] density product: %d verts, aabb %s (after %d polls)"
			% [n, mesh.get_aabb(), _polls])


## The four uniforms that decide WHERE the material samples itself, printed
## because this scene is where a mismatch between them and the graph is visible.
##
## Blender's "Generated" is the position normalised over the UNDEFORMED MESH
## bounding box, so bb_gen_min/bb_gen_size describe the MESH. The density
## product is not a mesh -- its surface comes from the field, over the box the
## graph DECLARES. When those two boxes disagree the material samples the wrong
## region of itself, and it shows up here as two differently-shaded sides of one
## material rather than as any kind of error.
func _report_uniforms() -> void:
	if not (_mat is ShaderMaterial):
		return
	var m := _mat as ShaderMaterial
	var gmin = m.get_shader_parameter("bb_gen_min")
	var gsize = m.get_shader_parameter("bb_gen_size")
	print("[DensityVsMesh] material box (Generated): min %s size %s  from_uv=%s  coord_scale=%s"
			% [gmin, gsize, m.get_shader_parameter("bb_gen_from_uv"),
			   m.get_shader_parameter("bb_coord_scale")])
	if gmin == null or gsize == null:
		return
	var gmax = (gmin as Vector3) + (gsize as Vector3)
	print("           i.e. the material expects the surface inside %s .. %s" % [gmin, gmax])
	print("           the graph DECLARES the field inside %s .. %s"
			% [_bounds_min, _bounds_max])
	var lo := (gmin as Vector3)
	var hi := gmax as Vector3
	if true:
		if not (_bounds_min.is_equal_approx(lo) and _bounds_max.is_equal_approx(hi)):
			push_warning("[DensityVsMesh] THE TWO BOXES DISAGREE. Generated is "
					+ "normalised over %s..%s but the field is declared over " % [lo, hi]
					+ "%s..%s, so the material samples the wrong region " % [_bounds_min, _bounds_max]
					+ "of itself on the density side. The mesh side is unaffected, "
					+ "which is what makes the two sides differ here.")