class_name TerrainProbe
extends RefCounted
## TERRAIN GROUND PROBE — what is actually under the player, measured five ways.
##
## WHY THIS EXISTS. Every terrain instrument in this repo so far has been either
## headless or offline, and on a heightfield map BOTH are structurally blind:
## the baked block is bound on the GPU only (dc_terrain_generator_procnodes.cpp
## emits it into the GLSL; the C++ evaluate() never samples it), so
## sample_density() and every headless gate return the fBm world this map
## replaced. map_dc_diffusion.gd prints the size of that gap at every spawn --
## "CPU says 330.0 m, GPU expects 165.0 m". An instrument built on the CPU field
## can therefore report a clean table about a world the player never stands on.
##
## So this runs INSIDE a real render session, against the physics bodies the
## player actually collides with and the GPU density the mesher actually saw.
##
## AND IT SELF-TESTS ON EVERY RUN. #50's rule -- "an instrument must be able to
## fail" -- is enforced here rather than remembered: selftest() feeds synthetic
## flat / sloped / spiked / vertical data through the SAME pure functions the
## live report uses and checks they answer what those inputs mean. A report
## whose header says SELFTEST FAIL is not evidence about the terrain.
##
## The five measurements, and what each one distinguishes:
##
## A chunk which LOD the ground here is, and whether it has collision
## at all. "no collision body" and "bumpy collision" look the
## same from inside the game and need opposite work.
## B walked the collision surface as the player meets it: a downward
## raycast grid. Steps, roughness, holes, and how many
## neighbour pairs exceed floor_max_angle -- i.e. how many
## WALLS are in ground that looks flat.
## C triangles the collision mesh's own faces, by inclination. B says the
## surface is bumpy; C says whether the bumps are triangles
## standing up or the sampling of a smooth thing.
## D field the GPU density at the SAME voxel size the mesher used. If
## the field is smooth and B is bumpy, the MESHER makes the
## bumps. If the field is bumpy, the generator does. This is
## the one split no previous instrument could make.
## E lod shift the same field at voxel v and 2v. That difference IS the
## step a player walks into at an LOD boundary.
##
## Sign convention: density positive = inside solid (dc_terrain.h).
const MISS := 1e30 # a raycast that hit nothing, kept in-band as a float
const DEG := 180.0 / PI
# ---------------------------------------------------------------------------
# PURE metrics. No engine state, so selftest() can drive them with data whose
# answer is known by construction.
# ---------------------------------------------------------------------------
## Height grid -> the numbers that decide whether a surface is walkable.
## h is n*n row-major (index = row*n + col); spacing is metres between samples.
static func height_metrics(h: PackedFloat32Array, n: int, spacing: float) -> Dictionary:
var out := {
"samples": n * n, "misses": 0, "pairs": 0,
"max_step": 0.0, "p95_step": 0.0, "mean_step": 0.0,
"max_slope_deg": 0.0, "steeper_than_60": 0, "steeper_than_45": 0,
"plane_rms": 0.0, "relief": 0.0, "worst_at": Vector2i(-1, -1),
}
if n < 2 or spacing <= 0.0 or h.size() < n * n:
out["error"] = "grid too small"
return out
var steps := PackedFloat32Array()
var lo := INF
var hi := -INF
var misses := 0
for r in range(n):
for c in range(n):
var v: float = h[r * n + c]
if v >= MISS:
misses += 1
continue
if v < lo: lo = v
if v > hi: hi = v
# 4-neighbour pairs, each counted once (right and down)
if c + 1 < n:
var vr: float = h[r * n + c + 1]
if vr < MISS:
var d: float = absf(vr - v)
steps.append(d)
if d > out["max_step"]:
out["max_step"] = d
out["worst_at"] = Vector2i(c, r)
if r + 1 < n:
var vd: float = h[(r + 1) * n + c]
if vd < MISS:
var d2: float = absf(vd - v)
steps.append(d2)
if d2 > out["max_step"]:
out["max_step"] = d2
out["worst_at"] = Vector2i(c, r)
out["misses"] = misses
out["pairs"] = steps.size()
if steps.size() > 0:
var sorted := steps.duplicate()
sorted.sort()
out["p95_step"] = sorted[int(floor(float(sorted.size() - 1) * 0.95))]
var acc := 0.0
for s in steps:
acc += s
out["mean_step"] = acc / float(steps.size())
var worst: float = out["max_step"]
out["max_slope_deg"] = atan(worst / spacing) * DEG
for s in steps:
var a: float = atan(s / spacing) * DEG
if a > 60.0: out["steeper_than_60"] = int(out["steeper_than_60"]) + 1
if a > 45.0: out["steeper_than_45"] = int(out["steeper_than_45"]) + 1
if lo <= hi:
out["relief"] = hi - lo
# Least-squares plane through the hits: h = a*x + b*z + c. The residual RMS
# is roughness at the WALKING scale with the hillside's own slope removed,
# which is the number a slope figure cannot give you.
var sx := 0.0; var sz := 0.0; var sh := 0.0
var sxx := 0.0; var szz := 0.0; var sxz := 0.0; var sxh := 0.0; var szh := 0.0
var m := 0
for r in range(n):
for c in range(n):
var v2: float = h[r * n + c]
if v2 >= MISS:
continue
var x := float(c) * spacing
var z := float(r) * spacing
sx += x; sz += z; sh += v2
sxx += x * x; szz += z * z; sxz += x * z
sxh += x * v2; szh += z * v2
m += 1
out["fit_samples"] = m
if m < 8:
# TOO FEW POINTS TO SAY ANYTHING. The plane fit through 5 points of a
# 81-point grid is not "roughness 0.0000", it is no measurement -- and
# 0.0000 reads as "perfectly smooth", which is the most misleading
# answer available. Live report D printed exactly that from 5 columns.
out["plane_rms"] = -1.0
out["undersampled"] = true
if m >= 3:
# Solve the 3x3 normal equations by Cramer's rule.
var fm := float(m)
var a11 := sxx; var a12 := sxz; var a13 := sx
var a22 := szz; var a23 := sz; var a33 := fm
var det := (a11 * (a22 * a33 - a23 * a23)
- a12 * (a12 * a33 - a23 * a13)
+ a13 * (a12 * a23 - a22 * a13))
if absf(det) > 1e-9:
var b1 := sxh; var b2 := szh; var b3 := sh
var da := (b1 * (a22 * a33 - a23 * a23)
- a12 * (b2 * a33 - a23 * b3)
+ a13 * (b2 * a23 - a22 * b3))
var db := (a11 * (b2 * a33 - a23 * b3)
- b1 * (a12 * a33 - a23 * a13)
+ a13 * (a12 * b3 - b2 * a13))
var dc := (a11 * (a22 * b3 - b2 * a23)
- a12 * (a12 * b3 - b2 * a13)
+ b1 * (a12 * a23 - a22 * a13))
var pa := da / det; var pb := db / det; var pc := dc / det
var acc2 := 0.0
for r2 in range(n):
for c2 in range(n):
var v3: float = h[r2 * n + c2]
if v3 >= MISS:
continue
var e: float = v3 - (pa * float(c2) * spacing + pb * float(r2) * spacing + pc)
acc2 += e * e
if m >= 8:
out["plane_rms"] = sqrt(acc2 / float(m))
out["plane_slope_deg"] = atan(sqrt(pa * pa + pb * pb)) * DEG
return out
## Triangle list (flat triples) -> inclination statistics against `up`.
static func triangle_metrics(faces: PackedVector3Array, up: Vector3) -> Dictionary:
var out := {
"triangles": 0, "degenerate": 0, "overhang": 0,
"over_60": 0, "over_45": 0, "max_incl_deg": 0.0,
"area": 0.0, "area_over_60": 0.0, "mean_incl_deg": 0.0,
"hist_deg": PackedInt32Array([0, 0, 0, 0, 0, 0]), # 0-15,15-30,30-45,45-60,60-80,80-90+
}
if faces.size() < 3:
return out
var u := up.normalized()
var incl_sum := 0.0
var t := 0
while t + 2 < faces.size():
var a := faces[t]
var b := faces[t + 1]
var c := faces[t + 2]
t += 3
# GODOT'S WINDING, not the textbook one. Plane(p1,p2,p3) builds its
# normal as (p1-p3).cross(p1-p2), which is the NEGATIVE of
# (b-a).cross(c-a) -- so the obvious formula reported every up-facing
# terrain triangle as an overhang. It did on the first three live
# reports (152/152, 296/296) and the selftest did not catch it because
# no case asserted the overhang field. Fixed, and case 6 below now
# pins the sign.
var nrm := (a - c).cross(a - b)
var len2 := nrm.length()
if len2 < 1e-9:
out["degenerate"] = int(out["degenerate"]) + 1
continue
out["triangles"] = int(out["triangles"]) + 1
var area := len2 * 0.5
out["area"] = float(out["area"]) + area
var cosang: float = clampf((nrm / len2).dot(u), -1.0, 1.0)
if cosang < 0.0:
out["overhang"] = int(out["overhang"]) + 1
var incl: float = acos(absf(cosang)) * DEG
incl_sum += incl
if incl > float(out["max_incl_deg"]):
out["max_incl_deg"] = incl
if incl > 45.0: out["over_45"] = int(out["over_45"]) + 1
if incl > 60.0:
out["over_60"] = int(out["over_60"]) + 1
out["area_over_60"] = float(out["area_over_60"]) + area
var bin := 0
if incl >= 80.0: bin = 5
elif incl >= 60.0: bin = 4
elif incl >= 45.0: bin = 3
elif incl >= 30.0: bin = 2
elif incl >= 15.0: bin = 1
var hist: PackedInt32Array = out["hist_deg"]
hist[bin] += 1
out["hist_deg"] = hist
if int(out["triangles"]) > 0:
out["mean_incl_deg"] = incl_sum / float(out["triangles"])
return out
# ---------------------------------------------------------------------------
# THE INSTRUMENT MUST BE ABLE TO FAIL. Synthetic inputs whose answers are known
# by construction, through the SAME functions the live report uses.
# ---------------------------------------------------------------------------
static func selftest() -> Dictionary:
var cases: Array = []
var ok := true
var n := 9
var sp := 0.25
# 1. Flat: no steps, no roughness, no walls.
var flat := PackedFloat32Array()
flat.resize(n * n)
flat.fill(100.0)
var mf := height_metrics(flat, n, sp)
var c1: bool = float(mf["max_step"]) < 1e-6 and float(mf["plane_rms"]) < 1e-6 and int(mf["steeper_than_60"]) == 0
cases.append(["flat -> 0 step, 0 rms", c1, "max_step=%.6f rms=%.6f" % [mf["max_step"], mf["plane_rms"]]])
ok = ok and c1
# 2. A 30 degree ramp: a real slope must NOT read as roughness, and must NOT
# read as a wall. A metric that cannot tell a hillside from a barb is the
# exact failure this probe exists to avoid.
var ramp := PackedFloat32Array()
ramp.resize(n * n)
var tan30 := tan(deg_to_rad(30.0))
for r in range(n):
for c in range(n):
ramp[r * n + c] = float(c) * sp * tan30
var mr := height_metrics(ramp, n, sp)
var c2: bool = absf(float(mr["max_slope_deg"]) - 30.0) < 0.5 \
and float(mr["plane_rms"]) < 1e-5 \
and int(mr["steeper_than_60"]) == 0 \
and absf(float(mr.get("plane_slope_deg", 0.0)) - 30.0) < 0.5
cases.append(["30deg ramp -> slope 30, rms 0", c2,
"slope=%.3f rms=%.6f plane=%.3f" % [mr["max_slope_deg"], mr["plane_rms"], mr.get("plane_slope_deg", 0.0)]])
ok = ok and c2
# 3. One spike of exactly 1 m on flat ground: max_step must SEE it.
var spike := flat.duplicate()
spike[4 * n + 4] = 101.0
var ms := height_metrics(spike, n, sp)
var c3: bool = absf(float(ms["max_step"]) - 1.0) < 1e-5 and int(ms["steeper_than_60"]) >= 4
cases.append(["1 m spike -> step 1.0, walls>=4", c3,
"max_step=%.6f walls=%d" % [ms["max_step"], ms["steeper_than_60"]]])
ok = ok and c3
# 4. A hole: a miss must be counted, not silently averaged away.
var holed := flat.duplicate()
holed[0] = MISS
var mh := height_metrics(holed, n, sp)
var c4: bool = int(mh["misses"]) == 1
cases.append(["one miss -> misses 1", c4, "misses=%d" % mh["misses"]])
ok = ok and c4
# 5. Triangles: flat reads 0, vertical reads 90, degenerate is counted and
# does not pollute the statistics.
var tris := PackedVector3Array([
Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(0, 0, 1), # flat
Vector3(0, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, 1), # vertical
Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(2, 0, 0), # degenerate
])
var mt := triangle_metrics(tris, Vector3.UP)
var c5: bool = int(mt["triangles"]) == 2 and int(mt["degenerate"]) == 1 \
and absf(float(mt["max_incl_deg"]) - 90.0) < 0.01 and int(mt["over_60"]) == 1
cases.append(["flat+vertical+degenerate", c5,
"tris=%d degen=%d max=%.2f over60=%d" % [mt["triangles"], mt["degenerate"], mt["max_incl_deg"], mt["over_60"]]])
ok = ok and c5
# 6. THE SIGN. A ground triangle wound the way dual contouring winds ground
# must NOT read as an overhang, and a ceiling must. Added because the
# first three live reports called every flat terrain triangle an
# overhang and every check above still passed.
var ground := PackedVector3Array([Vector3(0, 0, 0), Vector3(1, 0, 0), Vector3(0, 0, 1)])
var ceil_t := PackedVector3Array([Vector3(0, 0, 0), Vector3(0, 0, 1), Vector3(1, 0, 0)])
var mg := triangle_metrics(ground, Vector3.UP)
var mc3 := triangle_metrics(ceil_t, Vector3.UP)
var c6: bool = int(mg["overhang"]) == 0 and int(mc3["overhang"]) == 1
cases.append(["ground up-facing, reversed one is an overhang", c6,
"ground_overhang=%d reversed_overhang=%d" % [mg["overhang"], mc3["overhang"]]])
ok = ok and c6
return {"pass": ok, "cases": cases}
# ---------------------------------------------------------------------------
# LIVE measurement
# ---------------------------------------------------------------------------
## B — the surface the player actually collides with, by raycast.
static func walked_surface(space: PhysicsDirectSpaceState3D, center: Vector3,
n: int, spacing: float, up: Vector3, exclude: Array) -> PackedFloat32Array:
var h := PackedFloat32Array()
h.resize(n * n)
var half := float(n - 1) * spacing * 0.5
# A basis for the horizontal grid that does not assume up is +Y.
var ax := up.cross(Vector3(0, 0, 1))
if ax.length_squared() < 1e-6:
ax = up.cross(Vector3(1, 0, 0))
ax = ax.normalized()
var az := up.cross(ax).normalized()
for r in range(n):
for c in range(n):
var p := center + ax * (float(c) * spacing - half) + az * (float(r) * spacing - half)
# +6 / -20 m, not +40. The ray starts above the PROBE CENTRE, not
# above each column's own ground, so on a slope the up-hill columns
# sit higher than the centre: over a 4 m half-width, 6 m of headroom
# covers ground up to about 56 degrees. Starting far higher instead
# would catch a ledge or an overhang metres above the player and
# report it as "the ground here", which is a wrong answer rather
# than a missing one.
var q := PhysicsRayQueryParameters3D.create(p + up * 6.0, p - up * 20.0)
q.exclude = exclude
q.collide_with_areas = false
var hit := space.intersect_ray(q)
h[r * n + c] = (hit["position"] as Vector3).dot(up) if hit.has("position") else MISS
return h
## D/E — the GPU density field's own surface, at a chosen voxel size.
## Returns heights along `up` on the same n*n footprint, MISS where the column
## has no sign change. voxel is the DENSITY sample spacing, and it matters:
## the shader's octave clamping and heightfield mip both read it, so sampling
## at 0.25 m does not reproduce what a 1 m-voxel chunk was meshed from.
static func field_surface(terrain: Node, center: Vector3, n: int, spacing: float,
voxel: float, up: Vector3, vertical_window: float = 60.0) -> Dictionary:
var res := {"heights": PackedFloat32Array(), "n": 0, "spacing": 0.0, "voxel": voxel, "note": ""}
if terrain == null or not terrain.has_method("debug_gpu_density"):
res["note"] = "no debug_gpu_density on this build"
return res
if absf(up.dot(Vector3.UP)) < 0.99:
res["note"] = "field probe assumes world-up columns"
return res
# One cubic grid covering the footprint horizontally and +/- half its span
# vertically. cells is chosen so cells*voxel covers the footprint.
# THE VERTICAL WINDOW IS THE MEASUREMENT, and tying it to the horizontal
# footprint made it 8 m tall -- so a field whose surface sits a few metres
# from the mesh reported "no sign change" for 94% of its columns instead of
# reporting the offset, which is the finding. The grid is cubic, so the
# footprint follows the window rather than the other way round.
var cells := int(ceil(vertical_window / voxel))
if cells < 4: cells = 4
if cells > 96: cells = 96 # 97^3 floats is already 3.6 MB
var span := float(cells) * voxel
var origin := Vector3(center.x - span * 0.5, center.y - span * 0.5, center.z - span * 0.5)
var data: PackedFloat32Array = terrain.call("debug_gpu_density", origin, voxel, cells)
if data.is_empty():
res["note"] = "the GPU declined the density request -- that IS the finding"
return res
var stride := cells + 1
if data.size() < stride * stride * stride:
res["note"] = "short grid: %d floats, expected %d" % [data.size(), stride * stride * stride]
return res
var out := PackedFloat32Array()
out.resize(stride * stride)
# THE LAYOUT IS data[z*stride*stride + y*stride + x] -- DensityGrid::fill,
# dc_density_grid.h:53. Walking [y*stride*stride + z*stride + x] instead
# steps along Z at the player's own height, and near the player that crosses
# the surface right underneath him: the probe then reports approximately the
# player's own altitude whatever the terrain does, which is an instrument
# that cannot fail. It read -0.077 m of offset that way.
for zi in range(stride):
for xi in range(stride):
var found := MISS
# Walk DOWN from the top: the first crossing from air (negative)
# into solid (positive) is the surface, and taking the topmost one
# is what a downward raycast does too.
var prev: float = data[zi * stride * stride + (stride - 1) * stride + xi]
for yi in range(stride - 2, -1, -1):
var d: float = data[zi * stride * stride + yi * stride + xi]
if (prev < 0.0) != (d < 0.0):
var y0 := origin.y + float(yi) * voxel
var y1 := y0 + voxel
var t := 0.5
if absf(d - prev) > 1e-12:
t = (0.0 - d) / (prev - d)
found = y0 + clampf(t, 0.0, 1.0) * (y1 - y0)
break
prev = d
out[zi * stride + xi] = found
# ORIENTATION CHECK. The centre column must be AIR at the top of the window
# and SOLID at the bottom -- that is what a vertical column through ground
# means, and a mis-indexed walk (along Z, say) has no reason to satisfy it.
# Reported in the report so a wrong axis shows up as a failed check rather
# than as plausible numbers.
var mid := stride / 2
var top: float = data[mid * stride * stride + (stride - 1) * stride + mid]
var bot: float = data[mid * stride * stride + 0 * stride + mid]
res["orient_top"] = top
res["orient_bot"] = bot
res["orient_ok"] = top < 0.0 and bot > 0.0
res["heights"] = out
res["n"] = stride
res["spacing"] = voxel
res["origin"] = origin
return res
# ---------------------------------------------------------------------------
# THE REPORT
# ---------------------------------------------------------------------------
## Run every measurement at `center` and return the formatted report.
## `terrain` may be null (B and C still work; D and E say so).
static func run(terrain: Node, space: PhysicsDirectSpaceState3D, center: Vector3,
up: Vector3, exclude: Array, footprint: float = 8.0, spacing: float = 0.25,
player: Node = null) -> String:
var L: Array[String] = []
var n := int(round(footprint / spacing)) + 1
if n < 3: n = 3
if n > 65: n = 65
L.append("=== TERRAIN GROUND PROBE ===")
L.append("at %s up %s footprint %.1f m @ %.2f m (%dx%d)"
% [center, up, footprint, spacing, n, n])
# NAME THE ARTIFACT. A report that does not say which binary produced it can
# be a report about code that was never loaded.
if terrain != null and terrain.has_method("build_stamp"):
L.append("binary: %s" % terrain.call("build_stamp"))
else:
L.append("binary: OLDER THAN THIS PROBE -- no build_stamp(); rebuild dc-terrain")
# --- the instrument, first ---
var st := selftest()
L.append("")
L.append("SELFTEST: %s" % ("PASS" if st["pass"] else "*** FAIL -- NOTHING BELOW IS EVIDENCE ***"))
for c in st["cases"]:
L.append(" %s %s (%s)" % ["ok " if c[1] else "FAIL", c[0], c[2]])
# --- A: the chunk ---
L.append("")
L.append("A. CHUNK UNDER THE POINT")
if terrain != null and terrain.has_method("debug_chunk_info"):
var info: Dictionary = terrain.call("debug_chunk_info", center)
if info.is_empty():
L.append(" no leaf contains this point (not 'LOD 0' -- no chunk at all)")
else:
L.append(" lod=%d voxel=%.3f m chunk=%.1f m tris=%d state=%d"
% [info["lod"], info["voxel_size"], info["chunk_size"], info["triangles"], info["state"]])
L.append(" collision: body=%s eligible=%s parent_collision_stale=%s"
% [info["has_collision"], info["collision_eligible"], info["parent_collision_stale"]])
if terrain != null and terrain.has_method("get_edit_count"):
var ec: int = terrain.call("get_edit_count")
var ms := -1
var ci3: Dictionary = terrain.call("debug_chunk_info", center) if terrain.has_method("debug_chunk_info") else {}
if ci3.has("mesh_state"):
ms = int(ci3["mesh_state"])
L.append(" CSG: terrain holds %d edit(s); this chunk's mesh_state=%d (0 never, 1 pending, 2 generating, 3 up-to-date, 4 NEEDS UPDATE)"
% [ec, ms])
if ec == 0:
L.append(" zero edits means the carve never reached DCTerrain at all -- game side, not shader side")
if terrain != null and "collision_distance" in terrain:
L.append(" collision is built within %.0f m of the camera, lod <= %s"
% [float(terrain.collision_distance),
str(terrain.collision_lod_max) if "collision_lod_max" in terrain else "?"])
# Neighbours, to catch an LOD boundary within walking distance.
var seen := {}
for d in [Vector3(32, 0, 0), Vector3(-32, 0, 0), Vector3(0, 0, 32), Vector3(0, 0, -32),
Vector3(96, 0, 0), Vector3(-96, 0, 0), Vector3(0, 0, 96), Vector3(0, 0, -96)]:
var ni: Dictionary = terrain.call("debug_chunk_info", center + d)
var k: String = "none" if ni.is_empty() else "lod%d/col=%s" % [ni["lod"], ni["has_collision"]]
seen[k] = int(seen.get(k, 0)) + 1
L.append(" within 96 m: %s" % str(seen))
else:
L.append(" (this build has no debug_chunk_info -- rebuild dc-terrain)")
# --- B: the walked surface ---
L.append("")
L.append("B. THE SURFACE THE PLAYER COLLIDES WITH (raycast grid)")
var hb := walked_surface(space, center, n, spacing, up, exclude)
var mb := height_metrics(hb, n, spacing)
L.append(" hits %d/%d misses %d (holes in collision)"
% [int(mb["samples"]) - int(mb["misses"]), mb["samples"], mb["misses"]])
L.append(" relief %.3f m plane slope %.2f deg ROUGHNESS (rms off best-fit plane) %.4f m"
% [mb["relief"], mb.get("plane_slope_deg", 0.0), mb["plane_rms"]])
L.append(" step between %.2f m samples: max %.4f m p95 %.4f m mean %.4f m (worst at %s)"
% [spacing, mb["max_step"], mb["p95_step"], mb["mean_step"], mb["worst_at"]])
L.append(" WALLS: %d/%d neighbour pairs steeper than 60 deg (floor_max_angle), %d over 45"
% [mb["steeper_than_60"], mb["pairs"], mb["steeper_than_45"]])
# --- C: the collision triangles ---
L.append("")
L.append("C. THE COLLISION TRIANGLES THEMSELVES")
if terrain != null and terrain.has_method("debug_collision_faces"):
var faces: PackedVector3Array = terrain.call("debug_collision_faces", center, footprint * 0.5)
if faces.is_empty():
L.append(" no collision faces within %.1f m -- the ground here has no trimesh body"
% (footprint * 0.5))
else:
var mc := triangle_metrics(faces, up)
L.append(" %d triangles (%d degenerate, %d overhanging), area %.1f m2"
% [mc["triangles"], mc["degenerate"], mc["overhang"], mc["area"]])
L.append(" inclination: mean %.1f deg max %.1f deg over45 %d over60 %d (%.1f%% by area)"
% [mc["mean_incl_deg"], mc["max_incl_deg"], mc["over_45"], mc["over_60"],
100.0 * float(mc["area_over_60"]) / maxf(float(mc["area"]), 1e-9)])
var hist: PackedInt32Array = mc["hist_deg"]
L.append(" histogram 0-15:%d 15-30:%d 30-45:%d 45-60:%d 60-80:%d 80+:%d"
% [hist[0], hist[1], hist[2], hist[3], hist[4], hist[5]])
else:
L.append(" (this build has no debug_collision_faces -- rebuild dc-terrain)")
# --- C2: is the collision where the terrain is DRAWN? ---
L.append("")
L.append("C2. VISUAL MESH vs COLLISION MESH")
if terrain != null and terrain.has_method("debug_visual_faces"):
var vfaces: PackedVector3Array = terrain.call("debug_visual_faces", center, footprint * 0.5)
var cfaces: PackedVector3Array = terrain.call("debug_collision_faces", center, footprint * 0.5)
if vfaces.is_empty():
L.append(" no visual triangles here")
else:
var mv := triangle_metrics(vfaces, up)
L.append(" visual %d triangles (mean %.1f deg), collision %d triangles"
% [mv["triangles"], mv["mean_incl_deg"], cfaces.size() / 3])
# The strong test: every collision vertex should BE a visual vertex.
# They are built from the same array, so a mismatch is either a
# different transform or a different mesh generation.
var worst := 0.0
var checked := 0
var step: int = maxi(1, cfaces.size() / 90)
for i in range(0, cfaces.size(), step):
var cp := cfaces[i]
var best := 1e30
for j in range(vfaces.size()):
var dd: float = cp.distance_squared_to(vfaces[j])
if dd < best:
best = dd
if best < 1e-12:
break
checked += 1
var dist: float = sqrt(best)
if dist > worst:
worst = dist
L.append(" %d collision vertices sampled: worst distance to the nearest VISUAL vertex = %.6f m"
% [checked, worst])
L.append(" 0 means they are literally the same points. Anything else is a real offset.")
else:
L.append(" (this build has no debug_visual_faces -- rebuild dc-terrain)")
# --- D/E: the field the mesher saw, and how it moves with LOD ---
L.append("")
L.append("D. THE GPU DENSITY FIELD, at the chunk's own voxel size")
var voxel := 1.0
if terrain != null and terrain.has_method("debug_chunk_info"):
var ci: Dictionary = terrain.call("debug_chunk_info", center)
if not ci.is_empty():
voxel = float(ci["voxel_size"])
var fd := field_surface(terrain, center, n, spacing, voxel, up)
if fd["n"] == 0:
L.append(" %s" % fd["note"])
else:
var md := height_metrics(fd["heights"], int(fd["n"]), float(fd["spacing"]))
var fhits: int = int(md["samples"]) - int(md["misses"])
L.append(" column orientation: top of window %.2f (want < 0 = air), bottom %.2f (want > 0 = solid) -> %s"
% [fd.get("orient_top", 0.0), fd.get("orient_bot", 0.0),
"OK" if bool(fd.get("orient_ok", false)) else "*** WRONG AXIS OR NO GROUND HERE ***"])
L.append(" voxel %.3f m, %dx%d columns over a %.0f m vertical window: %d found a surface, %d did not"
% [fd["spacing"], fd["n"], fd["n"], float(fd["spacing"]) * float(int(fd["n"]) - 1),
fhits, md["misses"]])
if bool(md.get("undersampled", false)):
L.append(" TOO FEW COLUMNS CROSSED ZERO TO CHARACTERISE THE FIELD (%d of %d)."
% [fhits, md["samples"]])
L.append(" That is itself the finding: the density this build evaluates has no")
L.append(" surface near the ground the player is standing on. Check the offset below.")
else:
L.append(" relief %.3f m plane slope %.2f deg ROUGHNESS %.4f m max step %.4f m"
% [md["relief"], md.get("plane_slope_deg", 0.0), md["plane_rms"], md["max_step"]])
L.append(" READ THIS AGAINST B: field smooth + B rough => the MESHER makes the bumps.")
L.append(" field rough => the GENERATOR makes them.")
# THE NUMBER THAT MATTERS: is the field where the collision is? The mesh
# is built FROM this field, so anything but ~0 means the two halves of
# the pipeline disagree about where the ground is.
var fc0 := _centre_value(fd["heights"], int(fd["n"]))
var rc0 := _centre_value(hb, n)
if fc0 < MISS and rc0 < MISS:
L.append(" FIELD vs COLLISION at the centre: field %.3f m, collision %.3f m -> OFFSET %+.3f m"
% [fc0, rc0, fc0 - rc0])
elif rc0 >= MISS:
L.append(" FIELD vs COLLISION: no collision under the centre sample")
else:
L.append(" FIELD vs COLLISION: the field has no surface in the centre column at all")
L.append("")
L.append("E. THE SAME FIELD ONE LOD COARSER (the step at an LOD boundary)")
var fc := field_surface(terrain, center, n, spacing, voxel * 2.0, up)
if fc["n"] == 0:
L.append(" %s" % fc["note"])
else:
var mc2 := height_metrics(fc["heights"], int(fc["n"]), float(fc["spacing"]))
L.append(" voxel %.3f m: relief %.3f m roughness %.4f m max step %.4f m"
% [fc["spacing"], mc2["relief"], mc2["plane_rms"], mc2["max_step"]])
# Compare the two at their common centre column.
var af := _centre_value(fd["heights"], int(fd["n"]))
var ac := _centre_value(fc["heights"], int(fc["n"]))
if af < MISS and ac < MISS:
L.append(" surface at the centre: %.4f m at voxel %.2f vs %.4f m at voxel %.2f -> SHIFT %.4f m"
% [af, fd["spacing"], ac, fc["spacing"], ac - af])
L.append(" That shift is what a player walks into where those two LODs meet.")
# --- G: holes. Chunks that are finished and empty where ground should be ---
L.append("")
L.append("G. HOLES — a coarse ring scan for ground that is not there")
var holes := 0
var probed := 0
var hole_states := {}
# INSIDE THE COLLISION RADIUS ONLY. Collision is built for chunks within
# collision_distance of the camera (100 m by default) and for lod <=
# collision_lod_max, so a ring at 256 m finds no ground BY DESIGN and
# reporting that as a hole is the instrument inventing a defect -- it
# reported 22 of 60 on a map with no visible holes at all.
var col_dist := 100.0
if terrain != null and "collision_distance" in terrain:
col_dist = float(terrain.collision_distance)
var radii := [col_dist * 0.15, col_dist * 0.3, col_dist * 0.5, col_dist * 0.75]
L.append(" ring radii inside collision_distance %.0f m: %s" % [col_dist, str(radii)])
for rad in radii:
for k in range(12):
var ang := TAU * float(k) / 12.0
var pw := center + Vector3(cos(ang), 0.0, sin(ang)) * float(rad)
probed += 1
# Straight down from well above: 400 m covers the relief a ring of
# this size can climb, so a miss is a MISSING SURFACE and not a ray
# that started underground.
var qq := PhysicsRayQueryParameters3D.create(pw + up * 400.0, pw - up * 400.0)
qq.exclude = exclude
qq.collide_with_areas = false
var hh := space.intersect_ray(qq)
if hh.has("position"):
continue
holes += 1
# WHAT the streamer thinks is there. A leaf that is ACTIVE (state 4)
# with zero triangles is a chunk that finished and produced nothing --
# a hole the mesher believes in. PENDING/GENERATING is just streaming
# and will fill in. No leaf at all is a third thing again.
var ci2: Dictionary = {}
if terrain != null and terrain.has_method("debug_chunk_info"):
ci2 = terrain.call("debug_chunk_info", pw)
# AT THE PLAYER'S ALTITUDE, which on a slope is AIR a hundred metres
# away -- an air chunk is legitimately ACTIVE with zero triangles and
# must not be read as "meshed to nothing". So walk the column and ask
# whether ANY leaf above or below carries triangles.
var column_has_geometry := false
for dy in [-64.0, -32.0, -16.0, 0.0, 16.0, 32.0, 64.0]:
var ck: Dictionary = {}
if terrain != null and terrain.has_method("debug_chunk_info"):
ck = terrain.call("debug_chunk_info", pw + up * dy)
if not ck.is_empty() and int(ck["triangles"]) > 0:
column_has_geometry = true
break
var key: String = "no leaf"
if not ci2.is_empty():
key = "lod%d state=%d tris=%d %s" % [ci2["lod"], ci2["state"], ci2["triangles"],
("column has geometry elsewhere" if column_has_geometry
else "COLUMN EMPTY TOP TO BOTTOM")]
hole_states[key] = int(hole_states.get(key, 0)) + 1
L.append(" %d of %d ring samples (16-256 m) found NO ground under them" % [holes, probed])
if holes > 0:
L.append(" what the streamer says is there: %s" % str(hole_states))
L.append(" state 4 = ACTIVE (finished). ACTIVE with tris=0 is a chunk that meshed to nothing.")
else:
L.append(" (this only sees holes a vertical ray can find; a hole beside you may need a wider ring)")
# --- I: every collision body actually here, not just the ones I can name ---
L.append("")
L.append("I. THE COLLISION BODIES ACTUALLY OVERLAPPING YOU")
L.append(" debug_collision_faces walks get_leaves(). A chunk that has SPLIT is no")
L.append(" longer a leaf, but _on_mesh_free keeps a parent's collision body alive")
L.append(" while its children mesh -- so a stale coarse surface can sit here and be")
L.append(" invisible to section C. This asks the physics server instead.")
var shape := SphereShape3D.new()
shape.radius = 2.0
var sq := PhysicsShapeQueryParameters3D.new()
sq.shape = shape
sq.transform = Transform3D(Basis(), center)
sq.collide_with_areas = false
sq.collide_with_bodies = true
sq.exclude = exclude
var hits: Array = space.intersect_shape(sq, 32)
var bodies := {}
for h in hits:
var rid_s: String = str(h.get("rid", RID()))
bodies[rid_s] = int(bodies.get(rid_s, 0)) + 1
L.append(" %d shape overlaps within 2.0 m, across %d DISTINCT bodies"
% [hits.size(), bodies.size()])
if bodies.size() > 1:
L.append(" *** MORE THAN ONE BODY OVERLAPS YOU. Terrain collision is one body per")
L.append(" *** chunk, so overlapping bodies here means two surfaces at this spot.")
# Is there terrain ABOVE you? On ground with no overhanging triangle there
# should be nothing, and a hit is a stale surface by construction.
var upq := PhysicsRayQueryParameters3D.create(center + up * 0.1, center + up * 6.0)
upq.exclude = exclude
upq.collide_with_areas = false
var uphit := space.intersect_ray(upq)
if uphit.has("position"):
var uh: Vector3 = uphit["position"]
var un: Vector3 = uphit.get("normal", Vector3.ZERO)
L.append(" CEILING FOUND %.3f m above you, normal %.1f deg from up"
% [(uh - center).dot(up), rad_to_deg(acos(clampf(un.dot(up), -1.0, 1.0)))])
L.append(" *** On ground whose triangles are all shallow, a surface overhead is a")
L.append(" *** collision body that should have been freed.")
else:
L.append(" nothing overhead within 6 m (so no stale surface directly above)")
# --- H: how well did you move, with no definition of "badly" ---
L.append("")
L.append("H. HOW WELL YOU ACTUALLY MOVED (last 10 s, every frame, no threshold)")
if player != null and player.has_method("motion_summary"):
var mo: Dictionary = player.call("motion_summary", 10000)
if int(mo["frames"]) == 0:
L.append(" no frames with movement input in the window -- walk, THEN press")
else:
L.append(" TRAVEL EFFICIENCY %.1f%% (%.2f m delivered of %.2f m asked, over %d frames)"
% [100.0 * float(mo["efficiency"]), mo["got_total"], mo["want_total"], mo["frames"]])
L.append(" per-frame delivery: p10 %.2f median %.2f p90 %.2f" % [mo["p10"], mo["p50"], mo["p90"]])
L.append(" a low p10 with a healthy median is STUTTER; a uniformly low median is DRAG")
L.append(" on the floor %.1f%% of frames, losing/regaining it %d times <- flapping is bouncing"
% [mo["on_floor_pct"], mo["floor_transitions"]])
L.append(" mean contacts %.2f vertical velocity range %.2f .. %.2f m/s mean ground angle %.1f deg"
% [mo["mean_contacts"], mo["min_vy"], mo["max_vy"], mo["mean_ground_deg"]])
L.append(" THE CONTROLLER'S OWN TERMS: input %.2f desired %.2f m/s traction mean %.3f min %.3f"
% [mo.get("mean_input", 0.0), mo.get("mean_desired", 0.0),
mo.get("mean_traction", 0.0), mo.get("min_traction", 0.0)])
L.append(" surface_friction min %.2f ski held on %d frames"
% [mo.get("min_friction", 0.0), mo.get("ski_frames", 0)])
L.append(" _update_movement did NOT run on %d frames (flipping on %d) —"
% [mo.get("stale_frames", 0), mo.get("flip_frames", 0)])
L.append(" on those, every term above is a STALE value from an earlier frame")
L.append(" traction near 0 means the acceleration BUDGET is zero: the character")
L.append(" cannot push, whatever the ground is doing. input 0 means no key was down.")
L.append(" WITH A KEY ACTUALLY DOWN: %d frames, efficiency %.1f%%, mean contacts %.2f,"
% [mo.get("trying_frames", 0), 100.0 * float(mo.get("trying_eff", 0.0)),
mo.get("trying_contacts", 0.0)])
L.append(" deepest penetration %.4f m, steepest contact %.1f deg"
% [mo.get("trying_depth", 0.0), mo.get("trying_worst_deg", -1.0)])
L.append(" THIS is the number that separates being pinned from having let go:")
L.append(" want comes from velocity, so a released key and a stuck character")
L.append(" look identical without it.")
var wf: Dictionary = mo.get("worst_frame", {})
if not wf.is_empty():
L.append(" THE WORST SINGLE FRAME (wanted %.4f m, got %.4f m, %d contacts):"
% [wf.get("want", 0.0), wf.get("got", 0.0), wf.get("contacts", 0)])
var gn: Vector3 = wf.get("gnormal", Vector3.UP)
L.append(" ground_normal at that moment: %s" % str(gn))
for ci in (wf.get("contact_info", []) as Array):
var cn: Vector3 = ci.get("n", Vector3.ZERO)
# A contact normal that is the NEGATIVE of the floor normal is
# the same triangle hit from behind -- a backface, not a
# surface. Distinguishing the two is the whole question: 163
# to 168 degrees on 15 degree ground is 180 minus the slope,
# which is what an inverted normal looks like and what a real
# overhang does not.
var inv: float = cn.dot(-gn)
L.append(" %6.1f deg depth %.5f m at %s n=%s dot(-ground)=%+.3f%s <- %s"
% [ci.get("deg", -1.0), ci.get("depth", 0.0), str(ci.get("at", Vector3.ZERO)),
str(cn), inv, " BACKFACE" if inv > 0.98 else "", ci.get("who", "?")])
if (wf.get("contact_info", []) as Array).is_empty():
L.append(" no contacts recorded on that frame — it was not collision")
L.append(" COMPARE THIS NUMBER BETWEEN MAPS. It does not care whether the loss is")
L.append(" stopping, stuttering, sliding or bouncing, which is why it replaced F.")
else:
L.append(" this player has no motion trace")
# --- F: what actually stopped you, from the player's own recorder ---
L.append("")
L.append("F. FRAMES WHERE YOU ASKED TO MOVE AND DID NOT (last 10 s)")
if player != null and player.has_method("block_summary"):
var bs: Dictionary = player.call("block_summary", 10000)
L.append(" %d blocked frames in the window (%d recorded in total)"
% [bs["events_window"], bs["events_total"]])
if int(bs["events_window"]) > 0:
L.append(" mean progress %.1f%% of what was asked full stops %d blocked by a wall-angle face %d"
% [100.0 * float(bs["mean_frac"]), bs["full_stops"], bs["wall_blocked"]])
L.append(" steepest contact normal %.1f deg (floor_max_angle is %.1f deg -- above it Godot calls it a WALL)"
% [bs["worst_deg"], bs["floor_max_angle_deg"]])
var last: Dictionary = bs["last"]
L.append(" deepest penetration in the window: %.4f m (a slope you cannot climb has ~0; being pushed out of geometry does not)"
% float(bs.get("max_depth", 0.0)))
L.append(" most recent: at %s wanted %.3f m, got %.3f m, %d contacts, worst %.1f deg, depth %.4f m, on_floor=%s"
% [last["pos"], last["want"], last["got"], last["collisions"], last["worst_deg"],
float(last.get("depth", 0.0)), last["on_floor"]])
L.append(" touching: %s" % str(last.get("colliders", [])))
if terrain != null and terrain.has_method("debug_chunk_info"):
var li: Dictionary = terrain.call("debug_chunk_info", last["pos"])
if not li.is_empty():
L.append(" that spot was chunk lod=%d voxel=%.3f m collision=%s"
% [li["lod"], li["voxel_size"], li["has_collision"]])
elif player == null:
L.append(" no local player (probing a camera position)")
else:
L.append(" this player script has no block recorder")
L.append("")
L.append("=== end ===")
return "\n".join(PackedStringArray(L))
static func _centre_value(h: PackedFloat32Array, n: int) -> float:
if n <= 0 or h.size() < n * n:
return MISS
var m := n / 2
return h[m * n + m]
## Write the report where it will survive: print() is dropped by dev-run's
## pipeline (hyper-games #51 section 2.2) and Godot rotates its own logs after
## five runs, so the file is the record and the print is only a pointer to it.
static func write_report(text: String) -> String:
var dir := "user://probe"
DirAccess.make_dir_recursive_absolute(dir)
var path := "%s/terrain_probe_%s.txt" % [dir, Time.get_datetime_string_from_system().replace(":", "-")]
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
push_warning("[TerrainProbe] cannot write %s" % path)
return ""
f.store_string(text)
f.flush()
f.close()
return ProjectSettings.globalize_path(path)