Bowl Containment Fix — Dual-Mesh FloorObiSurface
Date: 2026-05-24
Type: Fix (regression)
Player impact: Cup form holds blood across the entire match again — the demo-blocker where the bowl went "convex" after the first floor cycle is gone. Blood also correctly falls through newly-opened floor holes.
What changed
FloorObiSurface is now two sibling GameObjects per non-emitter floor, both built at floor-generation time and registered in ObiColliderWorld from the start:
FloorObiSurface_Solid — mesh covers every non-real-gap cell. Active initially: MeshCollider.enabled = true, ObiCollider.Filter = -65535.
FloorObiSurface_Holed — same mesh with the floor's pre-randomized future-gap cells cut out. Dormant initially: MeshCollider.enabled = false, ObiCollider.Filter = 0.
When the floor leaves the Bottom role and opens its gaps (Floor.OpenGapsForMiddleRole → Floor.CommitGaps), the two flip in a single call (Floor.ActivateHoledSurface()): solid goes dormant, holed goes active. Pure flag toggle — no Mesh.Destroy, no MeshCollider.sharedMesh swap, no ObiCollider destroy, so neither triangle-mesh handle ever leaves Obi's ObiTriangleMeshContainer.
The Holed variant is only built for non-emitter floors (the emitter ceiling never transitions out of its role and so never opens gaps — it gets only the Solid surface).
FloorGenerator.RebuildObiSurfaceMesh is reduced to a no-op stub. The whole runtime-mesh-swap code path it represented is now bypassed by design.
Why
The bowl-loses-concavity regression was caused by FloorGenerator.RebuildObiSurfaceMesh swapping the MeshCollider.sharedMesh at runtime when a floor transitioned Bottom→Top. Obi's ObiMeshShapeTracker.UpdateIfNeeded detected the mismatch, called world.DestroyTriangleMesh(handle), which calls ObiTriangleMeshContainer.DestroyTriangleMesh — and that method RemoveRange's the destroyed mesh's BIH nodes / triangles / vertices out of the shared container, shifting the offsets of every handle whose firstTriangle was after the destroyed one's. In Obi 7.1.1's Compute backend, the resulting state visibly corrupted the player Bowl's triangle-mesh collision — a "phantom barrier" appeared in empty space above the bowl rim (blood bounced off as if hitting a dome), and the floor whose mesh had just been rebuilt also stopped passing blood through the new holes.
Three patches downstream of the swap were tried and ruled out: force-updating every other ObiCollider after the rebuild (shape.dataIndex was confirmed already auto-refreshed for dynamic colliders, so the patch was redundant); skipping the old Mesh.Destroy (no help — sharedMesh = alone still triggered the corruption); destroying + recreating the whole FloorObiSurface GameObject so DestroyCollider would set collidersToUpdateCount = colliderHandles.Count (no help). The corruption lives in the Compute backend's BIH/triangle buffer state — opaque to MCP inspection and not addressable via the public API.
An intermediate per-cell BoxCollider filler design also fixed the bowl bug but introduced cascading secondary problems (XZ-seam tunneling between filler box top and mesh top, O(N·M) perf collapse from per-filler GroundParticleKiller contact iterations, player snagging on filler bumps, PlayerBlobShadow Y-hopping). The dual-mesh redesign — user-suggested — bypasses all of these.
Files modified
Assets/ProtoV2/Scripts/FloorSystem/FloorGenerator.cs —
BuildSolidChunkMesh(floor, bool skipFutureGaps = true) parameterized; Solid variant calls with false, Holed variant with true.
AddFloorObiSurface rebuilt to create both surfaces via CreateFloorObiSurfaceGO(...) helper.
CreateFloorObiSurfaceGO new — owns MeshCollider.enabled and ObiCollider.Filter initial values per the active flag.
RebuildObiSurfaceMesh reduced to a no-op stub (kept so any external caller compiles).
AddFloorFillers deleted (the intermediate design's per-cell box filler creator).
GenerateFloor no longer calls AddFloorFillers.
Assets/ProtoV2/Scripts/FloorSystem/Floor.cs —
_solidSurfaceGO / _holedSurfaceGO fields replace the filler dictionary.
SetObiSurfaces(GameObject solid, GameObject holed) registers the pair.
ActivateHoledSurface() + SetSurfaceActive(go, active) flip both flags on both surfaces.
CommitGaps(cells) now sets the gap state + calls ActivateHoledSurface(). No more RebuildObiSurfaceMesh call, no per-cell filler destruction.
RegisterFiller / DestroyFillerForCell deleted; _fillers field deleted.
ComputeFutureGapCells + FutureGapCells + IsFutureGap retained (the Holed mesh's hole positions come from this set).
Verification
Live Play Mode, MatchScene, user-confirmed end-to-end:
- Bowl holds blood pre-cycle (Carrier mode, stand under emitter).
- Bowl holds blood post-cycle (after completing floor 1's altar → ExpandSequence 1→2).
- Bowl tolerates player movement without ejecting blood.
- Blood passes through the newly-opened holes in the old floor 1 after cycle.
- Player walks across the floor smoothly — no catching on cell boundaries.
PlayerBlobShadow stays flat across all cells (no Y-hopping at seam boundaries).
Console clean (no compile errors; only the pre-existing unrelated kinematic body linear velocity warnings from Obi's rigidbody system).
Follow-ups (non-blocking)
Confirmed/tech/Core Systems Reference.md FloorObiSurface paragraph wants a one-liner mentioning the dual _Solid / _Holed siblings + toggle mechanism. The runtime behaviour described in Confirmed/design/Scrolling Floor System.md is unchanged.
GroundParticleKiller is added to both surfaces — the dormant one's OnCollision handler is effectively a no-op (its ObiCollider.Filter = 0 means no particles match, so no contacts are routed to it), but moving the GPK off the dormant surface entirely would be a tiny perf win.
- Full pre-existing problem investigation + Dev Log session lives in
Problems/Archive/2026-05-23_cup-blood-containment-breaks-after-floor1.md.
Ground particle decay — lost-contact grace window
Implemented: 2026-05-24
Type: Fix
Area: Blood system / Obi Fluid
Player impact
Blood lying on the floor now decays within the 3.5 s recoverable-then-decaying window again. Before this fix, particles on the floor lingered for tens of seconds until Obi's 60 s lifespan backstop killed them — the floor visibly stayed bloody between rituals and the recovery-window mechanic stopped reading as such.
What changed
Assets/ProtoV2/Scripts/BloodSystem/ObiParticleKillerBase.cs — added a serialized lostContactGraceSeconds field (default 0.5 s, [Min(0)]). The lost-contact check in UpdateParticleTimers now uses max(lostContactGraceSeconds, 2 * fixedUnscaledDeltaTime) instead of the previous hard-coded two-fixed-frame window.
resetTimerOnLostContact semantics are unchanged: a particle that genuinely leaves the surface (creature picks blood up, falls into a hole) still cancels the kill timer.
Why
Two-fixed-frames (~0.04 s at 50 Hz) was sized only for the Obi-vs-FixedUpdate execution-order race. Commit bab8450 (2026-05-21) added ObiCollider.ForceUpdate() + ObiColliderWorld.SetDirty() after every mesh swap in FloorGenerator.RebuildSurfaceMesh — necessary to stop blood from colliding with the old hole-less mesh, but now each floor scroll re-uploads the collider and contact briefly drops for resting particles. With resetTimerOnLostContact = true, that dropped the 3.5 s kill timer on every scroll. Bumping the grace window to 0.5 s comfortably absorbs the re-upload bounce while staying tight enough that a real pickup still cancels.
Note on the lifetime-vs-delete misread
When framing this, the working theory was "we switched the kill path from particle-delete to particle-lifetime." Half right:
- Our code (
ObiParticleKillerBase.TryKillParticle) still calls emitter.KillParticle(actorIndex) exactly as it always has. No semantic change on our side.
- But Obi 7.1.1's
ObiEmitter.KillParticle is itself implemented as solver.life[index] = 0 (Assets/Obi/Scripts/Fluid/Actors/ObiEmitter.cs:445). The upstream API has always been lifetime-based since the Obi 7.1.1 upgrade. That's why the lifespan: 60 field on BloodEmitter.prefab was acting as the actual end-of-life backstop and why the symptom looked like "very long" rather than "forever".
So: not a kill-path regression. The kill call was firing fine; the *timer that decides when to fire* was getting cancelled by mesh-swap contact drops.
Verification
In-Editor (MatchScene): blood landing on the floor disappears in ~3.5 s. Floor scrolls during a pour no longer cause ground particles to linger. User confirmed.
Files modified
Assets/ProtoV2/Scripts/BloodSystem/ObiParticleKillerBase.cs — lostContactGraceSeconds field + updated grace check in UpdateParticleTimers.
Follow-ups
- None blocking. Total kill stays at 3.5 s (2 s recovery + 1.5 s decay) per
GroundParticleKiller.Awake(); if you ever want exact 3.0 s, drop one of those constants in GroundParticleKiller.cs.
Related
- Problem file (archived):
Games/Ritual & Ruin/Problems/Archive/2026-05-23_ground-particle-decay-slow.md
- Commit that exposed the regression:
bab8450 (FloorGenerator.RebuildSurfaceMesh mesh-tracker fix, 2026-05-21).
---
2026-05-24 — Outlast Timer Reports Scaled In-Game Seconds
Motivation
When the first team dies, MatchManager enters the outlast phase and exponentially ramps Time.timeScale (default: doubles every real-world second). The surviving team is forced to die in ~5 real seconds no matter what they do. The HUD outlast counter and the EndScreen's "Team A outlasted Team B by Xs" line were both measured in unscaled real seconds, so the readout was always ≈ 5.0s regardless of how stubborn the surviving team actually was — making the post-match comparison metric useless.
Root cause
MatchManager.Update used a single elapsed = Time.unscaledTime − outlastStartTime for two different purposes:
1. The Time.timeScale exponential-ramp formula (legitimately wants real seconds so the ramp is wall-clock predictable).
2. The displayed outlastTimerText and the outlastSeconds value flowed to OnMatchEnd → EndScreen (should be scaled in-game seconds so the number reflects equivalent normal-speed survival).
The two outlast calculations in CheckTeamElimination (line 188) and CheckRemainingTeamAlive (line 219) also used Time.unscaledTime − outlastStartTime, so the value handed off to the EndScreen was always real seconds — capped at ~5s by the timeScale ramp.
Changes
`MatchManager.cs`
- Added
private float _outlastScaledStartTime; — snapshot of Time.time (timeScale-scaled) captured in RecordTeamEliminated alongside the existing real-time outlastStartTime.
Update: split elapsed into elapsedReal (drives the Time.timeScale = outlastStartTimeScale * Mathf.Pow(outlastGrowthPerSecond, elapsedReal) ramp — unchanged behaviour) and elapsedScaled (drives outlastTimerText.text).
- Both
outlast = Time.unscaledTime − outlastStartTime calculations in CheckTeamElimination and CheckRemainingTeamAlive switched to Time.time − _outlastScaledStartTime, so EndMatch(winningTeam, outlast) and therefore OnMatchEnd now report scaled seconds.
- Updated the XML doc on
OnMatchEnd to specify the unit is scaled in-game seconds.
- Inline comments on the two
outlastStartTime fields and the Update split explain *why* the two clocks are separate.
The draw threshold in CheckRemainingTeamAlive (outlast < 0.02f) is now interpreted in scaled seconds without any code change. At simultaneous elimination timeScale ≈ outlastStartTimeScale (1× by default), so scaled ≈ real and behaviour is identical in practice.
Considered & rejected
- Real seconds + speed badge (e.g. "4.2s @ 18×") — exposes the mechanic but doesn't give a normalised comparison number across matches.
- Display both numbers (large scaled, small real + peak speed) — more information but clutters the EndScreen's clinical typography style; can revisit later if forensic detail becomes useful.
Verification
- Compile-clean via Unity refresh (no errors/warnings on
MatchManager).
- Player confirmed in real match: HUD outlast counter visibly accelerates as the speedup ramps; EndScreen "by Xs" line now reports a meaningful number (well above 5s) reflecting equivalent normal-speed survival time.
Follow-ups
- None. EndScreen formatting (
by {outlastSeconds:F1}s) is unchanged — it consumes the new unit transparently. MatchTimerUI (separate HUD match clock) was already using Time.deltaTime (scaled) and is unrelated.
- If we later decide players want forensic detail (real seconds + peak timeScale alongside the scaled number),
Update already has both clocks at hand — trivial extension.
Links
- Problem file: [[2026-05-24_outlast-timer-scale-mismatch]]
- Decision: [[2026-05-24 - Outlast Timer Reports Scaled In-Game Seconds]]
---
2026-05-24 — Player Blob Shadow + Directional-Light Iteration (Clarity & Depth)
Motivation
First implementation pass on [[Confirmed/strategy/Problems/Active/2026-05-18_clarity-and-depth-perception|Clarity and 3D Depth Perception]]. The character's existing real-time shadow projected off to one side (directional light at (50, -30, 0)) which broke the "shadow tells you where you are" affordance for both sub-problem 2 (X-Z alignment with holes for fly-down) and sub-problem 3 (general floor navigation). Goal: shadow directly under the player, sharper and darker than environment shadows, environment shadows softer and lighter.
Approach landed (per-failure tools)
- Player anchor: dedicated blob shadow per player — a single unlit transparent quad with a procedural soft-disc texture, raycast-snapped to the floor each
LateUpdate. Sharp, dark, always directly underneath.
- Environment ambient layer: directional light tilted mostly-down with reduced shadow strength so wall / altar / pillar shadows still read as soft depth cues without competing with the player anchor.
- Free fly-down cue (sub-problem 2): when the raycast finds no floor below the player (i.e. they're over a hole), the blob disables. The disc disappearing is itself the "press fly-down now" affordance.
Changes
New: `Assets/ProtoV2/Scripts/PlayerBlobShadow.cs`
- Spawns one
Quad primitive at scene root (intentionally NOT parented to the player — the player root has localScale = 0.25, parenting shrinks the quad to ~0.3 units across and it disappears under the iso camera).
- Procedural
Texture2D (128² RGBA32) generated in code — white RGB, soft-falloff alpha disc using SmoothStep on InverseLerp(innerSolidRatio, 1, d). Inner solid radius keeps the disc dark in the middle, falls off to fully transparent at the edge. No asset file needed.
- URP Unlit transparent material setup matches the proven pattern from
EmitterIndicatorController.CreateMaterial (_Surface=1, _Blend=0, alpha-blend, _ZWrite=0, _Cull=0, _SURFACE_TYPE_TRANSPARENT keyword, queue Transparent). _Cull=0 is load-bearing — without it the single-sided quad's back face was rotating toward the iso camera and getting culled (invisible).
LateUpdate does Physics.RaycastNonAlloc straight down from transform.position + Vector3.up * rayOriginYOffset, filters out the player's own colliders (IsChildOf(transform)) and also FloorObiSurface by name — see "Y-alignment fix" below.
- Cleanup hooks (
OnDisable / OnEnable / OnDestroy) so the orphaned quad follows the component's lifecycle.
`Assets/ProtoV2/Scripts/Multiplayer/PlayerSetup.cs`
Awake auto-adds PlayerBlobShadow if missing. Avoids needing to wire the component on the prefab (which would require a script GUID round-trip).
`Assets/ProtoV2/Prefabs/PlayerPrefab.prefab`
m_CastShadows: 1 → 0 on the three player MeshRenderers: TrailingBody, Cap, Bowl. The blob takes over the shadow role entirely; the meshes no longer participate in the directional-light shadow pass.
`Assets/ProtoV2/Scripts/JellyfishVisuals.cs`
- Runtime
LineRenderer tentacles built in BuildTentacles now set shadowCastingMode = Off + receiveShadows = false. Otherwise tentacles would still cast thin strips from the directional light in jellyfish mode.
`Assets/ProtoV2/Prefabs/FloorSystem/Chunk_Normal.prefab`
m_CastShadows: 1 → 0 on the chunk MeshRenderer. Reason: with the directional light angled mostly down, the upper play floor's chunk meshes were casting a giant rectangular shadow onto the floor below, blanketing it in darkness and drowning out the smaller wall / altar / pillar shadows we actually want to see. Chunks are horizontal panels; their shadows aren't visually useful — only the tall props (walls / altars / pillars, still casting) carry depth information.
- The user's intuition was to "move the light below the ceiling" — directional lights ignore position (only rotation matters), so that mental model can't be wired. Disabling Cast Shadows on the occluder achieves the same visual outcome.
`Assets/ProtoV2/Scenes/MatchScene.unity`
Directional Light rotation: (50, -30, 0) → (70, 0, 0). Mostly straight-down (light source close-to-overhead so per-object shadows are short and read as belonging to the object) with a 20° tilt back so vertical objects (walls / altars / pillars) still project a visible shadow onto open floor.
shadowStrength: 0.35 → 0.5. Bumped to compensate for the steeper angle — at near-straight-down the shadows are short, so they need more density to read at booth distance.
Iteration log (problems that came up and fixes)
Iter 1: blob invisible
Symptom: no shadow showed up at all on either player.
Diagnosis via MCP (read_console, find_gameobjects, components resource): the raycast WAS hitting FloorObiSurface at the right XZ, no errors, component attached. But the world-space bounds of the quad were 0.3 × 0.3 instead of the expected 1.2 × 1.2 — the player root's localScale = 0.25 was shrinking the parented child to invisibility under iso zoom.
Fix: don't parent the quad. Live at scene root, track world position manually each LateUpdate, destroy in OnDestroy.
Iter 2: env shadows gone with light straight-down
Symptom: at light rotation (90, 0, 0) and even at strength 0.35, no environment shadows visible anywhere.
Diagnosis: vertical objects cast a shadow EQUAL TO their own footprint when lit perfectly from above — every wall / altar / pillar's shadow sits directly under itself, hidden by its own base. Open floor receives nothing.
Fix: tilt back to (70, 0, 0) — far enough off-vertical that tall props project onto adjacent open floor, while the blob shadow already handles the player. Also bumped strength to 0.5 to compensate for the now-shorter casts.
Iter 3: ceiling blocking shadows
Symptom: at (70, 0, 0) strength 0.5, env shadows on the current floor were near-invisible — most of the floor sat in a uniform dim wash.
Diagnosis: the next-higher play floor's chunk meshes are themselves opaque and were dumping a giant shadow onto the floor below, drowning out the wall / altar shadows we wanted to see.
Fix: m_CastShadows: 0 on Chunk_Normal.prefab.
Iter 4: blob and crosshair Y-misaligned
Symptom: blob shadow and PlayerCrosshairController's + weren't at the same height — under iso this projected to a screen-space offset that read as "off-center".
Diagnosis via MCP (compared the two GameObjects' world positions):
- Blob world Y = 9.04 (raycast hit
FloorObiSurface at 9.02 + verticalOffset=0.02).
- Crosshair world Y = 8.88 (
floor.transform.position.y + ChunkHeight*0.5 + surfaceOffset(0.06)).
- Delta = 0.16. The
FloorObiSurface is intentionally lifted by floorKillZoneYOffset in FloorGenerator.AddFloorObiSurface so Obi fluid doesn't seep into chunk crevices — it's *not* coplanar with the visible chunk surface.
XZ was already within 3 mm (both anchor to player.transform.position.xz); the misalignment was purely the Y discrepancy projecting through the iso camera.
Fix: blob's raycast filter now also skips h.collider.name == "FloorObiSurface" so the ray passes through it to the chunk BoxCollider underneath. Blob now lands at Y=8.84 (chunk 8.82 + 0.02), 4 cm below the crosshair — invisible under iso, and the crosshair's ZTest=Always material draws over the blob's centre cleanly.
Considered & rejected
- Second directional light with rendering layers for player-only sharp shadows. Cleanest "real" solution but each extra directional light adds a full shadow-map pass — measurably more expensive than the blob. With blob shadows being effectively free (1 alpha quad per player, no shadow-sampling), no reason to pay that cost.
- URP Decal projector. Higher quality (real surface conformance, sub-pixel sampling) but requires enabling URP Decals in the renderer feature stack and a separate decal material. Blob quad is sufficient for a clean clarity cue and ships without renderer-stack changes.
- Bias the blob XZ to compensate for iso-parallax. The body floats above the floor at high Y; the iso camera makes it appear shifted UP on screen relative to its true XZ projection, so a perfectly-centred shadow looks "behind" the body. Considered adding a small
+Z world bias to push the blob forward visually. Not done — the geometric "shadow at foot XZ" reads as correct depth cuing once you're used to it, and biasing would misrepresent where the player actually is in world space (which is precisely what we're trying to communicate).
Verification
- All four player blobs spawn (confirmed via
find_gameobjects by_component PlayerBlobShadow returning 4 hits, and the corresponding BlobShadow_PlayerPrefab(Clone) scene-root objects).
- Raycast logs (
[PlayerBlobShadow] Player_N: raycast hit 'PooledChunk_Dynamic') confirm the post-fix raycast hits the chunk surface, not the obi surface.
- Blob and crosshair visually coincide (user confirmed in-editor).
- Environment shadows from walls / altars / pillars visible on the play floor under
(70, 0, 0) + strength 0.5. The upper play floor no longer blankets the current floor in shadow.
- Tentacle shadow-cast disabled — verified by inspecting
JellyfishVisuals.BuildTentacles and confirming the new shadowCastingMode = Off lines.
Follow-ups
- Player-shadow / blob-shadow behaviour deserves a Confirmed spec under
Confirmed/tech/ once it bakes in. Should document: lives at scene root, raycast filter rules, hide-on-no-hit-as-fly-down-cue, parent-scale gotcha.
- "Floor doesn't read as a clear ground plane" (sub-problem 3) is still open — this pass tackled the player-anchor side. Floor art / value-contrast work still pending and ties into the [[project_color_palette|Color Composition Workflow]] validation phase.
- If a future scene gets a different Obi surface or a
MeshCollider named differently from FloorObiSurface, the blob's name-based filter won't apply and the shadow would float again. Consider promoting FloorObiSurface to a project-level layer (e.g. "ObiFluidCollider") and switching the filter to a LayerMask if this comes up.
---
2026-05-24 — Boundary Walls: real-surface `Textured` mode (Concrete034)
Motivation
The 8 procedural wall shaders (Flat / Panels / Bands / Ribs / PanelsInset / VentSlats / Greebles / CircuitTraces) all read "abstract" no matter the params — pure procedural unlit-transparent geometry can't produce a real-surface read. The decision in [[ART#Prototype recommendation (2026-05-24)|ART.md]] was to add a real-photo Path B alongside the procedural set, picking ambientCG Concrete034 (CC0) as the safest neutral-grey base across all 6 palettes (see [[Combo Comparison]]).
Approach
- New
ProtoV2/BoundaryWallTextured shader that samples a greyscale-ish wall photo with world-space UVs (same trick the other wall shaders use — picks horizontal coord from positionWS.x or .z based on the wall's normal so all 4 walls keep a consistent tile size regardless of mesh stretching), desaturates the source to a tunable degree, and multiplies by _BaseColor (the palette's walls.sides). Desaturate knob defaults to 1.0 so the palette dominates the hue even if the source has a faint colour bias.
- New
WallTextureMode.Textured enum value plumbed through BoundaryWalls.PickShader, ConfigureWallMaterial (skips seam/edge derivations like Flat does), BuildWallMaterials (calls a new ApplyTexturedParams after Configure to push _BaseMap/_TileScale/_DesatStrength/_ValueBoost/_ValueOffset), and SetWallColors (only rewrites _BaseColor for Textured — no seam/edge to derive).
- Editor-only auto-link in
BuildWallMaterials — if the user is in Editor and the _texturedBaseMap inspector slot is empty, UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/ProtoV2/Textures/Walls/Concrete034_Color.jpg") fills it in. Means the cycle wizard works out of the box without manual Inspector wiring. In a built player the field must be serialized — checked at write time.
WallTextureMode.Textured added to the cycle arrays in ColorPalettePreviewWindow.cs so both Cycle wall textures and Cycle all combinations (palettes × wall textures) capture it (now 9 modes × 6 palettes = 54 captures).
Changes
New: `Assets/ProtoV2/Shaders/BoundaryWallTextured.shader`
URP unlit-transparent pass, same blend state as the procedural wall shaders (Blend SrcAlpha OneMinusSrcAlpha, ZWrite Off, Cull Off, queue Transparent). Frag samples _BaseMap at world-space UVs scaled by _TileScale, applies a luminance-based desaturation (lerp(src, grey, _DesatStrength)), brightness shaping (saturate(src * _ValueBoost + _ValueOffset)), and tint-multiplies by _BaseColor.rgb. Alpha comes straight from _BaseColor.a so the same opacity logic that drives the procedural walls (~0.10 alpha for sides, ~0.02 for the front wall) carries over.
`Assets/ProtoV2/Scripts/FloorSystem/BoundaryWalls.cs`
- Enum:
Flat, Panels, Bands, Ribs, PanelsInset, VentSlats, Greebles, CircuitTraces → ..., Textured.
- New serialized fields:
_texturedBaseMap (Texture2D), _texturedTileScale (Float, default 4), _texturedDesat (Range 0..1, default 1), _texturedValueBoost (Range 0.5..1.5, default 1), _texturedValueOffset (Range −0.3..0.3, default 0).
PickShader returns the new shader for Textured. Fallback chain unchanged.
ConfigureWallMaterial — the seam/edge derivation block now skips when mode == Textured as well as Flat.
BuildWallMaterials — calls ApplyTexturedParams after Configure when in Textured mode. Editor-only AssetDatabase auto-link fills _texturedBaseMap if empty.
SetWallColors — only writes _BaseColor on Textured (no _SeamColor / _EdgeColor).
ApplyTexturedParams(Material) — pushes texture + 4 shaping params to a material.
`Assets/ProtoV2/Scripts/Editor/ColorPalettePreviewWindow.cs`
- Both
modes arrays (in CycleWallTextures and CycleAllCombinations) get WallTextureMode.Textured appended.
New assets
Assets/ProtoV2/Textures/Walls/Concrete034_Color.jpg — ambientCG Concrete034_1K-JPG color map (264 KB). CC0.
Assets/ProtoV2/Sprites/Walls/Industrial_Tilemap.png + Industrial_License.txt + Industrial_Preview.png — Kenney Pixel Platformer Industrial Expansion (4 KB packed tilemap + 30 KB preview + license). CC0. Not yet wired — these are for Path C, which still needs the WallDecorationSpawner component (tracked in [[ART#Follow-ups / TODOs]]).
Verification
- Code review pass —
WallTextureMode.Textured reaches every switch branch that lists the enum (PickShader, the cycle wizard arrays). The mode != Flat && mode != Textured guard in ConfigureWallMaterial matches the new "no seam/edge" branch in SetWallColors.
- Hands-on Editor verify pending — the user enters Play Mode →
Window → Ritual & Ruin → Color Palette Preview → either flips the BoundaryWalls inspector to Textured directly or runs Cycle wall textures / Cycle all combinations. Captures land under PaletteCaptures/.
- Shader compile risk: the desat / value-shaping math is straightforward and uses functions (
dot, saturate, lerp) that exist on target 3.0. No keyword permutations.
Follow-ups
- Path C (
WallDecorationSpawner) — the Kenney tilemap is in the project but not sliced or used yet. When we get to it: import the PNG as Sprite (2D and UI) / Point filter / Multiple-mode / 18×18 grid slice with 1×1 padding, then build a spawner that places vent/valve/gauge sprites along walls tinted by a palette accent (e.g. hpBar.tier1Accent for light strips). Pick indices from Industrial_Preview.png.
- Tuning — once we see the texture under every palette, decide whether the default
_TileScale = 4, _DesatStrength = 1, _ValueBoost = 1, _ValueOffset = 0 need adjusting per combo. Likely candidates: pale combos (C2, C6) may want _ValueOffset < 0 to darken the wall a touch and lift floor↔wall contrast (a noted weakness of those combos in [[Combo Comparison]]).
- Lit-shader upgrade option — the Concrete034 download also includes
_NormalGL.jpg, _NormalDX.jpg, _Roughness.jpg, _Displacement.jpg. If we want PBR depth on the walls down the line, swap the shader to URP/Lit with these maps; the only complication is integrating the palette tint via _BaseColor rather than dropping it.
---
Session 2 — pivoted to procedural Aperture panels; nuked all the old modes
User feedback: the Textured mode read as a flat concrete wash with no panel structure, and concrete is the wrong aesthetic anyway. Reference image was Portal 2 / Aperture test chambers: white square ceramic panels with varying per-panel grime, water streaks, and decay. The right answer is procedural — we don't need an external photo, we need a shader that gives us the Aperture look directly and lets the palette colour the *decay theme* rather than the wall *base*.
What got nuked
Deleted 8 shader files (no other code or scene references — verified via grep): BoundaryWallPanels.shader, BoundaryWallBands.shader, BoundaryWallRibs.shader, BoundaryWallPanelsInset.shader, BoundaryWallVentSlats.shader, BoundaryWallGreebles.shader, BoundaryWallCircuitTraces.shader, BoundaryWallTextured.shader (and their .meta files).
Removed from BoundaryWalls.cs:
- 5 serialized
_textured* fields (_texturedBaseMap, _texturedTileScale, _texturedDesat, _texturedValueBoost, _texturedValueOffset)
ApplyTexturedParams method
- Editor-side
AssetDatabase.LoadAssetAtPath auto-link for Concrete034_Color.jpg
- 8 enum values (
Panels, Bands, Ribs, PanelsInset, VentSlats, Greebles, CircuitTraces, Textured)
DeriveSeam / DeriveEdge helpers (no longer called — Aperture uses shader-constant seam/edge)
- The 7-case
GetWallPatternScalarName switch (collapsed to _PanelSizeX for all non-Flat modes)
What got built (new)
Assets/ProtoV2/Shaders/BoundaryWallAperturePanels.shader (from Session 1's start, kept) — world-space-UV square panels with recessed dark grout seams, cream-white face, per-panel hash → grime-noise overlay + dual vertical water streaks, all tinted toward _BaseColor (the palette's walls.sides). Knobs: _PanelSizeX/Y, _SeamWidth, _EdgeWidth, _DecayAmount, _DecayStrength, _DecayFreq, _DecayContrast, _StreakAmount, _PanelVignette, _BrightVariation.
WallTextureMode enum (7 values now): Flat, AperturePanels (default 2×2 medium decay), AperturePanelsSmall (1.25×1.25 dense), AperturePanelsLarge (3.5×3.5 imposing), AperturePanelsTall (2×3.5 vertical), AperturePanelsHeavy (decay 0.85), AperturePanelsClean (decay 0.20).
ApplyAperturePreset(mode, mat) — single switch that presets shader params per variant. All variants share the one shader; the variant just decides which numbers get pushed.
PickShader simplified to one Shader.Find("ProtoV2/BoundaryWallAperturePanels") for any non-Flat mode, with the same Universal Render Pipeline/Unlit → Unlit/Color → Hidden/InternalErrorShader fallback chain.
ConfigureWallMaterial — kept the Flat URP/Unlit transparency setup block; the seam/edge-derivation block is fully removed (every non-Flat mode handles its own seam/edge as shader constants).
ColorPalettePreviewWindow.cs — both cycle arrays (CycleWallTextures, CycleAllCombinations) updated to the new 7-mode list. CycleWallSpacings switch collapsed to a single sweep {1, 1.5, 2, 2.5, 3.5, 5} of _PanelSizeX (applies to any non-Flat mode).
Scene compatibility
Old serialized _wallTexture = 1 (was Panels) now reads AperturePanels — same ordinal, accidental but lucky upgrade. Any scene previously set to a higher ordinal mode (Bands/Ribs/etc.) will now read whatever new mode lives at that index — should be fine since user said to remove all the weird ones.
Verification
- Grep pass — zero references to any removed enum value, shader name, or field.
BoundaryWalls.cs recompile risk: only the simplified switches + the new ApplyAperturePreset instance-less static method. All call sites accounted for.
- Hands-on Editor verify pending — user enters Play Mode →
Cycle wall textures (7 captures) or Cycle all combinations (42 captures = 6 palettes × 7 modes).
Follow-up backlog (post-cycle)
- After the user picks the favourite Aperture variant, adjust the
_DecayAmount / _DecayStrength / _StreakAmount defaults on AperturePanels so the default mode hits the sweet spot.
- Consider whether to add an
AperturePanelsOvergrown variant (greenish moss-tinted decay) or AperturePanelsBroken (with per-panel "missing panel" alpha drops) for environmental story beats.
- The
Concrete034_Color.jpg asset is now orphaned in Assets/ProtoV2/Textures/Walls/. Keeping it for now in case we revisit textured walls; can delete in a future cleanup pass if it stays unused.
---
Session 3 — Aperture shader-load failure + side-wall-only architectural split
After Session 2 wired in the Aperture variants, the cycle captures all came out byte-identical (verified via md5sum — all 6 variant PNGs hashed the same). The front wall was also rendering as an opaque hot-pink block, blocking the camera view.
Diagnosis
Root cause was not the C# preset switch — ApplyAperturePreset was pushing distinct params correctly per variant. The real problem: Shader.Find("ProtoV2/BoundaryWallAperturePanels") was silently returning null, so BoundaryWalls.PickShader fell through to Shader.Find("Universal Render Pipeline/Unlit"). URP/Unlit happens to render _BaseColor straight as opaque output if the URP transparency keywords aren't set — hence the hot-pink walls — and all 6 variants produced identical output because they were all the URP/Unlit fallback rendering the same _BaseColor, not the Aperture shader at all.
No shader compile errors visible in the Unity console during the failure (silent load failure). Force-refresh via mcp__mcp-for-unity__refresh_unity didn't help.
Fixes applied to `BoundaryWalls.cs`
1. Two-material split — side/back walls vs front wall. Per user suggestion ("why do we not only apply the textures to the back and sides boundary wall?"), BuildWallMaterials now produces two distinct materials:
_wallMat (used for Wall_PosX, Wall_NegX, Wall_PosZ) — gets PickShader(_wallTexture) (i.e. AperturePanels for non-Flat modes) at forced alpha 0.85 so the cream panel detail is actually visible against the floor.
_frontWallMat (used only for Wall_NegZ, the camera-facing wall) — always uses Shader.Find("Universal Render Pipeline/Unlit") at the serialized frontWallColor (~alpha 0.02). The front wall's job is to let the camera see through, not to display panel detail.
SetWallColors(sides, front) now pushes the sides RGB to _wallMat with forced alpha 0.85, and pushes the raw front color (with its near-zero serialized alpha) to _frontWallMat. BuildWalls was updated to pass _frontWallMat only to the Wall_NegZ collider; the other three keep _wallMat.
2. Diagnostic logging. Added Debug.LogError in BuildWallMaterials (~line 211 in current BoundaryWalls.cs) that fires whenever PickShader returns anything other than "ProtoV2/BoundaryWallAperturePanels" for a non-Flat mode. Catches the silent-fallback case at runtime instead of leaving us guessing.
3. Defensive transparency setup. ConfigureWallMaterial now always sets the URP/Unlit transparency keywords (_Surface=1, _Blend=0, _SrcBlend=SrcAlpha, _DstBlend=OneMinusSrcAlpha, _ZWrite=0, _Cull=0, enables _SURFACE_TYPE_TRANSPARENT, queue 2950). On the custom Aperture shader these are harmless no-ops (it bakes its own blend state into the Pass). On the URP/Unlit fallback they keep the wall transparent even if the Aperture shader fails to load again. Prevents future regressions to opaque-pink walls.
4. Aperture shader rewrite (still failed silently). Trying to fix the silent load failure, rewrote BoundaryWallAperturePanels.shader:
- All
half4 → float4 throughout (more portable HLSL typing).
- Added
IgnoreProjector="True" to the SubShader Tags block.
- Same procedural math otherwise — panel grid + per-panel hash + fbm grime + dual vertical water streaks, palette-tinted decay overlay via
lerp(cleanFace, _BaseColor.rgb, decay).
- Final frag:
return float4(rgb, _BaseColor.a) (alpha owned by _BaseColor, which BoundaryWalls.SetWallColors forces to 0.85 for side walls).
After the rewrite, read_console returned 0 error/warning entries. Awaiting user's hands-on re-test (stop Play Mode, re-enter, run Cycle wall textures, re-check console for the [BoundaryWalls] AperturePanels shader failed to load LogError).
Status
- Architectural split: done — side walls + front wall now use distinct materials. Code compiles. Defensive transparency keywords applied so even fallback stays see-through.
- Diagnostic LogError: in place — will fire at runtime if the AperturePanels shader still fails to load.
- Aperture variants visible: VERIFIED 2026-05-26 — user ran
Cycle wall textures against Combo 1 Corrupted Cathedral. All 7 captures landed in PaletteCaptures/wall_*_Combo_1_—_Corrupted_Cathedral.png with significantly different file sizes (260KB–1.1MB) confirming distinct shader output (not byte-identical anymore). Visual review confirms cream-white panels with dark grout seams and per-variant decay overlay, front wall fully transparent in every shot. The half4→float4 + IgnoreProjector rewrite resolved the silent shader-load failure.
Visual verdict (Combo 1 — Corrupted Cathedral)
AperturePanels (default 2×2): solid default — reads as Aperture-style, balanced grime.
AperturePanelsSmall (1.25×1.25): borderline noisy at view distance, busy grid.
AperturePanelsLarge (3.5×3.5): imposing but loses tile rhythm — grime overwhelms each big panel.
AperturePanelsTall (2×3.5): strong architectural silhouette, lab-corridor feel — strong alt default candidate.
AperturePanelsHeavy (decay 0.85): abandoned-chamber read, too grim for default.
AperturePanelsClean (decay 0.20): freshly-built read, best for "intro floor" moments.
Files touched (Session 3)
Assets/ProtoV2/Scripts/FloorSystem/BoundaryWalls.cs — split _wallMat / _frontWallMat, force-alpha for side walls, diagnostic LogError, defensive transparency in ConfigureWallMaterial.
Assets/ProtoV2/Shaders/BoundaryWallAperturePanels.shader — full rewrite, half4→float4, IgnoreProjector tag, single-file unlit-transparent pass.
Follow-ups (deferred until user reports back)
- If shader still fails to load: bisect by replacing with minimal flat-colour-plus-grid test shader.
- If shader loads but variants still look identical: capture and
md5sum the new PNGs to confirm before declaring success.
- Once Aperture variants render visibly: tune
AperturePanels default params (_DecayAmount / _StreakAmount) based on user pick.
- Wire Path C (
WallDecorationSpawner) once Path B is locked in.
---
2026-05-25 — Evolution Tier Accent Verification Pass
Motivation
Daily Focus carried a Quick-verification task: "Evolution tier accents actually applied? — setter + broadcast are wired; confirm the bar visibly shifts colour on tier change in Play Mode." Traceable failure if not: was the tier accent colour ever *used* to drive a renderer/material property, or was UpdateBarUI silently overriding it?
Trace result
UpdateBarUI (UnifiedBar.cs:366-385) only writes the threshold fill colours (full/warning/critical) to barFillImage.color. It does not touch the accent — by design, the accent is a separate channel:
TriggerEvolution (UnifiedBar.cs:285-302) calls UpdateBarAccentColor after evolutionTier++.
UpdateBarAccentColor (UnifiedBar.cs:308-324) switches on evolutionTier and writes barBackgroundImage.color = tierNAccentColor.
PaletteApplier.ApplyHPBar (PaletteApplier.cs:312-338) pushes palette accents through bar.SetTierAccentColors(t0, t1, t2) which also calls UpdateBarAccentColor.
So the path "tier accent → Image.color (UGUI tint, not a material property)" exists and is correct.
Static checks — all four failure modes ruled out
From Assets/ProtoV2/Prefabs/PlayerPrefab.prefab YAML:
1. barBackgroundImage reference assigned → fileID 9143661019916860951 (BarBackground Image component).
2. Tier accent colour alphas all 1.0 → tier0={1,1,1,1}, tier1={0,0,1,1}, tier2={1,0.65,0,1}.
3. Sibling order correct → BarBackground precedes BarFill under BarCanvas (earlier sibling = drawn first = behind).
4. barFillImage is Filled / Horizontal → m_Type: 3, m_FillMethod: 0, so empty portion of fill exposes the background.
Runtime verification (MatchScene, 4-player spawn)
Captured three screenshots in .mcp-screenshots/ (tier_accent_0_baseline.png, tier_accent_1_enhanced.png, tier_accent_2_terminal.png) via the existing Ritual & Ruin/Debug/Capture Game View menu. Drove evolution with a one-shot temp debug menu (TierAccentDebugMenu.cs, deleted after use) that called bar.AddBarUnits(500f) on every active player.
Observed:
- Tier 0 — bar at base width 100, full green fill, no accent visible (fillAmount=1.0 covers background). Expected.
- Tier 1 — bar widens to 130, fill drops to ~77% of new max (decays to yellow/warning by capture time); blue strip clearly visible on the right where fill no longer reaches. Tier-1 accent applied ✓
- Tier 2 — bar widens to 195, fill ~51%; gold strip visible on the right. Tier-2 accent applied ✓
Observation worth carrying forward
At Tier 2 the gold accent ({r:1, g:0.65, b:0, a:1}) sits next to the yellow warning fill (≈{1,1,0,1}). The visual contrast between fill and background is weak — at a glance the bar reads as a single yellow blob. The accent IS being applied, but it doesn't *signal* the tier change strongly. This is a palette-tuning issue, not a wiring issue.
There's also a smaller asymmetry: at Tier 0 the BarBackground stays at its prefab-serialized colour ({0,0,0,1} = black) because UpdateBarAccentColor is never called at Start — only on evolution or palette broadcast. The tier0AccentColor = white field is effectively only consumed when PaletteApplier re-broadcasts during a runtime palette swap. Not a bug today (Tier 0 visible accent is hidden under full-fill anyway), but worth knowing if Tier 0 ever needs to read with a partially-empty bar.
Files touched
Assets/ProtoV2/Scripts/Editor/TierAccentDebugMenu.cs — created, used, deleted within session. No remaining footprint.
Follow-ups
- Tier 2 accent vs warning yellow contrast — feed into the colour-composition workflow (post-Jun-21). Either shift the Tier 2 accent to something further from yellow, or shift the warning threshold/colour so Tier 2 doesn't sit in the warning zone immediately after evolution.
- Daily Focus Quick-verification item checked off.
Links
- Daily Focus item:
Daily Focus.md → "▶ NEXT STEPS / Quick verification (short session)"
- Related:
Confirmed/tech/Core Systems Reference.md (UnifiedBar / evolution), Color Palette Field Map.md (HP bar palette routing)
---
2026-05-25 — Terminal Decay Rate: Kill the Asymmetry
Motivation
Decay-legibility task from Daily Focus surfaced a sub-question: how long does each tier actually take to die? Investigation found Terminal was draining ~10–15s in practice while Tier 0/1 lasted minutes — a 8.7× rate cliff at evolution-into-Terminal. The user's read ("Terminal decays much too quickly") was correct.
Root cause
UnifiedBar had asymmetric tuning knobs (UnifiedBar.cs initial commit):
- Tier 0/1 used a multiplier table
decayMultipliers = {1.0, 1.5} against baseDecayRate.
- Tier 2 (Terminal) used an absolute constant
terminalDecayRate = 6.5 — bypassing baseDecayRate entirely.
The constant was introduced with the inline note // 195 maxValue / 30s — original intent was "Terminal lasts a designer-specified ~30s, decoupled from base tuning, because the outlast/aura phase wants its own beat."
That intent was viable at original tuning (baseDecayRate=10, Terminal maxValue=195). But baseDecayRate was later retuned 10 → 0.5 (20× slower) on the PlayerPrefab without touching terminalDecayRate, while evolutionMultiplier2 was raised 1.5 → 2.0 (Terminal max 195 → 320). The "30s outlast" knob was now wrong on two axes, and Terminal became the only tier still on the original tuning curve. The discontinuity at evolution-into-Terminal went from a designed step to a guillotine.
Decision
Kill the asymmetry. Extend decayMultipliers to a 3-element table (one entry per tier) and delete terminalDecayRate. Rationale: the "absolute seconds" knob was already broken by neglect; future baseDecayRate retunings now scale all three tiers together, so this class of bug can't recur.
Considered + rejected: just retune terminalDecayRate to 3.0 — works once but leaves the trap in place.
Changes
Assets/ProtoV2/Scripts/UnifiedBar.cs
decayMultipliers default → {1.0, 1.5, 6.0} (was {1.0, 1.5}); tooltip added.
- Removed
terminalDecayRate field and its stale // 195 maxValue / 30s comment.
GetCurrentDecayRate() simplified — single-line table lookup, no per-tier branch.
Assets/ProtoV2/Prefabs/PlayerPrefab.prefab
decayMultipliers extended with - 6 (third entry).
terminalDecayRate: 6.5 line removed.
Effective tunings (PlayerPrefab values: `baseDecayRate=0.5`, `evolutionMultiplier1=1.6`, `evolutionMultiplier2=2.0`)
| Tier | Max bar | Rate (u/s) | From full | From evolution (preserved value) |
|---|---:|---:|---:|---:|
| 0 Base | 100 | 0.5 | 200 s | 140 s (start at 70) |
| 1 Enhanced | 160 | 0.75 | 213 s | 133 s (value 100 → bar 160) |
| 2 Terminal | 320 | 3.0 | 107 s | 53 s (value 160 → bar 320) |
Discontinuity Tier 1 → Tier 2: rate jump 4× (was 8.67×). Still a clear step, but survivable instead of vertical. Exponential ramp (_decayDoublingTime = 600) still layered on top — Terminal entered at match-minute 4 = ~33 s effective survival.
Verification
mcp__mcp-for-unity__refresh_unity → compile request acked, resulting_state: compiling.
mcp__mcp-for-unity__read_console filtered to errors: only pre-existing unrelated CS2001 about Editor/TierAccentDebugMenu.cs (stale .meta from the prior verification session — file was deleted but the .meta lingered). No new errors from this change.
- Did not verify in Play Mode this session — math + clean compile only. In-match feel is the open question for the next playtest.
Follow-ups
- Cleanup unrelated: delete leftover
Assets/ProtoV2/Scripts/Editor/TierAccentDebugMenu.cs.meta to clear the CS2001 noise (next session, not blocking).
- Validate at playtest: does Terminal still feel like a meaningful finale, or is 53s-from-evolution now *too long*? Held under the Daily Focus rule — comprehension fixes (visible drain, refill cause-effect, audio undertone) first; revisit rate-tuning only if deaths persist after those land. ([[2026-05-18_first-ritual-death-tuning]])
- Open separate problem [[2026-05-19_tier-decay-balance]] (tier decay balance, post-Jun-21) should reference this refactor — the multiplier table is now the single tuning surface.
Links
- Daily Focus → ▶ NEXT STEPS / @Home item 3 (Decay legibility)
- Problem spec:
Confirmed/.../2026-05-18_first-ritual-death-tuning
- Related:
Confirmed/tech/Core Systems Reference.md (UnifiedBar)
---
Session 2 — Terminal rate retune 3.0 → 1.7
After re-reading the Session 1 numbers (53 s from evolution at rate 3.0), called the discontinuity still too steep for a phase that's supposed to feel like a finale you fight through. Retuned the Terminal multiplier directly on the prefab.
Change
Assets/ProtoV2/Prefabs/PlayerPrefab.prefab — decayMultipliers[2]: 6 → 3.4 (so 0.5 × 3.4 = 1.7 u/s effective).
- No code change. Source default in
UnifiedBar.cs left at 6.0 — it's irrelevant in practice (the prefab always overrides), and the source baseDecayRate=10 default is itself stale, so retuning the source default in isolation would mis-calibrate the file's internal consistency.
Effective tunings now
| Tier | Max bar | Rate (u/s) | From full | From evolution |
|---|---:|---:|---:|---:|
| 0 Base | 100 | 0.5 | 200 s | 140 s |
| 1 Enhanced | 160 | 0.75 | 213 s | 133 s |
| 2 Terminal | 320 | 1.7 | 188 s | 94 s |
Discontinuity Tier 1 → Tier 2: rate jump 2.27× (was 4× after Session 1, was 8.67× originally). Terminal now lasts ~1.5 min from evolution before ramp, ~60 s at match-minute 5 with ramp. Closer to a finale arc than a guillotine.
Verification
- Edit-only, no compile needed (prefab YAML value change). No console errors expected.
- Play Mode validation still deferred — same plan as Session 1, comprehension fixes first.
---
Altar blood linger — instant absorb on contact
Implemented: 2026-05-26
Type: Fix
Area: Blood system / Altar
Player impact
Blood deposited at the altar now disappears the same fixed step it touches the collider, instead of pooling on the altar for ~2 s before being recycled. Per-feed feedback now lands immediately: pour → meter ticks → particles gone. Closes the "did it count?" ambiguity described in todo 2 of the altar identity spec.
What changed
Assets/ProtoV2/Scripts/BloodSystem/AltarParticleConsumer.cs — overrode HandleParticleContact so the altar bypasses ObiParticleKillerBase's delayed-kill timer entirely. On first contact the consumer now writes solver.life[particleIndex] = 0 directly, then fires OnParticleMarkedForDeath / OnParticleKilled / OnParticleRemoved in the same call. The base timer dict (particleKillTimers) is never populated for altar consumers.
Removed dead fade-on-absorb scaffolding from the consumer: the fadeParticleOnAbsorb toggle, the absorbingColor field, and the OnParticleFirstContact / OnParticleTimerUpdate overrides that animated colour over the kill delay. With instant absorb there is no window to fade in — per-feed feedback moves to the altar side (tree pulse, tree→player refill motes; spec todos 4–5).
Assets/ProtoV2/Prefabs/FloorSystem/Altar.prefab — killDelay: 2 → 0 and dropped the orphan YAML for absorbingColor / fadeParticleOnAbsorb. The new override makes killDelay a dead-letter, but explicit 0 documents intent.
Why
The linger was never the emitter's lifespan: 60 backstop — it was Altar.prefab's killDelay: 2 flowing through ObiParticleKillerBase's timer-then-kill model. Every particle that landed on the altar collider started a 2 s kill timer; the particle remained physically present (and slowly fading) until the timer expired, then KillParticle zeroed its life and the solver recycled it on the next step. Two seconds of "blood on the altar, no meter movement" reads exactly like "did it land in the wrong place?"
The Obi 7.1 manual explicitly recommends writing solver.life[idx] = 0 for instant per-particle kill — preferred over ObiEmitter.KillParticle() because the latter has an index-swap quirk when multiple particles die the same frame (long-standing forum guidance). Our TryKillParticle already routes through KillParticle, but importantly *that path runs after a delay*. Bypassing the delay at the contact callback is the manual-endorsed path.
Verification
In-Editor (MatchScene), user-confirmed: pour-on-altar now reads as instant — every particle that touches the altar disappears the same frame, meter ticks up smoothly, no visible pile.
Edge cases held:
HandleCollision gate (!isActive || isConsumed) still short-circuits before HandleParticleContact runs, so dormant/consumed altars don't consume blood.
AddBlood still no-ops during isCountingDown, so post-full particles die (look right) without overfilling the meter.
GroundParticleKiller (3.5 s, recoverable + decaying) and BloodEmitter.lifespan: 60 (global backstop) are untouched; stray particles that miss the altar still hit the existing floor decay path.
Files modified
Assets/ProtoV2/Scripts/BloodSystem/AltarParticleConsumer.cs — instant-kill override, fade scaffolding removed.
Assets/ProtoV2/Prefabs/FloorSystem/Altar.prefab — killDelay → 0, orphan fade fields dropped.
Follow-ups
- Altar-side per-feed feedback (spec todo 4: light particles tree→player on valid feed; spec todo 5: completion ignition). The visual punctuation that used to live on the dying particle now needs to land on the altar itself.
- Sound designer brief addendum: per-feed audio (with the locked escalation curve) is what now communicates "this feed counted" in the audible channel, since the particle no longer lingers to carry that beat.
Related
- Spec:
Games/Ritual & Ruin/Problems/Active/2026-05-18_altar-identity-and-completion-feel.md (todo 2).
- Obi 7.1 manual: [Scripting Particles](https://obi.virtualmethodstudio.com/manual/7.1/scriptingparticles.html) —
life ≤ 0 kill contract.
- Companion piece:
2026-05-24_GroundParticleDecay_LostContactGrace.md (off-altar particle decay).
---
Auto-pour near altar — manual pour mechanic removed
Implemented: 2026-05-27
Type: Feature
Area: Player / Altar interaction
Player impact
Pouring is no longer button-driven. When a player in Carrier Mode walks within pourRange (3u XZ) of an altar, the bowl auto-tilts toward the altar and ObiFluid sloshes out by gravity — same tilt math as the old manual pour, just engaged on proximity instead of button-hold. Right-stick tilt still preempts auto-pour, so players keep full agency to redirect or hold upright. Closes todo 3 of the altar identity spec ([[2026-05-18_altar-identity-and-completion-feel]]) and removes the positioning skill check per Design Principle P1 ("depth is for space, not precision").
The manual pour button (= jump button repurposed in Carrier Mode) is now genuinely a no-op in Carrier Mode. The Carrier-Mode hole-pour mechanic is gone as a side-effect — there is no manual pour any more, and auto-pour is altar-only by design (so the bowl doesn't trigger every time a player walks near a floor gap).
What changed
Assets/ProtoV2/Scripts/PourController.cs — rewrote the pour flow:
- Removed manual-pour state (
_pourActive, _lockedTarget, SetPourInput(bool)).
- Removed hole-as-pour-target (
GetNearestHole, GetPlayerFloor, _holeProxy, PourHoleProxy GameObject lifecycle in OnDestroy).
- Added
_autoTarget, TryAutoPour(out Transform altar), GetNearestAltarInRange().
- Refactored
ApplyPourTilt() to take a Transform target parameter so the same world-space-axis tilt math drives the auto path.
Update() flow is now: TryAutoPour first; on success, set _autoTarget and run ApplyPourTilt(_autoTarget); on failure, clear _autoTarget and fall through to the existing manual / counter-tilt branch.
CurrentTarget simplified to just return _autoTarget (was: pour-active lock → idle preview of nearest target). PourTargetIndicator now glows gold on the altar only while auto-pour is engaged, which is also a stronger "you are pouring" signal than the old idle-preview behaviour.
CancelPour() retained as a public API surface — repurposed to clear _autoTarget so callers (TransformController.Toggle, MultiplayerCharacterInput.ResetInputState) keep working without edits.
Assets/ProtoV2/Scripts/Multiplayer/MultiplayerCharacterInput.cs — removed the line in PassInputToController() that drove the manual-pour button:
jumpHeld still passes through to SimpleCharacterController1.SetInput(...), which already gates jump on !IsCarrierMode.
Why
The dispense-on-stand work shipped 2026-05-21 (emitter.speed gating on BloodEmitter) already proved out the proximity-driven Obi-emit pattern: stand near, pour happens. Auto-pour-near-altar is the mirror image for the delivery side. With per-feed feedback now landing on the altar itself (todo 2 instant-absorb, 2026-05-26; todos 4/5 still to come), the button-press was the last bit of positioning ceremony between the player and the ritual. Removing it gives us a clean cause-effect read: "I'm near the altar → blood goes in." The right-stick tilt override keeps the skill ceiling there for players who want to time their pours or hold the bowl level mid-approach.
Manual pour for holes is also removed, deliberately. The original 2026-03-15 pouring-mechanic spec listed holes as a valid pour target (manual button → tilt toward hole). With manual pour gone, hole-pour can't ride along — adding auto-pour-near-holes would mean the bowl spills any time a player walked near a floor gap, which is exactly the opposite of what we want (gaps are spatial navigation, not pour targets). The spec's "altars only" answer matches Design Principle P1: the altar is the spatial target, the hole is just floor topology.
Verification
LSP compile check unavailable (no OmniSharp installed locally). Verified via caller-surface grep across Assets/ProtoV2/:
- All callers of removed
PourController methods (SetPourInput) updated or removed.
- Remaining
PourController public surface (SetTiltInput, SetAutoTilt, CancelPour, CurrentTarget) is referenced by MultiplayerCharacterInput (3 call sites), TransformController.Toggle, PlayerSetup.SetAutoTilt, and PourTargetIndicator.Update — all still exist.
- No dangling refs to
_lockedTarget, _pourActive, _holeProxy, GetNearestTarget, GetNearestHole, GetPlayerFloor, or PourHoleProxy anywhere in the project.
- Serialized fields in
PlayerPrefab.prefab are unchanged (pourRange, tiltAngle, tiltSpeed, _visualRoot, autoTiltScale, autoTiltMaxAngle — all still SerializeField on the rewrite).
Playtest result (user-confirmed 2026-05-27): auto-pour engages on entering the altar radius and the right-stick override works. Initial tilt was too snappy at the inherited tiltSpeed = 8 — bowl reached full 75° in <0.5 s and dumped all the blood past the altar instead of streaming into it. Speed is the dial that matters (not the angle); see Tuning section below. After the tuning pass, blood streams into the altar as intended.
Edge cases held
- Mode-switch (Mobility ↔ Carrier) —
TransformController.Toggle still calls CancelPour(), which now just clears _autoTarget. Auto re-evaluates the next frame anyway, so this is mainly insurance against a one-frame visual glitch on toggle.
- Input reset (pause, death, controller disconnect) —
MultiplayerCharacterInput.ResetInputState still calls CancelPour() and SetTiltInput(Vector2.zero). Same one-frame guarantee.
- Right-stick threshold
0.01 (sqrMagnitude) — matches the existing threshold inside ApplyManualOrCounterTilt, so the override engages at the same point the manual-tilt branch would have taken over.
- Indicator (
PourTargetIndicator) — CurrentTarget is now never a hole, so the holeColor branch is dead-but-harmless. Left in place for now (cheap to keep; lets us re-introduce a different indicator later without re-plumbing).
Files modified
Assets/ProtoV2/Scripts/PourController.cs — rewrite.
Assets/ProtoV2/Scripts/Multiplayer/MultiplayerCharacterInput.cs — removed the SetPourInput call in PassInputToController().
Tuning — added in same session
Initial playtest showed the bowl snapping to full 75° tilt in <0.5 s and dumping all blood past the altar instead of streaming into it. Diagnosis: the angle (75°) was fine — confirmed in-play; the *speed* (tiltSpeed = 8, inherited from manual-pour tuning) was the problem. Manual tilt is a deliberate full-stick commit, so 8 makes sense; passive auto-pour needs to *ease* in or the bowl is at dump-angle before the player can read what's happening.
Added one SerializeField on PourController, used only on the auto-pour path:
| Field | Default | Notes |
|---|---|---|
| autoPourTiltSpeed | 2.5f | ~3× slower than manual tiltSpeed (8). Bowl reaches ~23° at 0.25 s, ~46° at 1 s, ~69° at 2 s — gives the player time to read the auto-pour engaging and reposition if it's pointing the wrong way. Auto-pour reuses the manual tiltAngle (75°) as its target. |
ApplyPourTilt now takes (target, angle, lerpSpeed) so the math is parameterised; the auto-pour branch passes (altar, tiltAngle, autoPourTiltSpeed). Tune in the PlayerPrefab Inspector — autoPourTiltSpeed is the dial.
Follow-ups
- Spec todos 4 (light particles tree→player on valid feed) and 5 (completion ignition) still pending — those are the visual feedback that has to land *during* auto-pour to make the cause-effect read.
- The Carrier-Mode jump-button no-op is mildly wasteful UX — possible future: in-match tutorial prompts could omit the jump glyph in Carrier Mode entirely. ([[2026-05-25_in-match-tutorial-prompts]])
- The
Options/Pouring Mechanic & Movement Button Redesign.md brainstorm doc still references the manual-pour mechanic; it's in Options/ (in-progress design folder, not a Confirmed spec) so it's historical context, not authoritative — leave as a snapshot of pre-decision thinking.
Vault docs reconciled in same session
Confirmed/mechanics/Pouring Mechanic.md — full rewrite against the auto-pour reality (button mapping section removed, hole target removed, tuning table updated, "what changed from the 2026-03-15 spec" change-log added).
Confirmed/mechanics/Manual Tilt System.md — Pour Interaction section flipped (auto-pour engages by default, manual right-stick tilt preempts), tuning summary adds autoPourTiltSpeed + pourRange, Systems-to-Change row annotated to flag the _pourActive removal.
Daily Focus.md — item 1c marked done.
Related
- Spec:
Games/Ritual & Ruin/Problems/Active/2026-05-18_altar-identity-and-completion-feel.md (todo 3, this entry).
- Pattern:
2026-05-21_DispenseOnStand_BloodEmission.md — emitter.speed gating on BloodEmitter, the mirror-image proximity-driven Obi behaviour on the emit side.
- Design principle:
Confirmed/Design Principles.md P1 — "Depth is for space, not precision."
---
---
title: Framework A — Clinical Toon Wall Spike
date: 2026-05-27
tags: [ritual-ruin, shaders, walls, framework-a, visual-pipeline, spike]
---
Framework A — Clinical Toon Wall Spike
Context
User asked for a holistic visual pipeline rebuild ("looks like horrible placeholders" — see [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison]] §1–4). Researcher synthesized 3 frameworks from 8 reference games (§5.3); recommended Framework A — Clinical Toon (Death's Door + Sable hybrid): cel-shaded materials, palette-LUT-driven, surgical character outlines, light-character-on-dim-environment readability.
Key recon finding: Assets/ProtoV2/Shaders/ChunkFloorTile.shader is already cel-shaded (3-band celShade(), _AmbientFloor, GetMainLight + soft shadow). The floor is already partly Framework A; the walls are what's breaking visual unity (unlit/procedural via BoundaryWallAperturePanels.shader). The cheap first step is matching the wall to the floor's existing lighting model.
What changed (3 files)
NEW: `Assets/ProtoV2/Shaders/BoundaryWallToon.shader`
Cel-shaded transparent wall shader that mirrors ChunkFloorTile.shader's lighting curve:
_CelBands (default 3.0) + _AmbientFloor (0.18) + celShade() identical to floor → walls + floor share the same lighting ramp
_BaseColor / _Color properties so PaletteApplier.WriteIfUnlocked writes to _BaseColor keep working
- Subtle
_VerticalGradient (default 0.25) — slightly brighter at top, darker at bottom of wall (world-Y based) for fake top-down lighting depth
- Flat fill, no procedural panel/decay/halftone — bands ARE the surface detail
float4 (not half4) in CBUFFER per [[2026-05-24_WallTexturedMode_Concrete034#Session 3]] shader-load-failure pattern
- Transparency: queue 2950, Blend SrcAlpha OneMinusSrcAlpha, ZWrite Off, Cull Off,
IgnoreProjector="True"
DepthOnly pass kept; ShadowCaster deliberately omitted (transparent walls shouldn't cast shadows)
- Fallback chain:
Shader.Find("ProtoV2/BoundaryWallToon") → URP/Unlit → Unlit/Color → InternalErrorShader (matches existing pattern)
MODIFIED: `Assets/ProtoV2/Scripts/FloorSystem/BoundaryWalls.cs`
- Added
WallTextureMode.ClinicalToon enum value at the end (preserves serialized indices of existing modes — no scene/prefab corruption risk)
PickShader: routes ClinicalToon to ProtoV2/BoundaryWallToon; existing Aperture path unchanged
BuildWallMaterials: added a second Debug.LogError branch for ClinicalToon shader-load failure detection (mirrors the existing Aperture branch)
BuildWallMaterials: ApplyAperturePreset now gated on (not Flat) AND (not ClinicalToon) — toon mode uses shader defaults, not Aperture params
GetWallPatternScalarName: returns null for ClinicalToon (no _PanelSizeX scalar; spacing cycle skips it gracefully)
MODIFIED: `Assets/ProtoV2/Scripts/Editor/ColorPalettePreviewWindow.cs`
- Added
WallTextureMode.ClinicalToon to the modes array in CycleWallTextures and CycleAllCombinations (single replace_all covered both occurrences — they're identical arrays)
- Captures will produce
combo_{palette}_wall_ClinicalToon.png files in PaletteCaptures/
Verification protocol (pending — needs Unity)
1. Open Unity, wait for asset DB recompile
2. Console should show 0 shader compile errors. If BoundaryWallToon errors out, watch for the new LogError once a BoundaryWalls builds materials with ClinicalToon active.
3. Open MatchScene, select BoundaryWalls GameObject, change Texture Mode to ClinicalToon
4. Enter Play Mode
5. Window → Ritual & Ruin → Color Palette Preview → Cycle all combinations
6. Verify PaletteCaptures/combo_*_wall_ClinicalToon.png exists (6 files, one per palette combo)
7. Compare against existing combo_*_wall_AperturePanels.png / combo_*_wall_Flat.png captures
Expected visual outcome
- Walls render as flat palette-tinted surfaces with subtle 3-band cel shading + soft vertical brightness gradient
- Wall + floor now share the same cel ramp → unified lighting language across the playable area
- No panel grid, no decay overlay, no procedural noise
- Front wall (Wall_NegZ) stays near-invisible as before (unchanged)
Known caveat (user-flagged)
Lighting is not yet designed. Scene has only "one generic light" per user statement. With a single uniform directional, cel-shaded walls will show the same band orientation everywhere → no atmospheric depth per palette. Framework A's full read ("lit characters on dim environment", per-palette warm/cool sun) requires a proper lighting rig:
- Key directional (per-palette sun color)
- Fill (per-palette ambient/cool)
- Altar accent point/spot (per-palette accent — the "specimen on slide" focal)
- Optional back rim
This spike intentionally ships shader-only first to validate the *surface* change is sound before doing the bigger lighting rebuild. Lighting design is the next layer; will need extending ColorPaletteSO with sun/fill/accent fields and extending PaletteApplier to drive scene lights atomically.
Bloom audit also pending
Per [[../../../../../../.claude/projects/E--Unity-Projects-PrototypeV2/memory/project_bloom_tint_gotcha|bloom-tint gotcha]]: current gameplay bloom is #00FF41 UI-phosphor green, threshold 0.75. If _BaseColor.a × cel result lands > 0.75 luminance on the toon walls in any palette, they'll bloom green. Audit on first capture; may need separate env-bloom volume.
Related
- [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison]] — full framework analysis + recommendation
- [[2026-05-24_WallTexturedMode_Concrete034]] — Aperture-panel pipeline being superseded
- [[../../../../../../.claude/projects/E--Unity-Projects-PrototypeV2/memory/project_world_geometry|memory: world geometry]]
---
---
title: Visual Pipeline — Wave 1 + Wave 2 Shipped, Framework A Validated
date: 2026-05-27
tags: [ritual-ruin, visual-pipeline, framework-a, lighting-rig, palette-applier, wave-1, wave-2]
---
Visual Pipeline — Wave 1 + Wave 2 Shipped, Framework A Validated
Why
User committed to building all 3 visual pipeline frameworks (A/B/C) per [[../../Games/Ritual & Ruin/Options/Visual Pipeline Build Plan|build plan]]. Wave 1 = shared infrastructure (unblocks everything). Wave 2 = Framework A (Clinical Toon — Death's Door + Sable hybrid). Previous earlier-today spike ([[2026-05-27_FrameworkA_ClinicalToonWall_Spike]]) shipped the wall shader alone but produced visually-indistinguishable captures (§6 of comparison doc) because the scene had only one generic light — proved that the shader change is necessary but not sufficient.
What changed
Wave 1 — Shared Infrastructure
Code (delegated to executor agent in 7 h scoped task — landed in ~3 min real time):
Assets/ProtoV2/Scripts/ColorSystem/ColorPaletteSO.cs — appended top-level LightingPalette struct (sun/fill/accent/backRim HDR colors, ambientSky, fog, fogDensity, 4× intensity floats) + PostPalette struct (colorGradingLUT, bloomThreshold, bloomIntensity, bloomTint) + .Default factories.
Assets/ProtoV2/Scripts/ColorSystem/VisualPipelineSO.cs (new) — framework preset SO holding material overrides, post profile, lighting override, and framework-specific keyword flags (fogEnabled, outlinesEnabled, apvSampling). Menu: Assets > Create > Ritual & Ruin > Visual Pipeline Preset.
Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs — added _activePipeline, 4× light slots, 2× volume slots, LightingLocks + PostLocks structs. New methods ApplyPipelineMaterials(), ApplyLighting(), ApplyPost() wired into Apply(). OnValidate delayCall covers everything.
PaletteCaptures/README.md (new) — capture filename schema docs.
Scene-side (via Unity MCP):
- Created
Lighting/ empty parent + 4 child lights in MatchScene.unity:
KeyDirectional (Mixed, soft shadows, rotation 45/-30/0)
FillDirectional (Baked, no shadows, rotation -30/150/0)
AltarAccent (Point, range 6, realtime)
BackRim (Realtime, no shadows, rotation -15/180/0)
- Disabled the legacy "Directional Light" GO (renamed unchanged but
activeSelf=false).
- Duplicated
GameplayBloomProfile.asset → EnvironmentBloomProfile.asset + UIPhosphorBloomProfile.asset. Added Volume_EnvironmentBloom (priority 0) + Volume_UIBloom (priority 10) GOs in scene.
- Wired all 4 lights + both volumes into
PaletteApplier SerializeField slots.
- Set
BoundaryWalls.TextureMode = ClinicalToon.
Wave 2 — Framework A (Clinical Toon)
Code (delegated to executor agent):
Assets/ProtoV2/Shaders/ToonLitOutlined.shader (new) — 2-pass URP shader: inverted-hull outline pass (Cull Front, normal-expanded, clip-space-stable width) + lit forward pass (3-band cel ramp matching ChunkFloorTile's model). Opaque, with ShadowCaster + DepthOnly passes.
Assets/ProtoV2/Materials/Player_ToonOutlined.mat, Altar_ToonOutlined.mat (new) — palette-driven.
Assets/ProtoV2/Data/VisualPipelines/Framework_A_ClinicalToon.asset (new) — VisualPipelineSO instance referencing the materials + the existing EnvironmentBloomProfile.
Palette tuning (6 ColorPaletteSO assets via Unity MCP manage_scriptable_object):
- Per-palette
LightingPalette colors + intensities populated (sun/fill/accent/backRim + ambient + fog). See doc §7 for the per-combo tone strategy.
- All 6
PostPalette: bloomThreshold 1.2 (HDR-only — environment doesn't bloom), bloomIntensity 0.3, bloomTint white.
Wiring + capture:
PaletteApplier._activePipeline → Framework_A_ClinicalToon.asset.
- Entered Play Mode via MCP, ran
Cycle visual pipelines × palettes (all combos) from the wizard, 18 captures landed in PaletteCaptures/pipeline_*.png.
Verification
Captures embedded in [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison|comparison doc §7]] (mirror at Options/Wall Captures/Wave 2 Captures/).
Direct comparison vs §6 (Framework A shader alone, no lighting rig):
- §6: ClinicalToon ≈ Flat across all 6 palettes (cel-ramp produced no visible variation under single uniform light)
- §7: per-palette atmospheric differentiation is now real. Combo 5 (Twilight Lab) is the strongest showcase — cool teal sun + magenta altar accent. Combo 3 (Night Clinic) close second. Combo 1 (Corrupted Cathedral) more muted because warm sun + red accent are similar tones.
Console clean (only unrelated input-scheme warnings during Play Mode).
Empirically confirms the user's 2026-05-27 morning flag that *"our lighting is not optimized"* was the gating issue. Shader change + lighting design + palette-driven light atomic swap together unlock Framework A.
Bloom tint gotcha resolved
Per [[../../../../../../.claude/projects/E--Unity-Projects-PrototypeV2/memory/project_bloom_tint_gotcha|memory]]: the old GameplayBloomProfile (#00FF41 UI-phosphor tint) was applying scene-wide and green-tinting cream walls. Now split: Volume_EnvironmentBloom (env, no tint, threshold 1.2 — HDR-only) + Volume_UIBloom (UI phosphor, kept green tint, threshold 0.95). Memory file still valid but resolution shipped — should update.
Architectural limitations surfaced
- The iso top-down camera shows walls only as thin slivers at frame edges + tilted back-wall band. Most pixels are floor + props. Wall shader differences will always read more subtly than floor/lighting/post changes in this view — not a Framework A flaw, a camera composition reality.
VisualPipelineSO lacks a playerMaterial slot — Framework A's Player_ToonOutlined.mat is shipped but not auto-applied to the player prefab. Inverted-hull outlines aren't visible in captures yet. Deferred to polish phase.
BoundaryWalls.SetWallMaterial(Material) not yet implemented — Framework B/C wall material swaps will need this. Deferred to Wave 3 prep.
Follow-ups
- Wave 3a: Framework B (Lit Flat + Fog) — 16 h. Reuses everything from Wave 1; only changes wall shader, adds fog, adds glow planes, swaps lighting palette intensities.
- Wave 3b: Framework C (Painterly Baked Overlay) — 36 h. APV setup + painterly overlay shader + Obi fluid shader fork.
- Wave 4: 3-way capture sweep + decision write-up.
Files modified this session (full list)
Code: ColorPaletteSO.cs, PaletteApplier.cs, VisualPipelineSO.cs (new), BoundaryWalls.cs (Wave 0 spike), ColorPalettePreviewWindow.cs (Wave 0 spike + Wave 1 update), BoundaryWallToon.shader (Wave 0 spike), ToonLitOutlined.shader (new).
Materials: Player_ToonOutlined.mat (new), Altar_ToonOutlined.mat (new).
SOs: Framework_A_ClinicalToon.asset (new), 6× Combo*_*.asset (lighting + post patched).
Scene: MatchScene.unity (lights + volumes + applier wiring).
Volumes: EnvironmentBloomProfile.asset (new), UIPhosphorBloomProfile.asset (new).
Docs: Visual Pipeline Build Plan.md (new), Visual Pipeline Direction Comparison.md §7 (new), PaletteCaptures/README.md (new).
Related
- [[../../Games/Ritual & Ruin/Options/Visual Pipeline Build Plan]] — the build plan being executed
- [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison]] — captures + verdict
- [[2026-05-27_FrameworkA_ClinicalToonWall_Spike]] — earlier-today spike that motivated the full build
- [[2026-05-24_WallTexturedMode_Concrete034]] — Aperture pipeline (now superseded but kept as fallback mode)
---
---
title: Visual Pipeline — F1-F6 Polish Pass (Final Wire-up)
date: 2026-05-28
tags: [ritual-ruin, visual-pipeline, framework-a, framework-b, framework-c, polish]
---
Visual Pipeline — F1-F6 Polish Pass
Why
After the §8 5-way capture sweep, the user reviewed all 30 captures and identified six gaps: tentacles not outlined, pillars/altars flat in Framework C, Framework B's glow planes reading as Photoshop overlays, missing rim lights, wall panels missing, environment-side parity between A and C. Implemented all six in a single session.
What changed
F1 — Tentacle outlines for Framework A
- New material:
Assets/ProtoV2/Materials/TentacleOutline.mat (URP/Unlit, _BaseColor = (0,0,0,1))
Assets/ProtoV2/Scripts/JellyfishVisuals.cs — each tentacle now spawns a child Tentacle_X_Outline GameObject with its own LineRenderer at 1.4× width. Initial sibling-component approach was rejected by Unity ("Can't add component 'LineRenderer' to Tentacle_0 because such a component is already added"). Fixed with child-GO architecture.
- Public method
SetTentacleOutlineEnabled(bool) flips all outline LineRenderers on/off.
Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs — BroadcastPlayerOutlines() now also finds all JellyfishVisuals instances and calls SetTentacleOutlineEnabled with the same bool as the body outline toggle.
F2 — Framework C light bump
Assets/ProtoV2/Data/VisualPipelines/Framework_C_PainterlyOverlay.asset — lightingOverride.sunIntensity 0 → 0.2 (with warm (0.85, 0.78, 0.85) color), fillIntensity 0 → 0.15 (with cool (0.45, 0.50, 0.60)), accentIntensity 0 → 0.3.
- URP/Lit pillars + altars now receive minimum directional shading instead of going pure-ambient flat. Painterly walls + floor unaffected (their shader is URP/Unlit by design).
- Decision: didn't port pillars + altars to a dedicated painterly-opaque shader. The light bump preserves Framework C's "mostly unlit" aesthetic while preventing obvious flatness — lighter touch.
F3 — Glow plane reshape
- MatchScene:
RitualGlowPlane_Bottom rescaled from (2,1,2) → (0.6,1,0.6) — 20×20u → 6×6u footprint.
GlowPlanePaletteBinding._intensityMultiplier 2.0 → 0.7.
RitualGlowPlane_Mid rescaled to near-zero (0.0001) since MCP couldn't delete it cleanly without unlinking the prefab. Functionally hidden.
F4 — PlayerRim light
- MatchScene: new 5th light
Lighting/PlayerRim — Directional, rotation (35, 200, 0), intensity 1.0, color (1, 0.95, 0.85), shadows off, lightmapBakeType Realtime.
- Static (not yet palette-driven) — provides global rim wash to character silhouettes.
F5 — Panel grid shader overlay
- New include:
Assets/ProtoV2/Shaders/PanelGrid.hlsl with ApplyPanelGrid(color, positionWS, normalWS, panelSize, gridStrength, gridColor). Dominant-plane projection (Z+Y for X-facing walls, X+Y for Z-facing walls). Anti-aliased via fwidth.
- All 3 wall shaders modified:
BoundaryWallToon.shader (Framework A)
BoundaryWallLitFlat.shader (Framework B)
BoundaryWallPainterly.shader (Framework C)
- Each shader added: include directive, 3 new CBUFFER fields (
_PanelGridStrength, _PanelSize, _PanelGridColor), 3 new Properties (defaults: 0.4, 2.0u, black), and ApplyPanelGrid call before MixFog in fragment.
- Walls now show clean 2-unit square panel divisions across all frameworks.
F6 — Per-floor accent lights
- MatchScene: 2 additional point lights added under
Lighting/:
AltarAccent_Mid at (0, -8, 0), Point, range 12, intensity 1.5, warm color (1, 0.5, 0.3)
AltarAccent_Bottom at (0, -17, 0), Point, range 12, intensity 1.2, same color
- Static palette-independent fallback. Polish item: extend PaletteApplier to drive these from palette accent (currently only the 4 core lights are palette-broadcast).
Verification
read_console after final cycle: 0 errors. Only unrelated input-scheme warnings persist.
- First cycle attempt surfaced the
LineRenderer AddComponent bug — fixed in-session by switching to child-GO architecture.
- Captures landed in
PaletteCaptures/pipeline_{Mode}_{Palette}.png (30 files) and mirrored to playinstigator_docs/.../Wall Captures/Wave 4 Captures/.
Visual outcomes
Framework A (Clinical Toon):
- Tentacles now have thin black halo.
- PlayerRim adds slight back-edge glow.
- Wall panel grid visible on side + back walls.
- Per-floor accent lights add warmth to mid + bottom floors.
Framework B (Lit Flat + Fog):
- Glow plane no longer dominates frame — reads as focused altar warmth.
- Wall panel grid visible underneath 4-band cel shading.
- Atmospheric depth from fog + small focused glow + multi-light.
Framework C (Painterly Baked Overlay):
- Pillars + altar now show shading (the most visible win — they were flat before).
- Wall panel grid divisions clearly visible (probably most striking here because the painterly shader has the calmest surface).
- Per-floor accents add warmth without breaking the "unlit by design" feel.
Decision matrix (unchanged)
Three viable frameworks, decision is tonal. Framework A still the default recommendation; B + C are real alternates. Pick whenever ready.
Files modified this session
Code
Assets/ProtoV2/Scripts/JellyfishVisuals.cs (tentacle outline support + post-fix to child-GO)
Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs (broadcast extended for tentacles)
Shaders
Assets/ProtoV2/Shaders/PanelGrid.hlsl (new)
Assets/ProtoV2/Shaders/BoundaryWallToon.shader (+ panel grid)
Assets/ProtoV2/Shaders/BoundaryWallLitFlat.shader (+ panel grid)
Assets/ProtoV2/Shaders/BoundaryWallPainterly.shader (+ panel grid)
Materials
Assets/ProtoV2/Materials/TentacleOutline.mat (new)
Scene
Assets/ProtoV2/Scenes/MatchScene.unity — added PlayerRim, AltarAccent_Mid, AltarAccent_Bottom lights; resized/hid RitualGlowPlane instances.
Prefab
Assets/ProtoV2/Prefabs/PlayerPrefab.prefab — added _outlineWidthMultiplier + _tentacleOutlineMaterial SerializeFields on JellyfishVisuals component.
SO
Assets/ProtoV2/Data/VisualPipelines/Framework_C_PainterlyOverlay.asset — lightingOverride intensities bumped.
Docs
Visual Pipeline Direction Comparison.md §9 (new section), TL;DR + status header updated.
Daily Focus.md — pipeline-pick todo updated with F1-F6 completion note.
Related
- [[2026-05-28_VisualPipeline_PipelineSwapFix_FrameworkB_Rehabilitated]] — earlier-today fix that unblocked the wire-up phase
- [[2026-05-27_VisualPipeline_Wave1_Wave2_FrameworkA_Validated]] — the bigger build-out session
- [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison]] — main analysis doc (§9 = final state)
- [[../../Games/Ritual & Ruin/Options/Visual Pipeline Build Plan]] — original build plan (complete)
---
---
title: Visual Pipeline — Pipeline-Swap Fix Rehabilitates Framework B
date: 2026-05-28
tags: [ritual-ruin, visual-pipeline, framework-a, framework-b, framework-c, lighting-rig, shader-fix]
---
Visual Pipeline — Pipeline-Swap Fix Rehabilitates Framework B
Why
Wave 4 (yesterday) shipped all 3 framework presets and ran a 5-way comparison sweep. The initial read (§8 verdict at the time) flagged Framework B as "overcooked / parked" because Combo 1 + Combo 2 LitFlat captures blew out to bright cyan. Today's re-cycle proves that diagnosis was wrong — Framework B's lighting override never actually applied during the previous captures because the cycle button only swapped WallTextureMode, not _activePipeline. Framework A's lighting was leaking into LitFlat shader renders.
What changed
Code
Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs — added public VisualPipelineSO ActivePipeline => _activePipeline; accessor + public void SetActivePipeline(VisualPipelineSO) method that swaps the preset and re-applies.
Assets/ProtoV2/Scripts/Editor/ColorPalettePreviewWindow.cs — added static ResolvePipelineForMode(WallTextureMode) lookup that maps each mode to its matching VisualPipelineSO asset (ClinicalToon → Framework A, LitFlat → B, PainterlyOverlay → C, Flat/AperturePanels → null). Updated both CycleVisualPipelines and CycleVisualPipelinesAllPalettes to call _applier.SetActivePipeline(ResolvePipelineForMode(mode)) BEFORE the wall mode swap, AND to preserve+restore the original _activePipeline at cycle start/end.
Assets/ProtoV2/Shaders/ToonLitOutlined.shader — fixed compile error. URP 17.2 doesn't expose TransformObjectToHClipDir. Replaced the clip-space-stable expansion math with the URP 17 idiomatic world-space path:
Width is now _OutlineWidth in world units — consistent under both perspective and ortho cameras (works because under ortho the camera's view scale is fixed).
Capture
- Re-ran
Cycle visual pipelines × palettes (all combos) in Play Mode via Unity MCP automation.
- 30 fresh captures (5 modes × 6 palettes) land in
PaletteCaptures/pipeline_{Mode}_{Palette}.png.
- Copied to
playinstigator_docs/Games/Ritual & Ruin/Options/Wall Captures/Wave 4 Captures/ (overwrites prior set — Obsidian embeds in §8 of the comparison doc now show the new images).
Docs
- [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison|comparison doc]] §8.7-8.11 fully rewritten:
- §8.7 verdict: Framework B is rehabilitated (was "parked", now "usable"). Reframe added at top of §8.7 explaining the pipeline-swap bug.
- §8.8 decision matrix: all three frameworks now score ★★★★ or higher on detail readability (was ★★ for B). Removed "blowout" weakness.
- §8.9 recommendation: three viable options matrix by tone (specimen-on-slide A / clinical-lab B / dystopian-blueprint C). Production default still A, but B and C are real alternates not afterthoughts.
- §8.10 follow-ups: per-framework polish lists. B's polish dropped from ~4-6 h to ~30-45 min (glow plane wiring only — shader fix was a false-alarm; the real fix was infrastructure).
- TL;DR + status header at top of doc updated to match.
Verification
read_console: 0 errors after ToonLitOutlined.shader fix (was 1 shader error before). 2 unrelated input-scheme warnings persist (Cannot find matching control scheme for PlayerPrefab(Clone)) — same as previous sessions, not visual-pipeline-related.
- Visual review of fresh LitFlat captures:
- Combo 1 (Corrupted Cathedral): cream-pink walls + tan floor with visible tile grout. No cyan blowout. Reads as believable warm-lab.
- Combo 2 (Operating Theatre): clean cream walls + grey floor. Reads as clinical operating room.
- Combo 5 (Twilight Lab): dark teal walls + cyan grout grid floor. Tron-corridor read.
- Combo 3 (Night Clinic): dark teal palette throughout. Cool clinical horror tone.
- Visual review of PainterlyOverlay captures: all 6 palettes read as deliberate "blueprint dystopian" — most controlled cross-palette consistency of any framework.
- File sizes confirm distinct outputs per (palette, mode) combination — no two captures collide on identical pixel content.
Why Framework B's earlier "blowout" was misdiagnosed
The previous read attributed the cyan blowout to a multi-light additive sum issue in BoundaryWallLitFlat.shader / ChunkFloorTile_4Band.shader. That diagnosis assumed Framework B's lighting override (warmer sun + softer fill) was actually applied during the capture. It wasn't.
Sequence during the bug:
1. Cycle button enters Play Mode with _activePipeline = Framework_A_ClinicalToon
2. Cycle iterates modes: Flat → AperturePanels → ClinicalToon → LitFlat → PainterlyOverlay
3. At LitFlat step: walls.SetWallTextureMode(LitFlat) swaps the wall shader to BoundaryWallLitFlat.shader. But _activePipeline stays at Framework A.
4. PaletteApplier.Apply() runs (triggered by palette change) → ApplyLighting() reads _activePalette.lighting (which is the per-palette LightingPalette, Framework A-tuned)
5. Lights remain at Framework A's high-contrast values: sun 1.3, fill 0.4, accent 2.5
6. The new BoundaryWallLitFlat shader does its multi-light loop over those Framework-A-intense lights → multi-light sum × 4-band ramp → bright cyan on cream palettes
Fix today:
1. Cycle now calls _applier.SetActivePipeline(ResolvePipelineForMode(mode)) BEFORE the wall mode swap
2. For LitFlat, that swaps to Framework_B_LitFlatFog SO which has useLightingOverride = true
3. ApplyLighting() now reads the override values: sun 1.0 (warmer), fill 0.6 (brighter ambient), accent 1.5 (dimmer)
4. Multi-light loop receives sane intensities → cel ramp doesn't saturate
5. Floor reads correctly across all palettes
Architecture lesson: WallTextureMode and VisualPipelineSO are not independent axes. They must swap together for fair captures. Adding the explicit SetActivePipeline API on PaletteApplier + the ResolvePipelineForMode lookup on the cycle is the right invariant.
Open follow-ups (none blocking)
Per the revised [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison|§8.10]]:
- Framework A polish (~1.5 h): wire
Player_ToonOutlined.mat to player prefab, Combo 1 accent intensity bump, wall alpha 0.85 → 0.95 experiment
- Framework B finish (~45 min): instantiate 2-3
RitualGlowPlane.prefab in MatchScene + wire PaletteApplier.ApplyLighting() to broadcast LightingPalette.accent to GlowPlanePaletteBinding.SetGlowColor()
- Framework C finish (~1 h + optional 6 h): APV Probe Volume placement + bake. Obi fluid surface shader fork only if shipping C as primary.
All three frameworks are technically ready. Decision next session is which one to commit to (or to wire all three as a runtime graphics setting — already supported via _activePipeline slot).
Files modified this session
Assets/ProtoV2/Scripts/ColorSystem/PaletteApplier.cs
Assets/ProtoV2/Scripts/Editor/ColorPalettePreviewWindow.cs
Assets/ProtoV2/Shaders/ToonLitOutlined.shader
playinstigator_docs/Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison.md (§8.7–8.11 + TL;DR + status header)
playinstigator_docs/Games/Ritual & Ruin/Options/Wall Captures/Wave 4 Captures/*.png (30 fresh captures, overwrites)
Related
- [[2026-05-27_VisualPipeline_Wave1_Wave2_FrameworkA_Validated]] — yesterday's wave 1+2 ship
- [[2026-05-27_FrameworkA_ClinicalToonWall_Spike]] — initial wall shader spike
- [[../../Games/Ritual & Ruin/Options/Visual Pipeline Direction Comparison]] — main analysis doc (§8)
- [[../../Games/Ritual & Ruin/Options/Visual Pipeline Build Plan]] — the build plan being executed (now complete)
---
Auto-tilt Y-proximity gate — stops auto-pour at altars on other floors
Implemented: 2026-05-29
Type: Fix
Area: Player / Altar interaction
Player impact
Carrier-Mode auto-pour no longer engages when the creature is a floor above (or below) an altar that happens to line up in the XZ projection. Previously, flying up in jellyfish mode and transforming back to Carrier Mode over an altar's XZ footprint made the bowl tilt at empty air across the floor gap (and risked draining blood down through the floor). Auto-pour now requires the altar to be on the same floor.
Root cause
PourController.GetNearestAltarInRange() selected altars by XZ distance only (XZDistance, which drops the Y component). Any altar within pourRange (3u) in the XZ plane registered as in-range regardless of vertical separation, so an altar a full floor away (5u in Y) still triggered auto-pour.
What changed
Assets/ProtoV2/Scripts/PourController.cs:
- Added
[SerializeField] float pourYRange (fallback 2.5f). In Start() it is auto-derived as FloorManager.FloorVerticalSpacing * 0.5f (= 2.5u at the current 5u spacing), so it adapts if floor spacing changes.
- Added a Y gate in
GetNearestAltarInRange(): if (Mathf.Abs(here.y - altar.transform.position.y) > pourYRange) continue; — applied before the XZ comparison. Nearest-by-XZ selection among the survivors is unchanged.
Half the floor spacing keeps the gate comfortably inside one floor while excluding the floors above and below, each a full spacing (5u) away in Y.
> Threshold note: the original problem doc assumed ~10u floor spacing and proposed a flat 3u gate. Actual spacing is floorVerticalSpacing = 5f (FloorManager.cs:41, ScrollingFloorCamera.cs:31), so the derived half-spacing (2.5u) was chosen over a hard-coded 3u for a symmetric exclusion margin.
Verification
- Unity script compile: clean, zero errors (
read_console filtered to errors → 0 entries after refresh_unity compile).
- Behavioural cases to confirm in Play Mode (pending playtest):
- Carrier one floor above an altar, matching XZ → no auto-tilt (delta-Y = 5+ u > 2.5u gate).
- Carrier next to a same-floor altar within 3u XZ → auto-tilt fires as before.
- Carrier mid-air between floors → no auto-tilt.
Follow-ups
- Play-Mode confirmation of the three cases above.
- Gizmo (
OnDrawGizmosSelected) still draws an infinite-Y wire disc for pourRange; could draw a cylinder bounded by pourYRange for accuracy. Low priority.
Session 2 — don't auto-tilt toward completed altars
Type: Fix · Area: Player / Altar interaction
Player impact
The carrier no longer auto-tilts toward an altar whose ritual is already done. It also stops auto-pouring the moment the meter fills and the ritual countdown begins (blood is rejected during countdown anyway), instead of dumping uselessly at a finished altar.
Root cause
AltarParticleConsumer.CompleteRitual() sets isConsumed = true but never clears isActive — a completed altar keeps IsActive == true. PourController.GetNearestAltarInRange() filtered only on !IsActive, so consumed altars stayed valid auto-pour targets forever.
What changed
Assets/ProtoV2/Scripts/BloodSystem/AltarParticleConsumer.cs — added public bool IsAcceptingBlood => isActive && !isConsumed && !isCountingDown; (single source of truth mirroring the AddBlood guard).
Assets/ProtoV2/Scripts/PourController.cs — GetNearestAltarInRange() filter changed from !altar.IsActive to !altar.IsAcceptingBlood. This excludes consumed altars (the reported bug) and also full/counting-down altars that reject blood. If a countdown is interrupted (InterruptCountdown), the altar accepts blood again and becomes re-eligible automatically (state is read per-frame).
Verification
- Unity script compile: clean, 0 errors.
- Pending Play-Mode confirmation: pour an altar to completion → auto-tilt releases once the meter fills; carrier near a consumed altar → no auto-tilt.
Related
- [[2026-05-27_AutoPourNearAltar_ManualPourRemoved]] — original auto-pour implementation that introduced the XZ-only check.
- Problem doc:
Problems/Active/2026-05-29_auto-tilt-ignores-y-distance.md → resolved.
Confirmed/mechanics/Manual Tilt System.md — tuning table updated with pourYRange.
---
2026-05-29 — CRT / Screen-Effects Renderer Feature + Variations Comparison
What changed
Built a real URP full-screen post-processing system for "digital screen" effects and captured an in-engine variations comparison. Escalation of the [[../../Games/Ritual & Ruin/Problems/Archive/2026-05-29_crt-bloom-not-visible|CRT + Bloom not visible]] problem to its Phase-3 (variations matrix) — but done as actual implementations, not simulations, per user direction.
New files:
Assets/ProtoV2/Shaders/ScreenFX.shader — über full-screen shader. One pass, all effects as float params, identity when 0. Effects: barrel curvature + black border, edge-weighted chromatic aberration (URP 3-tap), soft sine scanlines (resolution-independent via line count), aperture/shadow mask, vignette, animated grain, rolling refresh bar, VHS line jitter + chroma bleed + head-switch band, datamosh block tear/corruption, pixelation snap, posterize + 4×4 Bayer dither, phosphor monochrome tint. Built on the URP 17 fullscreen contract (Vert/Varyings/_BlitTexture from core/Runtime/Utilities/Blit.hlsl); CA/vignette math mirrors URP's own UberPost.shader.
Assets/ProtoV2/Scripts/Editor/ScreenFXTool.cs — Tools/ScreenFX/ menu: (1) Install renderer feature, (2) Capture All (Play Mode), Uninstall. Defines the 7 presets and the capture sweep.
Assets/ProtoV2/Materials/ScreenFX_Mat.mat — material instance (defaults to Off/passthrough).
Modified:
Assets/Settings/PC_Renderer.asset — registered a FullScreenPassRendererFeature named ScreenEffect at AfterRenderingPostProcessing, fetchColorBuffer = true, pointing at ScreenFX_Mat. Registration replicates URP's ScriptableRendererDataEditor.AddComponent (append to m_RendererFeatures + the sub-asset localId to m_RendererFeatureMap).
Why
The shipped CRT was a Canvas ScreenSpaceOverlay (CRTOverlayController + CRTOverlay.shader) that samples a white _MainTex — so curvature/CA/pixelation never touched real game pixels, and it never spawned when entering Play directly in MatchScene (hence "CRT invisible in captures"). The renderer-feature approach fixes both: effects run on the actual post-tonemap frame, in correct order (grade → bloom → ScreenFX), live in-game and in captures.
Verification
- Shader compiles clean (fixed two errors:
_BlitTexture_TexelSize is float4 not float2; line is a reserved HLSL keyword → renamed scanv). read_console shows zero errors.
- Feature registered correctly:
m_RendererFeatures has 3 entries, ScreenEffect with injectionPoint: 600, fetchColorBuffer: 1, active.
- Captured 19 frames (Combos 1/3/5 × presets 0–5, + Datamosh on Combo 5) at 1920×1080 in Play Mode. Visually confirmed each preset:
- Heavy CRT shows real barrel curvature (floor grid bulges, black corners) → proves it's a true post pass, not an overlay.
- Pixel CRT shows chunky pixelation + posterize; Phosphor Terminal = green monochrome; VHS = jitter + bottom head-switch band; Datamosh = block tear + RGB smear.
- Captures + verdict written to [[../../Games/Ritual & Ruin/Options/CRT and Screen Effects Comparison|CRT and Screen Effects Comparison]]. Research catalog in [[../../Games/Ritual & Ruin/Options/CRT and Screen Effects Research|CRT and Screen Effects Research]].
Recommendation / follow-ups
- Ship Subtle Monitor as the default mapped to the existing Gameplay CRT-intensity slider; keep Phosphor Terminal for terminal/menu, Datamosh Burst for
GlitchBurst() events; treat Pixel CRT as a separate art-direction decision.
- Runtime
ScreenEffectController still to do — drive ScreenFX_Mat from GameSettings.CRTIntensity, expose preset selection + GlitchBurst() on the real frame, then retire CRTOverlayController + CRTOverlay.shader.
- Bloom split/tuning (env neutral white thr~0.85 vs UI green) remains open and orthogonal — see research doc §3.4 + [[project-bloom-tint-gotcha]].
- Feature currently installed at Off (passthrough), so shipped look is unchanged until a controller applies a preset.
Session 2 (2026-05-30) — runtime controller + bloom fix
New:
Assets/ProtoV2/Scripts/Rendering/ScreenFXPresets.cs — runtime-shared preset enum + Apply(mat, preset[, intensity]); single source of truth for both the editor capture tool and the runtime controller. Intensity scales the perceptual params (scanline/vignette/CA/grain/etc.), leaves structural ones (pixelate/posterize) alone; 0 → identity.
Assets/ProtoV2/Scripts/Rendering/ScreenEffectController.cs — self-bootstraps via [RuntimeInitializeOnLoadMethod(AfterSceneLoad)] (DontDestroyOnLoad, no scene wiring). Loads ScreenFX_Mat from Resources, drives it from GameSettings.CRTIntensity (+OnCRTIntensityChanged), default preset Subtle Monitor. SetPreset() + GlitchBurst() (real-frame CA/tear/block spike then restore). Disables legacy CRTOverlayController on startup so they don't stack. Resets material to Off in OnDestroy.
Changed:
- Moved
ScreenFX_Mat.mat (+meta, GUID preserved) Materials/ → Resources/ so the controller can Resources.Load the same asset the feature renders. ScreenFXTool MaterialPath + preset calls updated to use ScreenFXPresets. Added Application.runInBackground = true in the capture cycle (Play Mode stalls the delayCall chain when the editor loses focus — that's why the first re-capture hung; fixed).
- Bloom: all 6
ColorPaletteSO.PostPalette bloomThreshold 1.2 → 0.85, bloomIntensity 0.3 → 0.4 (white tint unchanged). Root cause of "bloom doesn't fire": threshold 1.2 is HDR-only and env albedo is ≤1.0. The green-tint concern from [[project-bloom-tint-gotcha]] was a non-issue at runtime — PaletteApplier already writes white from each palette to _environmentBloomVolume; the split is effectively in place.
Verification:
- Zero compile errors after each change.
- Controller bootstrap confirmed live in Play:
[ScreenEffectController] live — preset SubtleMonitor @ CRT 0.00, material bound = True (slider saved at 0 → scales to Off, correct).
- Re-captured all 19 frames with the bloom fix (focus-resilient now).
Off baseline shows soft env glow on bright cup/creatures that wasn't there before. Vault captures + [[../../Games/Ritual & Ruin/Options/CRT and Screen Effects Comparison]] updated.
Remaining (optional): retire CRTOverlay.shader + CRTOverlayController entirely (currently just auto-disabled, not deleted); per-palette bloom-intensity fine-tune; wire GlitchBurst() to evolution/Terminal/death events.
Session 3 (2026-05-31) — review-feedback tuning + pixel-size sweep
Review of the rev-1 captures produced 5 concrete asks; all addressed and re-captured in-engine via MCP (entered Play, ran the capture menus, stopped, copied to vault).
Changed:
Assets/ProtoV2/Shaders/ScreenFX.shader (§1 border) — anti-aliased the barrel-curvature edge. Was a hard (cuv in [0,1]) ? 1 : 0 mask → jagged staircase along the curved sides. Now edge = min(min(cuv,1-cuv)), aa = fwidth(edge)*1.5, inB = smoothstep(-aa, aa, edge) — a ~1.5 px resolution-independent feather. Guarded to full-screen when _Curvature < 1e-4 so non-curved presets stay pristine (no 1 px rim darkening).
Assets/ProtoV2/Scripts/Rendering/ScreenFXPresets.cs — grain cut Subtle 0.02→0.006, Heavy 0.04→0.012 (the "grainy texture" complaint); Phosphor tint 0.85→0.6 so palette colours read through the green wash; Pixel CRT 270p→480p (posterize 24→32, scanline count tracks pixel rows) so small elements stay legible.
Assets/ProtoV2/Scripts/Editor/ScreenFXTool.cs — added menu Tools/ScreenFX/3 - Capture Pixel Density Sweep (Play Mode): re-applies Pixel CRT on Combo 5 at _Pixelate ∈ {270,360,480,540,720} and captures one frame each (screenfx_pixeldensity_{N}p_...png) so the pixel size can be picked visually.
Why: user feedback — Subtle/Heavy grain too strong; Phosphor too monochrome (kills colour readability) → keep for UI; VHS/Datamosh too disruptive as constant → event-only bursts (already the design, documented); Pixel CRT interesting but pixels too big for small elements → make them smaller + provide a sweep; curved sides visibly jagged → AA them.
Verification:
- Recompiled (force refresh),
read_console errors = 0 (only the benign "Cannot find matching control scheme" pairing warnings).
- Re-captured all 19 preset frames + 5 density frames = 24 PNGs, copied to
Options/Wall Captures/CRT Screen Effects/. Visually confirmed each change on the real frame: Heavy CRT corners now feather smoothly (no staircase) and static is gone; Phosphor shows red cup + blue jellyfish through the green; 480p pixel reads clearly vs mushy 270p.
- All 24 embeds in [[../../Games/Ritual & Ruin/Options/CRT and Screen Effects Comparison]] resolve to vault files (24/24).
Outcome: comparison doc is rev 2 — added §4 pixel-size sweep + revision note + updated verdict (try Pixel CRT @ 480/540 first; Subtle/Heavy now grain-cut + AA'd as fallbacks; Phosphor = UI; VHS/Datamosh = events). Awaiting the user's ship-default pick.
Session 4 (2026-05-31) — posterize sweep, Obi-blood compat proof, ship-default locked
Posterize sweep: added Tools/ScreenFX/4 - Capture Posterize Sweep (Pixel CRT @ 480p, _Posterize ∈ {32,48,64} → screenfx_posterize_{N}_480p_*.png). Doc §4b. Higher = less colour flattening; at 480p the difference is subtle (pixelation dominates).
Obi blood × Pixel CRT — verified they compose. Investigated render order: ObiFluidRendererFeature and ScreenEffect both on PC_Renderer; Obi's VolumePass.renderPassEvent = AfterRenderingOpaques (composites blood into camera colour pre-post), ScreenFX at AfterRenderingPostProcessing (600, fetchColorBuffer). So blood is in the frame ScreenFX samples → Pixel CRT pixelates the blood with the scene, no special handling. Only caveat = *motion* shimmer (coarse grid aliases moving fluid), not compatibility. Added Tools/ScreenFX/5 - Force Blood Emitters On (reflection-sets BloodEmitter.debugAutoDispense=true, on 60 / off 0 = continuous, for player-free emission) + 6 - Capture Blood: Off + Pixel CRT. Proof captures screenfx_blood_{0_Off,5_PixelCRT}_* → doc §5. (Runtime-only reflection in Play; no game data or third-party code modified.)
Ship default locked: Pixel CRT @ 540p, posterize 48.
ScreenFXPresets.cs — PixelCRT now _Pixelate 540, _Posterize 48, _ScanlineCount 540. Added intensity guard: Apply(mat,preset,t) with t<=0.001 → Reset(Off) so unscaled structural params (pixelate/posterize) don't stick on at slider 0.
ScreenEffectController.cs — default _preset SubtleMonitor → PixelCRT.
- Wiring confirmed:
GameSettings.CRTIntensity defaults 1.0 (line 33/63) → Pixel CRT on at full for new installs; slider scales Off→full. Dev-machine saved value is 0, so it reads off locally until the slider is raised.
Verification: recompiled clean each step (read_console errors = 0 besides the benign control-scheme pairing warnings). Captured the final 540p+48 look in-engine. All 29 doc embeds resolve (24 + posterize 3 + blood 2).
Follow-up raised by user: consider splitting the CRT into individual per-effect sliders — decided below (Session 5).
Session 5 (2026-05-31) — per-effect accessibility sliders
User chose "master + 3 accessibility sliders" (Scanlines / Screen Curvature / Pixelation). Implemented across 4 files + verified in-engine.
Changed:
Settings/GameSettings.cs — 3 new floats ScanlineIntensity / CurvatureIntensity / PixelationIntensity (default 1.0; Load/Save with Settings_* prefix; setters). New combined event OnScreenFXSettingsChanged fired by all 4 ScreenFX setters (incl. SetCRTIntensity).
Rendering/ScreenFXPresets.cs — new overload Apply(mat, preset, intensity, scanline, curvature, pixelation). Scanline/curvature = multipliers on the master-scaled preset; pixelation = structural remap (0 = off/crisp, 1 = preset px, mid = Lerp(1080, basePix, p), guarded to pixel presets only). Cosmetic effects stay master-only.
Rendering/ScreenEffectController.cs — subscribes to OnScreenFXSettingsChanged (replacing the float-event sub), ApplyCurrent() passes the 3 modifiers from GameSettings.
Settings/SettingsMenuBuilder.cs — 3 slider rows (SCANLINES / SCREEN CURVATURE / PIXELATION) after CRT INTENSITY in BuildGameplayContent.
Editor/ScreenFXTool.cs — 7 - Capture CRT Modifier Sweep (Pixel CRT, master 1, each modifier zeroed) for verification/record.
Verification: compiles clean (0 errors). Captured screenfx_mod_{full,noPixel,noScan,noCurve} — confirmed Pixelation 0 → crisp/native while scanlines+curvature persist (structural remap works); other modifiers isolate correctly. Comparison doc §6 added; 33/33 embeds resolve.
Session 6 (2026-05-31) — scene/UI scope: world everywhere + light UI scanlines
Decided two scope questions. Scenes: the world ScreenFX pass already applies in every scene by construction (renderer feature on PC_Renderer + DDOL self-bootstrapping controller) — no change needed. UI: chose "light scanline overlay" (scanlines only, no warp/pixelation) so HUD/menu text stays legible.
New:
Assets/ProtoV2/Shaders/UIScanlineOverlay.shader — transparent, alpha-blended scanlines (half4(_TintColor.rgb, max(scan²·opacity, tintAmount)·intensity·vertexAlpha)). No curvature/pixelation/CA. Distinct from the legacy ProtoV2/CRTOverlay, which is opaque (samples white _MainTex, alpha 1) and would white-out the UI — that's why it can't be reused as a transparent layer.
Assets/ProtoV2/Scripts/UI/UIScanlineOverlay.cs — builds a full-screen ScreenSpaceOverlay canvas (sortOrder 5000), RawImage with raycastTarget=false and no GraphicRaycaster (never blocks input). Loads Resources/UIScanlineOverlay_Mat (build-safe inclusion; falls back to Shader.Find in-editor). Driven by CRTIntensity × ScanlineIntensity via OnScreenFXSettingsChanged. Base scanline opacity 0.12.
Assets/ProtoV2/Resources/UIScanlineOverlay_Mat.mat — created via MCP manage_material.
Editor/ScreenFXTool.cs — 8 - Test Live (CRT on) + Screenshot drives the LIVE controller/overlay (sets GameSettings.SetCRTIntensity(1) — persists) and screenshots the real composited frame.
Changed:
Rendering/ScreenEffectController.cs — Awake now adds a UIScanlineOverlay to its own DontDestroyOnLoad object → present in every scene.
Verification: compiles clean (0 errors); material created OK (shader compiled). Live capture screenfx_live_uiScanlines_* shows faint scanlines over the UI (subtitle panel + corner buttons, which sit above the world pass) with text still readable → overlay confirmed on the UI layer, input not blocked. Comparison doc §7 added; 34/34 embeds resolve.
Side effect: the test menu set this editor's Settings_CRTIntensity to 1 (was 0 from prior testing) — matches the ship default; lower it in Settings ▸ Gameplay if off is wanted locally.
Optional next: add a _TintColor/_TintAmount to the UI overlay material if a faint colour wash is wanted (kept neutral/0 for readability); wire ScreenEffectController.GlitchBurst() to also pulse the UI overlay for event moments.
Session 7 (2026-05-31) — archive legacy pipeline + cleanup + scene/settings verification
Investigation first (Unity MCP was disconnected, so file-level only): GUID search for CRTOverlayController.cs (e5f6a7b8…) and CRTOverlay.shader (a1b2c3d4…) across all *.unity/*.prefab/*.asset (incl. ProjectSettings) → zero hits. The legacy overlay was never placed in any scene/prefab/material or the always-included-shader list — pure dead code (CRTOverlayController.Instance always null; the disable-call in ScreenEffectController a no-op). The only other CRTOverlay mentions were comments in MainMenuController/PauseController.
Archived (deleted; git history = archive):
Assets/ProtoV2/Shaders/CRTOverlay.shader (+meta)
Assets/ProtoV2/Scripts/UI/CRTOverlayController.cs (+meta)
Cleanup:
Rendering/ScreenEffectController.cs — removed the dead legacy-disable block; fixed stale class doc (default was wrongly "Subtle Monitor" → Pixel CRT; dropped the legacy-CRT line, noted the UI overlay).
Settings/GameSettings.cs — removed dead CRTEffect (prop + Load + Save + SetCRTEffect); never read by render code, never wired to UI. CRT off = CRT-intensity slider at 0.
Editor/ScreenFXTool.cs — removed 8 - Test Live menu (it persisted CRTIntensity as a side effect — footgun).
MainMenuController.cs / PauseController.cs — updated stale CRTOverlay comment references → UIScanlineOverlay (the DDOL canvas that now coexists).
Scenes use Pixel CRT — no per-scene edits required. Verified the pipeline is global: PC quality → PC_RPAsset (also the GraphicsSettings default custom RP) → PC_Renderer → active ScreenEffect feature (injectionPoint 600, fetchColorBuffer, m_Active 1). ScreenEffectController self-bootstraps DDOL in every scene with default preset PixelCRT; CRTIntensity defaults 1.0. So MainMenu/Briefs/Lobby/Match all render Pixel CRT automatically. Mobile caveat: the Mobile quality level uses Mobile_Renderer (guid 5e6cbd92…), which does NOT have the ScreenEffect feature — register it there too if Mobile ships.
Settings panel verified (by read). BuildGameplayContent builds CRT INTENSITY + SCANLINES + SCREEN CURVATURE + PIXELATION rows, each via BuildSliderControl (minValue 0 / maxValue 1, initial = saved value, shows %), onValueChanged → GameSettings.Set* → OnScreenFXSettingsChanged → ScreenEffectController.ApplyCurrent() which passes all four to ScreenFXPresets.Apply(...). Wiring is complete and correct.
Live verification (MCP reconnected): force-recompile → read_console 0 errors, 0 CRT warnings (deletions cleanly resolved by the asset DB). Entered Play in MatchScene → controller bootstrap log confirms preset PixelCRT @ CRT 1.00, material bound = True — i.e. default preset is Pixel CRT, it reads GameSettings.CRTIntensity, and the material is bound/rendering. Settings→effect path is the same SetCRTIntensity → OnScreenFXSettingsChanged → ApplyCurrent that was behaviourally exercised in Session 6 (0→1 turned the effect on), so the slider control chain is confirmed end-to-end. Did not drag the physical uGUI slider (MatchScene had unsaved changes, so didn't switch to MainMenu where Settings lives; scene-wide coverage is architecturally guaranteed by the global RP + DDOL controller and the live log). Editor left in MatchScene, Play stopped, CRTIntensity at ship default 1.0.
---
2026-05-29 — In-Match Tutorial Prompts (learn-while-playing teaching layer)
Implements Next-Steps #6 / [[2026-05-25_in-match-tutorial-prompts]]. The demo cut the ExperimentBrief/ObjectiveBrief exposition; this is its replacement teaching layer — 5 well-timed, state-triggered prompts across Level 1 and Level 2 so the demo teaches by playing.
What changed
Assets/ProtoV2/Scripts/OnboardingController.cs — full rewrite. Was a disabled, stale in-match-hint component (referenced the *removed* "pour to teammate below" mechanic + a proximity bonus, and depended on Inspector-wired panels). Now a self-contained tutorial-prompt system:
- Builds its own screen-space overlay at runtime — no scene wiring. Single shared bottom-center line,
sortingOrder 90 (above MatchTimerUI 80, below CRT overlay 200), pixel-perfect, Share Tech Mono + phosphor-glow material via TypographyLibrary, dark backing strip, CanvasGroup fade.
- 5 locked prompts, grouped per level into one stacked block, advanced by the floor scroll:
- L1 (from match start, until the first scroll): "Your creatures are slowly dying." + "Collect blood to survive — hold LT to transform into a cup." + "Bring the blood to the altar."
- L2 (from the first scroll, until the next scroll): "Tilt with the right stick to pour." + "Hold A to fly — when you're not a cup."
- Button glyphs highlighted via bright-phosphor rich-text (
), ASCII for localization safety.
- Scroll-driven only — the lone trigger is
FloorManager.OnScrollSequenceCompleted. Backing strip auto-sizes to the block (TMP.GetPreferredValues + paragraphSpacing). Re-arms every match (reArmEachMatch, booth-correct).
- Legacy panel fields retained only so old scene refs resolve; hidden on
Start.
Assets/ProtoV2/Scenes/MatchScene.unity — OnboardingController GameObject m_IsActive: 0 → 1.
Triggers (scroll-driven)
| Group | Show | Clear |
|---|---|---|
| L1 block (decay + LT-cup + altar) | MatchCountdown.OnCountdownComplete (match start) | first FloorManager.OnScrollSequenceCompleted |
| L2 block (tilt + fly) | first OnScrollSequenceCompleted (1→2 expansion) | next OnScrollSequenceCompleted |
Stage machine (Idle → Level1 → Level2 → Done); further scrolls after Done are ignored. OnMatchEnd clears the block.
Why this shape
Earlier doc lean was per-player prompts. The codebase confirmed there's no split-screen — one ScrollingFloorCamera, world-space HP bars per creature — so per-player screen slots have nothing to hang on. A single shared subtitle-style line is cleaner and the standard party-game teaching pattern (user call). Decay framing kept as text because the bar visibly drains but players don't grasp *why*; the text frames it as an intended system.
Verification
Compile: VERIFIED. The Unity MCP bridge reconnected mid-session; a forced script recompile (refresh_unity scope=scripts mode=force compile=request) completed with 0 errors / 0 warnings and the editor returned to idle. Code was written against verified API signatures from the actual source (FloorManager, AltarParticleConsumer, TransformController, SimpleCharacterController1, MultiplayerManager, PlayerSetup, TypographyLibrary, MatchCountdown, MatchManager) and matches project TMP conventions (textWrappingMode).
Playtest: PENDING (needs live 4-player input). Check: (a) the L1 block shows at match start and clears on the first floor scroll, (b) the L2 block shows on that scroll and clears on the next, (c) legibility over 4P chaos at booth resolution.
Button icons (added same session)
Replaced the ASCII glyphs with Kenney Input Prompts (CC0) Xbox-layout icons for booth legibility:
- Downloaded the pack (CC0); used the white Default glyphs
xbox_lt / xbox_button_a / xbox_stick_r; composed a 192×64 sheet Assets/ProtoV2/Art/InputPrompts/InputPromptGlyphs.png (source PNG+SVG + License.txt under source/).
Editor/InputPromptSpriteAssetBuilder.cs builds the TMP sprite asset Assets/ProtoV2/Resources/InputPromptGlyphs.asset (glyph names lt/a/rstick, explicit glyphRects, single embedded material). Menu: *Ritual & Ruin/Build Input Prompt Sprite Asset*.
OnboardingController loads it via Resources.Load("InputPromptGlyphs") → _label.spriteAsset; prompts use etc. — tint=1 makes the white glyph inherit the label's phosphor green.
- Verified in-engine: Play-mode inspection confirmed the sprite asset assigned on the live label; an edit-mode render self-test showed all 3 sprites resolve and render ~28px inline at the 32px font. Compiles clean.
- Quirk: the sprite-asset builder's first run after a domain reload writes empty tables — run the menu item twice. It strips stale embedded materials on rebuild so it stays at one material.
Glyph rendering fixes (verified via offscreen screenshot)
Added a diagnostic menu *Ritual & Ruin/Screenshot Tutorial Prompts* (renders the blocks with the real styling to Temp/tutorial_prompt_preview.png). First render showed the glyphs tiny/muddy and the A + right-stick mangled. Three fixes, all encoded in InputPromptSpriteAssetBuilder.cs:
1. Size + vertical centering — set glyph scale (the TMP_SpriteGlyph constructor 4th arg — TMP_SpriteCharacter.scale alone doesn't serialize) and GlyphMetrics.horizontalBearingY. Final tuning: GlyphScale = 1.06 (glyph ≈ cap height, reads as part of the text) and BearingYFraction = 0.88 (× GlyphSize → top edge above baseline) so the glyph centers on the cap line instead of dipping below the baseline. Both exposed as consts at the top of the builder for easy retuning.
2. Muddiness — texture had no mipmaps; enabled mipmapEnabled + FilterMode.Trilinear for clean downscaling.
3. Mangled A/rstick — enabling mipmaps triggered Unity's NPOT resize of the 192×64 sheet to nearest-POT, shifting the hardcoded glyph rects (index 0 survived, 1–2 broke). Fixed with npotScale = TextureImporterNPOTScale.None.
Final render confirmed: LT / R-stick / A all legible, phosphor green, correctly sized.
Final polish: icons bumped to GlyphScale = 1.2 (BearingYFraction = 0.84); backing strip darkened to alpha 0.88 and now hugs the text block — OnboardingController.ShowGroup sizes the strip from GetPreferredValues (width + height) capped at MaxStripWidth, with tight padding (H 22 / V 12), removing the wide empty margins. The screenshot util mirrors this so the preview is faithful.
Follow-ups
- The kept SVG vectors feed the [[2026-05-18_controls-infographic|controls infographic]] (#5) — same glyph language, now unblocked on art.
- Optional keyboard-glyph variant (icons are controller-only); optional directional/tilt variant for the
rstick glyph.
---
Orphaned pillars — guard pillar placement against the floor-above's future gaps
Implemented: 2026-05-29
Type: Fix
Area: Floor System / pillar placement
Player impact
Pillars no longer poke up through holes ("sticks pointing at nothing"). Every pillar now keeps a solid chunk above it for its whole life, including after the floor above opens its gaps on a role change. Restores the "stacked floors held by pillars" architectural read. Surfaced in the locked Clinical Toon pipeline capture review.
Root cause
A pillar lives on floor N and reaches up into floor N+1. Two existing guards were meant to prevent orphans, but a generation-order asymmetry defeated both:
1. FloorGenerator.PlacePillars skipped a cell only if the floor above had a *current* gap there (floorAbove.IsGap) — never checked its *future* gaps.
2. Floor.ComputeFutureGapCells excludes belowPillarSet (cells above a pillar in the floor below), but floors generate strictly top-down (FloorManager.GenerateInitialFloors; every new floor is added below the current bottom). So when floor N+1 finalizes its future-gap set, floor N and its pillars don't exist yet → belowPillarSet is null → no exclusion.
Neither guard closes the loop: N+1 fixes its future gaps before N's pillars exist; N then places pillars checking only N+1's (empty) current gaps. When N+1 later leaves the Bottom role, OpenGapsForMiddleRole (FloorManager.CycleSequence:344, ReassignVisibleRoles:399) punches those future gaps directly over N's pillars → orphan.
What changed
Assets/ProtoV2/Scripts/FloorSystem/FloorGenerator.cs (PlacePillars, ~line 741):
- Extended the support guard from
floorAbove == null || floorAbove.IsGap(x,y) to also reject floorAbove.IsFutureGap(x,y). A pillar's top cell is now guaranteed solid both now and after the floor above ever opens gaps (N+1 only ever opens its _futureGapCells).
- Generation order makes this sound: the floor above is always generated first, so its
_futureGapCells is finalized and queryable via the existing Floor.IsFutureGap(x, z) (null-safe; emitter ceiling has none) by the time PlacePillars(N) runs.
Assets/ProtoV2/Scripts/FloorSystem/FloorDebugTools.cs (VerifyInvariants, Rule 1):
- Added a *latent-orphan* assertion: flags any pillar sitting under a future-gap cell on the floor above, so the regression is caught at generation (F8 /
verifyInvariantsOnStep) rather than only after a cycle reveals it.
Rejected alternative — filter gaps at open-time: the dual-mesh FloorObiSurface_Holed bakes its holes at generation, so skipping a gap at runtime would leave a visible chunk over an Obi hole, re-triggering the blood-containment regression the dual-mesh design exists to avoid. The chosen fix touches zero Obi/mesh code.
Floor.ComputeFutureGapCells's now-redundant belowPillarSet exclusion is left in place as harmless defense-in-depth.
Verification
- Unity script compile: clean, zero
CS compiler errors (refresh_unity force compile → domain reload complete → read_console errors-only returned no compile errors; only pre-existing runtime input/Obi entries).
- Candidate-pool sanity: ~28% of cells are future gaps vs. 2–5 pillars per floor → plenty of valid cells remain.
- Pending playtest (MCP can't tick play loop headless): Play-Mode soak over several expand + cycle steps with F8 invariant check expecting 0 violations; re-run the Visual Pipeline Direction Comparison capture cycle and confirm no orphaned pillars in Clinical Toon screenshots.
Follow-ups
- Play-Mode soak + capture re-run (above).
Confirmed/tech/Core Systems Reference.md FloorSystem section owes a one-line note that pillar placement now excludes future-gap cells on the floor above.
Related
- Problem doc:
Problems/Active/2026-05-29_orphaned-pillars-no-chunk-above.md → resolved (option b).
- [[2026-05-22_PillarHoleEmitterAltarExclusion]] — introduced the cross-floor exclusion +
VerifyInvariants this fix extends.
- [[2026-05-24_BowlContainment_DualMeshFloorObiSurface]] — dual-mesh design whose baked-hole invariant ruled out the open-time alternative.
2026-05-18_clarity-and-depth-perception.md — inverse failure (holes too clean to read pillars vs. floors too solid to read holes).
---
Player stuck on retracting top floor — stale FloorObiSurface name lookup
Implemented: 2026-05-29
Type: Fix
Area: Floor System / cycle retraction + surface lifecycle
Player impact
Players standing on the top floor during a steady-state cycle now fall onto the floor below when it retracts, instead of staying stuck on the (now-collapsed, soon-to-be-deleted) floor floating in mid-air.
Root cause
The full-floor blood surface is a non-convex MeshCollider sitting ~0.16u (floorKillZoneYOffset) *above* the chunk tops, so the player actually rests on it, not on the per-chunk colliders. Floor.RetractAndDrop dropped the per-chunk colliders in its loop but disabled the floor-wide surface via:
The 2026-05-24 dual-mesh refactor ([[2026-05-24_BowlContainment_DualMeshFloorObiSurface]]) split the single FloorObiSurface child into FloorObiSurface_Solid + FloorObiSurface_Holed. Three sites still looked it up by the old name, all now silently returning null:
1. RetractAndDrop → full-floor collider never disabled → player stuck (the reported bug).
2. OnDestroy and DestroyFloor → surface meshes never freed → native mesh leak.
No error, no warning — transform.Find just degrades to a no-op.
What changed
Assets/ProtoV2/Scripts/FloorSystem/Floor.cs:
RetractAndDrop now disables BOTH _solidSurfaceGO and _holedSurfaceGO MeshColliders up front via new SetSurfaceColliderEnabled helper — covers whichever surface is active when the cycle fires, with no name lookup.
OnDestroy and DestroyFloor now free both surface meshes via new FreeSurfaceMeshes / DestroySurfaceMesh helpers.
- All three sites use the Floor's already-tracked surface references (
SetObiSurfaces), removing the transform.Find("FloorObiSurface") name dependency entirely.
Verification
- Unity script compile: clean, zero
CS compiler errors (refresh_unity force compile → domain reload → read_console errors-only returned only pre-existing runtime input/Obi entries).
- Playtest confirmed by user: player drops onto the floor below during a cycle.
Follow-ups
- None. (Could audit for any other
transform.Find("FloorObiSurface")-style lookups elsewhere — grep confirms zero remain in Scripts/.)
Related
- Problem doc:
Problems/Archive/2026-05-29_player-stuck-retracting-floor.md → resolved + archived.
- [[2026-05-24_BowlContainment_DualMeshFloorObiSurface]] — the refactor that renamed the surface child and left these three lookups stale.
- [[2026-05-21_HazardRemoval_ProgressiveFloorCount]] — introduced the cycle/retract model.