fix(client): cutaway cuts the camera-character sightline corridor, not a proximity dome (T-1088)

Live-session finding: the half-disc cut bit nearby side walls while leaving
the wall actually occluding the character at full height, with dome-contour
scallops across the two-row walls. Rewritten as a sightline corridor: walls
within CUT_CORRIDOR_HALF_W of the character's x, camera-side, out to a
pitch-derived reach (WALL_H / tan(pitch) — how far a wall can still occlude
in ortho; ~4.3 m at -30, breathes with the T-cycle tilt) drop to the stub.
FollowCamera3D exposes pitch_deg(); reach clamped 0.5-16 m.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 19:01:49 +02:00
co-authored by Claude Fable 5
parent b09a50efbf
commit 2db7dd25e1
7 changed files with 111 additions and 38 deletions
@@ -96,6 +96,12 @@ func _process(delta: float) -> void:
_apply_orbit()
## Current (smoothed) pitch in degrees from horizontal — consumed by the
## cutaway corridor reach (sandbox root pushes WALL_H / tan(pitch) per frame).
func pitch_deg() -> float:
return _pitch_deg
# Dev affordances only — there is deliberately NO yaw/rotation input path here.
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey:
+8 -5
View File
@@ -188,7 +188,7 @@ func _ready() -> void:
# the shader defaults mirror them only for standalone/editor use.
_wall_material.set_shader_parameter("u_wall_h", SandboxConstants.WALL_H)
_wall_material.set_shader_parameter("u_cut_height", SandboxConstants.CUT_HEIGHT)
_wall_material.set_shader_parameter("u_cut_radius", SandboxConstants.CUT_RADIUS)
_wall_material.set_shader_parameter("u_corridor_half_w", SandboxConstants.CUT_CORRIDOR_HALF_W)
_wall_material.set_shader_parameter("u_cut_band", SandboxConstants.CUT_BAND)
_wall_mm = _make_layer("WallMM", wall_mesh, _wall_material)
@@ -217,11 +217,14 @@ func get_store_size() -> int:
## Per-frame cutaway anchor (design §8): pass the rig's INTERPOLATED
## world-space position (PlayerRig.global_position — it already carries the
## 45 deg WorldRoot rotation, matching the shader's world-space test) so the cut
## zone glides with the character and spatial smoothstep becomes temporal
## smoothness. One uniform write per frame — the entire CPU cost.
func set_cutaway_char_pos(world_pos: Vector3) -> void:
## 45 deg WorldRoot rotation, matching the shader's world-space test) plus the
## pitch-derived corridor reach (WALL_H / tan(camera pitch) — how far south a
## WALL_H wall can still occlude the character in ortho). The cut corridor
## glides with the character and breathes with the tilt; two uniform writes
## per frame — the entire CPU cost.
func set_cutaway_char_pos(world_pos: Vector3, reach_m: float) -> void:
_wall_material.set_shader_parameter("u_char_pos_xz", Vector2(world_pos.x, world_pos.z))
_wall_material.set_shader_parameter("u_cut_reach", reach_m)
func _make_layer(layer_name: String, mesh: Mesh, material: ShaderMaterial) -> MultiMeshInstance3D:
+8 -1
View File
@@ -270,7 +270,14 @@ func _install_facing_provider() -> void:
func _per_frame_update(delta: float) -> void:
if not _first_snapshot_seen:
return
greybox.set_cutaway_char_pos(player_rig.global_position)
# Corridor reach: how far camera-side a WALL_H wall can still occlude the
# character in ortho at the current (smoothed) pitch. Clamped: the -5 deg
# frontal preset would otherwise ask for a ~29 m corridor.
var pitch_abs: float = maxf(absf(camera_rig.pitch_deg()), 5.0)
var reach: float = clampf(
SandboxConstants.WALL_H / tan(deg_to_rad(pitch_abs)), 0.5, 16.0
)
greybox.set_cutaway_char_pos(player_rig.global_position, reach)
_anim.update(delta)
+5 -4
View File
@@ -19,10 +19,11 @@ const SUBTILE_M := 0.5
const WALL_H := 2.5
## Cutaway stub height (m) camera-side walls drop to.
const CUT_HEIGHT := 0.75
## Cutaway half-disc radius (m) around the character.
const CUT_RADIUS := 5.0
## Cutaway smoothstep falloff band (m) at the disc edge.
const CUT_BAND := 1.5
## Cutaway sightline-corridor half-width (m) — character is 1 subtile wide;
## this covers them plus margin. Corridor length is pitch-derived per frame.
const CUT_CORRIDOR_HALF_W := 1.4
## Cutaway smoothstep falloff band (m) at the corridor edges.
const CUT_BAND := 0.75
# -- Locomotion interpolation (design §4) --------------------------------------
@@ -5,33 +5,34 @@ render_mode diffuse_lambert, specular_disabled;
// proto-production.
//
// COLOR-multiplied albedo (same four-state tint contract as the floor shader)
// plus a smoothstep height cut: a half-disc of camera-side wall around the
// character drops to a CUT_HEIGHT stub with smooth radial falloff — the
// Xenonauts read, minus per-building logic the greybox doesn't have. The
// cutaway cuts RENDER HEIGHT; the tint gates KNOWLEDGE — independent factors,
// so remembered walls can be cut too (the player's camera doesn't care what the
// character currently sees). Open box tops are acceptable greybox fidelity;
// ghost band / dither / capping are named polish on this same shader.
// plus a smoothstep height cut along the CAMERA->CHARACTER SIGHTLINE CORRIDOR:
// walls inside the corridor drop to a CUT_HEIGHT stub — the Xenonauts read,
// aligned to what actually occludes the character (the earlier proximity dome
// bit side walls while leaving the occluding wall standing). The cutaway cuts
// RENDER HEIGHT; the tint gates KNOWLEDGE — independent factors, so remembered
// walls can be cut too. Open box tops are acceptable greybox fidelity; ghost
// band / dither / capping are named polish on this same shader.
//
// u_char_pos_xz is the rig's INTERPOLATED world XZ, pushed once per frame
// (GreyboxWorld.set_cutaway_char_pos) — the cut zone glides with the character,
// so spatial smoothstep becomes temporal smoothness. Parked far away by default:
// no cut until the first push.
// Corridor geometry (world space — the camera has yaw 0 and looks world -Z;
// the diamond view is WorldRoot's 45 deg rotation, never the camera's, D-148):
// the ground projection of the camera->character sightline is the +Z half-line
// at the character's x. A wall occludes the character only within
// |to_wall.x| < corridor half-width AND 0 < to_wall.y < reach, where reach is
// pitch-derived (a WALL_H wall can occlude someone at most WALL_H / tan(pitch)
// away in ortho): ~4.3 m at the -30 deg gameplay preset, long at -5 frontal,
// near-zero at -80 overhead. u_char_pos_xz + u_cut_reach are pushed per frame
// (GreyboxWorld.set_cutaway_char_pos) so the corridor glides with the character
// and breathes with the tilt. Parked far away by default: no cut until pushed.
//
// Camera-side predicate: the camera has yaw 0 and looks world -Z (the diamond
// view is WorldRoot's 45 deg rotation, never the camera's — D-148), so camera
// ground-forward is the constant vec2(0, -1) in world space. A wall fragment
// sits between camera and character when to_wall.y > 0; the test runs in world
// space, so it stays correct under the WorldRoot rotation.
//
// Geometry uniforms default to the SandboxConstants values; GreyboxWorld pushes
// Static uniforms default to the SandboxConstants values; GreyboxWorld pushes
// them at material build so SandboxConstants stays the single source (§12).
uniform vec2 u_char_pos_xz = vec2(1000000.0, 1000000.0);
uniform float u_wall_h = 2.5; // SandboxConstants.WALL_H
uniform float u_cut_height = 0.75; // SandboxConstants.CUT_HEIGHT — the stub
uniform float u_cut_radius = 5.0; // SandboxConstants.CUT_RADIUS — half-disc radius
uniform float u_cut_band = 1.5; // SandboxConstants.CUT_BAND — radial falloff band
uniform float u_wall_h = 2.5; // SandboxConstants.WALL_H
uniform float u_cut_height = 0.75; // SandboxConstants.CUT_HEIGHT — the stub
uniform float u_corridor_half_w = 1.4; // SandboxConstants.CUT_CORRIDOR_HALF_W
uniform float u_cut_band = 0.75; // SandboxConstants.CUT_BAND — falloff band
uniform float u_cut_reach = 4.4; // per-frame: WALL_H / tan(camera pitch)
uniform vec3 u_base_albedo : source_color = vec3(0.66, 0.64, 0.60);
varying vec3 v_world_pos;
@@ -43,12 +44,13 @@ void vertex() {
void fragment() {
vec2 to_wall = v_world_pos.xz - u_char_pos_xz;
// Soft camera-side gate: fully cut from 0.75 m on the camera side of the
// character, fully solid from 0.25 m on the far side — no hard seam.
float side_f = smoothstep(-0.25, 0.75, to_wall.y);
// Radial falloff: full cut inside (radius - band), fading to none at radius.
float rad_f = 1.0 - smoothstep(u_cut_radius - u_cut_band, u_cut_radius, length(to_wall));
float allowed = mix(u_wall_h, u_cut_height, side_f * rad_f);
// 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).
float near_f = smoothstep(-0.25, 0.75, to_wall.y);
// 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 allowed = mix(u_wall_h, u_cut_height, lateral * near_f * far_f);
if (v_world_pos.y > allowed) {
discard;
}