fix(client): bottom-wall scan pierces 2-thick walls + corner diagonal; whole-box quantized cuts (T-1088)

Live-session findings, three in one: (1) Gauntlet walls are 2 tiles thick —
the outer row's camera-far neighbor is the inner WALL, so its flag stayed 0
and it stood full-height behind the collapsed inner stub; the flag now scans
up to 3 tiles through consecutive KNOWN walls (unknown stops the scan — no
assumptions past the info boundary), and any newly-learned tile refreshes the
wall chain behind it. (2) Corner pillars at the screen-bottom junction never
collapsed — their interior floor sits DIAGONALLY behind, which no straight
scan line reaches; the combined diagonal joins the scan set. (3) Per-fragment
smoothstep falloff carved organic notches (the 'sphere' read) — the cut now
computes per-instance in the vertex shader from the box's tile center and
quantizes at 0.5: every wall box is either full or stub, X2-crisp.

Verified via locomotion_cutaway autopilot capture: south runs read as clean
stub rows, corners collapse, camera-far walls stand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 20:10:28 +02:00
co-authored by Claude Fable 5
parent ae04091f93
commit 6372537f2f
2 changed files with 47 additions and 21 deletions
+28 -7
View File
@@ -155,6 +155,10 @@ var _free_slots: Array = [[], []]
var _state_colors: Array[Color] = []
var _last_tick: int = -1
var _capacity_warned := false
## Bottom-wall scan depth (tiles) along each camera-far direction — covers the
## Gauntlet's 2-thick walls plus door trim.
const BOTTOM_SCAN_DEPTH := 3
## Cutaway mode: 0 = sightline corridor, 1 = bottom walls low, 2 = both.
var cutaway_mode: int = 0
## Camera-far sim-neighbor deltas for the bottom-wall rule (see
@@ -284,10 +288,13 @@ func _paint_new(key: Vector3i) -> void:
_slots[key] = Vector2i(kind, slot)
if kind == Store.Kind.WALL:
_refresh_bottom_flag(key)
else:
# A newly-learned floor can promote camera-ward walls to bottom walls.
for d in _behind_dirs:
_refresh_bottom_flag(key - d)
# Any newly-learned tile can promote camera-ward walls up the scan chain:
# a floor is the promotion itself; a wall can EXTEND a chain that previously
# stopped at unknown (Gauntlet walls are 2 tiles thick — the outer row sees
# floor only THROUGH the inner row).
for d in _behind_dirs:
for step in range(1, BOTTOM_SCAN_DEPTH + 1):
_refresh_bottom_flag(key - d * step)
## Cutaway mode (design amendment, live session 2026-07-06): 0 = sightline
@@ -322,6 +329,11 @@ func _compute_behind_dirs() -> void:
# hides lies OPPOSITE that face -> behind_dir = -d for camera-ward d.
if world_dir.z > 0.01:
_behind_dirs.append(-d)
# Corner pillars: at a wall-run junction the interior floor sits DIAGONALLY
# behind (neither straight scan line reaches it) — include the combined
# diagonal in the scan set.
if _behind_dirs.size() == 2:
_behind_dirs.append(_behind_dirs[0] + _behind_dirs[1])
# Repaint flags for walls painted before the deferred computation ran.
for key: Vector3i in _slots:
if (_slots[key] as Vector2i).x == Store.Kind.WALL:
@@ -332,11 +344,20 @@ func _refresh_bottom_flag(key: Vector3i) -> void:
var slot: Vector2i = _slots.get(key, Vector2i(-1, -1))
if slot.y < 0 or slot.x != Store.Kind.WALL:
return
# Scan up to BOTTOM_SCAN_DEPTH tiles along each camera-far direction,
# walking THROUGH consecutive known walls (Gauntlet walls are 2 thick):
# a known non-wall tile behind the wall run marks the whole run "bottom".
# Unknown tiles stop the scan — no assumptions past the info boundary.
var flag := 0.0
for d in _behind_dirs:
var n: Vector3i = key + d
if store.has_tile(n) and store.kind_of(n) != Store.Kind.WALL:
flag = 1.0
for step in range(1, BOTTOM_SCAN_DEPTH + 1):
var n: Vector3i = key + d * step
if not store.has_tile(n):
break
if store.kind_of(n) != Store.Kind.WALL:
flag = 1.0
break
if flag > 0.0:
break
_wall_mm.multimesh.set_instance_custom_data(slot.y, Color(flag, 0.0, 0.0, 0.0))
@@ -38,19 +38,17 @@ uniform int u_cutaway_mode = 0;
uniform vec3 u_base_albedo : source_color = vec3(0.66, 0.64, 0.60);
varying vec3 v_world_pos;
// Bottom-wall flag (INSTANCE_CUSTOM.r, set by GreyboxWorld): 1.0 when a KNOWN
// non-wall tile sits on this wall's camera-far side — the wall would occlude
// interior the player should see, X2-style.
varying flat float v_bottom;
// Whole-box cut decision, computed per-INSTANCE in vertex() from the box's tile
// center — every wall box is either full height or the stub, no per-fragment
// falloff curves carving organic notches (the "sphere" read). X2-crisp tile
// cuts; the smoothstep bands only shape where the 0.5 threshold falls.
varying flat float v_cut;
void vertex() {
// MODEL_MATRIX folds in the per-instance MultiMesh transform.
v_world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
v_bottom = INSTANCE_CUSTOM.r;
}
void fragment() {
vec2 to_wall = v_world_pos.xz - u_char_pos_xz;
// MODEL_MATRIX folds in the per-instance MultiMesh transform; its origin
// is the box center — .xz is the wall tile's center in world space.
vec2 inst_xz = (MODEL_MATRIX * vec4(0.0, 0.0, 0.0, 1.0)).xz;
vec2 to_wall = inst_xz - u_char_pos_xz;
// Lateral gate: full cut inside (half_w - band), fading to none at half_w.
float lateral = 1.0 - smoothstep(u_corridor_half_w - u_cut_band, u_corridor_half_w, abs(to_wall.x));
// Near gate: soft start just behind the character (no hard seam at their feet).
@@ -58,13 +56,20 @@ void fragment() {
// Far gate: corridor ends where a WALL_H wall can no longer occlude.
float far_f = 1.0 - smoothstep(u_cut_reach, u_cut_reach + u_cut_band, to_wall.y);
float corridor_f = lateral * near_f * far_f;
// Bottom-wall flag (INSTANCE_CUSTOM.r, GreyboxWorld): KNOWN non-wall tile
// behind this wall run — it would occlude interior the player should see.
float cut_f = corridor_f;
if (u_cutaway_mode == 1) {
cut_f = v_bottom;
cut_f = INSTANCE_CUSTOM.r;
} else if (u_cutaway_mode == 2) {
cut_f = max(corridor_f, v_bottom);
cut_f = max(corridor_f, INSTANCE_CUSTOM.r);
}
float allowed = mix(u_wall_h, u_cut_height, cut_f);
v_cut = cut_f > 0.5 ? 1.0 : 0.0;
v_world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
void fragment() {
float allowed = mix(u_wall_h, u_cut_height, v_cut);
if (v_world_pos.y > allowed) {
discard;
}