extends SceneTree
## Per-node GPU cost of the mountain's density body (blender/mountain_voronoi3.tscn),
## as map_mountain configures it (DCTerrainGeneratorProcNodesBounded, world_scale 1,
## heightfield off).
##
## ABLATION: a variant DISCONNECTS a node's outputs, so what it fed falls back to its
## stored default and proc-nodes emits only the density's cone — that node and
## everything feeding only it drop out of the shader. Its time saved vs the full graph
## is its EXCLUSIVE cost. "trivial" cuts the density itself: the fixed cost (dispatch,
## sync, readback) every variant pays.
##
## TIMING: DCTerrain.debug_gpu_density over the mountain's whole box (cells³ points);
## the GPU's own time for it (debug_last_gpu_fill_ms: timestamps around the compute
## list). ⛔ NOT wall clock: a first run timed the call and got removals that were
## SLOWER than the full graph and "all textures" below "trivial" — the wait behind the
## renderer's frame, not the shader. Median of REPS after WARM, once the terrain's own
## streaming has settled.
## ⛔ PAIRED: the full graph is re-measured right before and after EVERY variant, and a
## variant is compared to the mean of that pair only. A second run (timestamps, unpaired)
## had "full" drift 2.81 -> 3.26 ms mid-run — a GPU clock change — which made most
## removals look slower than the reference. The report checks the ruler (each pair
## within 5%, no removal slower than its pair, nothing below trivial) before any number.
## Each variant reloads the scene uncached, so variants cannot leak into each other.
##
## ⚠ WINDOWED (needs a RenderingDevice):
## CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 DISPLAY=:0 "$HYPER_TPS_GODOT" --path . \
## --script res://tools/bench_mountain_nodes.gd
const SCENE := "res://blender/mountain_voronoi3.tscn"
const HOLDER := ^"Mountain/ProcGeoNodes"
const CELLS := 128
const WARM := 10
const REPS := 15
const SETTLE_FRAMES := 30
const SETTLE_MAX_MS := 30000
const KINDS := ["NoiseTexture", "VoronoiTexture", "GradientTexture"]
var _terrain: Node
var _variants: Array = [] # [name, Array[node idx] to cut, or -1 = cut the density]
var _vi := -1
var _phase := ""
var _quiet := 0
var _t_settle := 0
var _box_min := Vector3.ZERO
var _voxel := 0.0
var _times: Array[float] = []
var _results: Array = []
var _evals := 0
func _initialize() -> void:
var g := _fresh()
var gen := _gen(g)
if not gen.call("has_compiled_shader"):
print("FAIL the full graph did not compile: ", gen.call("get_last_compile_error"))
quit(1)
return
_box_min = gen.call("get_bounds_min")
var size: Vector3 = Vector3(gen.call("get_bounds_max")) - _box_min
_voxel = maxf(size.x, maxf(size.y, size.z)) / CELLS
_evals = (CELLS + 1) * (CELLS + 1) * (CELLS + 1)
var emitted := _emitted(gen)
var by_kind := {}
for i in g.get_node_count():
var n: ProcNode = g.get_node(i)
if n != null and emitted.has(i) and KINDS.has(String(n.get_node_type())):
_variants.append(["%s #%d" % [n.get_node_type(), i], [i]])
by_kind[n.get_node_type()] = by_kind.get(n.get_node_type(), []) + [i]
for k in by_kind:
_variants.append(["ALL %s (%d)" % [k, by_kind[k].size()], by_kind[k]])
var all_tex := []
for k in by_kind:
all_tex += by_kind[k]
_variants.append(["ALL textures (%d)" % all_tex.size(), all_tex])
_variants.push_front(["trivial", -1])
# full, v1, full, v2, full, ... full: every variant bracketed by the reference.
var paired := [["full", []]]
for v in _variants:
paired.append(v)
paired.append(["full", []])
_variants = paired
print("BENCH box %s + %s, %d^3 points (%d evals/call), voxel %.1f m, %d variants, %d emitted nodes"
% [_box_min, size, CELLS + 1, _evals, _voxel, _variants.size(), emitted.size()])
_terrain = ClassDB.instantiate("DCTerrain")
_terrain.set("use_gpu", true)
_terrain.set("voxel_size", 64.0)
root.add_child(_terrain)
_next()
func _fresh() -> ProcNodeGraph:
var ps := ResourceLoader.load(SCENE, "", ResourceLoader.CACHE_MODE_IGNORE_DEEP) as PackedScene
var inst := ps.instantiate()
var g := inst.get_node(HOLDER).get("graph") as ProcNodeGraph
inst.free()
return g
func _gen(g: ProcNodeGraph) -> Object:
var gen: Object = ClassDB.instantiate("DCTerrainGeneratorProcNodesBounded")
gen.call("set_world_scale", 1.0)
gen.call("set_heightfield", false)
gen.call("set_graph", g)
return gen
func _emitted(gen: Object) -> Dictionary:
var out := {}
for m in RegEx.create_from_string("// Node (\\d+): ").search_all(String(gen.call("get_compiled_glsl"))):
out[int(m.get_string(1))] = true
return out
## Disconnect every output of the given nodes; -1 = the Group Output's density input.
func _cut(g: ProcNodeGraph, what: Variant) -> void:
var c := g.get_connections_flat()
if typeof(what) == TYPE_INT:
for i in g.get_node_count():
if g.get_node(i) is GroupOutputNode:
for j in range(0, c.size(), 4):
if c[j + 2] == i and c[j + 3] == 1: # socket 1 = Density
g.disconnect_sockets(c[j], c[j + 1], i, 1)
return
for idx in what:
for j in range(0, c.size(), 4):
if c[j] == idx:
g.disconnect_sockets(c[j], c[j + 1], c[j + 2], c[j + 3])
func _next() -> void:
_vi += 1
if _vi >= _variants.size():
_report()
return
var g := _fresh()
_cut(g, _variants[_vi][1])
var gen := _gen(g)
if not gen.call("has_compiled_shader"):
_results.append([_variants[_vi][0], -1.0, -1.0, 0, "did not compile: " + String(gen.call("get_last_compile_error")).left(80)])
_next()
return
_variants[_vi].append(_emitted(gen).size())
_terrain.set("generator", gen)
_phase = "settle"
_quiet = 0
_t_settle = Time.get_ticks_msec()
_times.clear()
func _process(_dt: float) -> bool:
match _phase:
"settle":
_quiet = _quiet + 1 if int(_terrain.call("get_pending_count")) == 0 else 0
if _quiet >= SETTLE_FRAMES or Time.get_ticks_msec() - _t_settle > SETTLE_MAX_MS:
_phase = "time"
"time":
var d: PackedFloat32Array = _terrain.call("debug_gpu_density", _box_min, _voxel, CELLS)
var ms := float(_terrain.call("debug_last_gpu_fill_ms"))
if d.size() != _evals or ms < 0.0:
_results.append([_variants[_vi][0], -1.0, -1.0, 0, "GPU declined (%d values)" % d.size()])
_next()
return false
_times.append(ms)
if _times.size() >= WARM + REPS:
var s := _times.slice(WARM)
s.sort()
_results.append([_variants[_vi][0], s[s.size() / 2], s[s.size() / 10], _variants[_vi][2], ""])
_next()
"done":
return true
return false
func _report() -> void:
_phase = "done"
# _results alternates: full, v, full, v, ..., full. Each v against its bracketing pair.
var rows := []
var worst_pair := 0.0
var slower := 0
var below := 0
var trivial_ms := -1.0
var ref_trivial := -1.0
for i in range(1, _results.size() - 1, 2):
var a: float = _results[i - 1][1]
var b: float = _results[i + 1][1]
var v: Array = _results[i]
var ref := (a + b) * 0.5
worst_pair = maxf(worst_pair, absf(a - b) / ref)
rows.append([v, ref])
if v[0] == "trivial":
trivial_ms = v[1]
ref_trivial = ref
for row in rows:
var v: Array = row[0]
if v[1] < 0.0 or v[0] == "trivial":
continue
if v[1] > row[1] * 1.03: slower += 1
if v[1] < trivial_ms * 0.97: below += 1
print("\nRULER worst pair spread %.1f%%, removals slower than their pair: %d, below trivial: %d -> %s"
% [100.0 * worst_pair, slower, below,
"VALID" if worst_pair < 0.05 and slower == 0 and below == 0 else "INVALID — do not use"])
print("\n%-26s %9s %9s %8s %10s %8s" % ["variant", "median ms", "pair ms", "nodes", "saves ms", "% body"])
for row in rows:
var v: Array = row[0]
if v[1] < 0.0:
print("%-26s %s" % [v[0], v[4]])
continue
var body: float = row[1] - trivial_ms # this pair's body
var saves: float = row[1] - v[1]
print("%-26s %9.3f %9.3f %8d %10.3f %7.1f%%" % [v[0], v[1], row[1], v[3], saves,
100.0 * saves / body if body > 0.0 else 0.0])
var body0 := ref_trivial - trivial_ms
print("\nBODY = full - trivial = %.3f ms per %d evals = %.2f ns/eval" % [body0, _evals, body0 * 1.0e6 / _evals])
quit(0)