Lane B round-5 (v2): parallax interior-mapping window glass behind ?winmap=1

The Vuntra window trick — a passing pedestrian sees a plausible room through the shop glass with real
parallax, no geometry. One shared ShaderMaterial does single-cube interior mapping on the existing
window mesh (back wall + seeded shelves + side walls + floor/ceiling); per-shop variation via
aTangent/aTint/aSeed attributes, still 1 window draw/chunk. §3.5 held via the shared uHour/uNight +
aHours: night → open room glows warm, closed room dark.

v2 prime law: WINMAP is read from the URL in buildings.js (self-contained — no index.html seam; F
just needs a flags-table row). Flag-OFF byte-identical to v1 (windowQuad → v1 hoursQuad, v1
windowMaterial). Proven: flag-off windows MeshStandardMaterial w/ no winmap attrs; flag-on interior
ShaderMaterial; worst view 122-168 draws (<=300); 100% procedural (0 fetch delta vs ?noassets
baseline); 0 console errors; GOLDEN.hash untouched. Screenshots in docs/shots/laneB/winmap_*.

qa.sh --strict GREEN (4/4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-14 21:00:57 +10:00
parent c52246444d
commit 2a923b61e6
6 changed files with 150 additions and 5 deletions

View File

@ -4,6 +4,20 @@
plus the game shell. It runs on my hand-written fixture **and** auto-integrates with Lane A's
`generatePlan` (which landed mid-session). Everything below was verified live in a browser.
## Round 5 (Fable's ROUND5 → Lane B) — v2 window trick, behind `?winmap=1` · qa GREEN (4/4)
- **Parallax interior-mapping window glass** (V2_IDEAS "Vuntra window trick"), behind **`?winmap=1`
default-off.** One shared `ShaderMaterial` on the existing window geometry does single-cube interior
mapping — each fragment rays into a virtual room box and shades back wall + seeded shelves + side
walls + floor/ceiling. **Zero extra geometry, 1 window draw/chunk, per-shop variation via attributes
(aTangent/aTint/aSeed), not per-shop materials.** Seeded from `shop.seed`.
- **§3.5 held:** night → open shop's room glows warm, closed shop's room dark (reuses the closed-facade
`uHour/uNight/aHours`). Screenshots: `docs/shots/laneB/winmap_{day,night_open_vs_closed,night_closed}.png`.
- **v2 prime law held & proven:** flag-OFF byte-identical to v1 (windows `MeshStandardMaterial`, no
winmap attrs); flag-ON worst view **122168 draws** (≤300); shader **100% procedural — 0 fetch delta**
vs `?noassets` baseline; 0 console errors; `GOLDEN.hash` untouched. `?winmap` is self-read in
buildings.js → **no index.html seam for F** (just a flags-table row). File: `buildings.js`.
## Round 4 (Fable's ROUND4 → Lane B) — done · qa.sh --strict GREEN (4/4)
- **B1 — closed-facade visuals (§3.5, headline).** Closed shops → **dark windows + red CLOSED plate**;

View File

@ -1,5 +1,34 @@
# LANE B — NOTES (measured)
## Round 5 (Fable's ROUND5 → Lane B) — v2: the Vuntra window trick, behind `?winmap=1`
- **Parallax interior-mapping glass** (V2_IDEAS "window trick"). Behind **`?winmap=1`, default-off.**
Single-cube interior mapping in one shared `ShaderMaterial` on the existing window geometry: each
glass fragment casts the view ray into a virtual room box and shades the surface it hits (back wall
+ seeded shelf silhouettes with product blocks, side walls, floor/ceiling) — a plausible room with
real parallax, **zero extra geometry, still 1 window draw/chunk**. Per-shop variation rides
attributes (`aTangent` = window right vector, `aTint` = seeded room colour, `aSeed` = shelf
variation), **not per-shop materials**. Verified live: rooms are visible through the glass with
parallax day + night.
- **§3.5 respected** via the same `uHour/uNight` + `aHours` the closed-facade system uses: at night an
**open shop's room glows warm** ("Video Regal" lit), a **closed shop's room is dark** ("Second Time"
dark) — screenshots in `docs/shots/laneB/winmap_*.png`.
- **v2 prime law held.** `WINMAP` is read straight from the URL in **buildings.js** — **no shell seam,
so F only needs a flags-table row, no `index.html` wiring.** Flag-OFF path is byte-identical to v1:
`windowQuad` collapses to the v1 `hoursQuad` (no extra attributes) and the finalize uses the v1
`windowMaterial()` — verified: flag-off windows are `MeshStandardMaterial` with no `aTangent`;
flag-on are the interior `ShaderMaterial` with `aTangent`. Seeded from `shop.seed` (deterministic).
- **Budget + fetches:** flag-on worst continuous-walk view with citizens **122168 draws** (≤300);
the shader is **100% procedural** — measured **0 fetch delta** vs the `?noassets` baseline (both 22
= the pre-existing facade/ground skin JPEGs). `?noassets=1&winmap=1` adds no network. **0 console
errors** in all modes. `qa.sh --strict` GREEN (4/4); `GOLDEN.hash` untouched (no plan change).
**→ Fable (F2 flags table):** `?winmap=1` — owner Lane B — state landed, default-off — needs **no
index.html wiring** (self-read in buildings.js). Smoke: boot `?winmap=1`, walk a block, glass shows
rooms; night open=warm / closed=dark.
---
## Round 4 (Fable's ROUND4 → Lane B) — done
- **B1 — closed-facade visuals (§3.5, the headline).** Closed shops now render **dark windows + a

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@ -52,6 +52,84 @@ function windowMaterial() {
return m;
}
// ── v2: parallax interior-mapping window glass (?winmap=1, default-off) ──────────────────────────
// Single-cube interior mapping: each glass fragment casts the view ray into a virtual room box and
// shades the surface it hits (back wall + shelf silhouettes, side walls, floor/ceiling) — a plausible
// room seen through the window with real parallax, zero extra geometry. One SHARED ShaderMaterial for
// all windows (per-shop variation via aTint/aSeed attributes), same 1 draw/chunk as v1. Respects §3.5
// via the same uHour/uNight + aHours: closed-at-night rooms go dark, open rooms glow warm. Fully
// procedural (no fetch). Flag-off path is untouched (v1 windowMaterial()).
const WINMAP = (() => { try { const p = new URLSearchParams(location.search); return p.has('winmap') && p.get('winmap') !== '0'; } catch (e) { return false; } })();
const ROOM_TINTS = [ // muted 90s interior wall colours (RGB 0..1), seeded per shop
[0.56, 0.44, 0.33], [0.42, 0.47, 0.52], [0.52, 0.47, 0.37], [0.47, 0.42, 0.46],
[0.54, 0.50, 0.41], [0.40, 0.45, 0.40], [0.58, 0.49, 0.40], [0.45, 0.48, 0.55],
];
function roomTint(shop) { return ROOM_TINTS[((shop ? shop.seed : 12345) >>> 0) % ROOM_TINTS.length]; }
let _winmapMat = null;
function interiorWindowMaterial() {
if (_winmapMat) return _winmapMat;
_winmapMat = new THREE.ShaderMaterial({
transparent: true,
uniforms: { uHour: _hoursUniforms.uHour, uNight: _hoursUniforms.uNight, uRoomDepth: { value: 0.85 } },
vertexShader: `
attribute vec2 aHours; attribute vec3 aTangent; attribute vec3 aTint; attribute float aSeed;
varying vec2 vUv; varying vec3 vWorldPos; varying vec3 vNormalW; varying vec3 vTangentW;
varying vec2 vHours; varying vec3 vTint; varying float vSeed;
void main(){
vUv = uv; vWorldPos = position; vNormalW = normal; vTangentW = aTangent;
vHours = aHours; vTint = aTint; vSeed = aSeed;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
precision highp float;
uniform float uHour; uniform float uNight; uniform float uRoomDepth;
varying vec2 vUv; varying vec3 vWorldPos; varying vec3 vNormalW; varying vec3 vTangentW;
varying vec2 vHours; varying vec3 vTint; varying float vSeed;
float h1(float n){ return fract(sin(n * 127.1) * 43758.5453); }
void main(){
vec3 N = normalize(vNormalW);
vec3 T = normalize(vTangentW);
vec3 Bb = normalize(cross(N, T));
vec3 rayDir = normalize(vWorldPos - cameraPosition);
vec3 rd = vec3(dot(rayDir, T), dot(rayDir, Bb), -dot(rayDir, N));
rd.z = max(rd.z, 1e-3);
vec2 uv = vUv;
float tz = uRoomDepth / rd.z; // back wall
float tx = (abs(rd.x) > 1e-4) ? (((rd.x > 0.0 ? 1.0 : 0.0) - uv.x) / rd.x) : 1e9;
float ty = (abs(rd.y) > 1e-4) ? (((rd.y > 0.0 ? 1.0 : 0.0) - uv.y) / rd.y) : 1e9;
if (tx <= 0.0) tx = 1e9; if (ty <= 0.0) ty = 1e9;
float t = min(tz, min(tx, ty));
vec3 hit = vec3(uv, 0.0) + rd * t;
float depth = clamp(hit.z / uRoomDepth, 0.0, 1.0);
vec3 wall = vTint;
vec3 col;
if (t >= tz - 1e-5) { // back wall + shelves
col = wall * 0.82;
for (int i = 0; i < 3; i++){
float fy = 0.22 + float(i) * 0.26 + (h1(vSeed + float(i) * 3.7) - 0.5) * 0.06;
float band = smoothstep(0.05, 0.03, abs(hit.y - fy));
col = mix(col, wall * 0.42, band * 0.7);
float bx = fract(hit.x * 6.0 + h1(vSeed + float(i)));
float prod = step(0.15, bx) * step(bx, 0.85) * step(fy, hit.y) * step(hit.y, fy + 0.11);
col = mix(col, wall * 1.3 + vec3(0.05, 0.03, 0.02), prod * 0.45);
}
} else if (t >= tx - 1e-5) {
col = wall * 0.6; // side wall
} else {
col = (rd.y < 0.0) ? wall * 0.5 : (wall * 1.05 + 0.04); // floor / ceiling
}
col *= mix(1.0, 0.55, depth); // depth falloff
float open = step(vHours.x, uHour) * step(uHour + 0.0001, vHours.y);
float lit = (uNight > 0.5) ? (open > 0.5 ? 1.0 : 0.10) : 0.72; // §3.5: closed@night dark
col *= lit;
if (uNight > 0.5 && open > 0.5) col = mix(col, col * vec3(1.0, 0.85, 0.6) * 1.7, 0.65); // warm glow
gl_FragColor = vec4(col, 0.94);
}`,
});
return _winmapMat;
}
function plateTexture() {
if (_plateTex) return _plateTex;
const c = document.createElement('canvas'); c.width = 256; c.height = 96;
@ -120,6 +198,29 @@ function hoursQuad(c0, c1, c2, c3, hours, uv) {
return g;
}
// A window quad. Under ?winmap it carries the extra interior-mapping attributes (aTangent = the
// window's world-space right vector, aTint = the seeded room colour, aSeed = shelf variation). With
// the flag off it is exactly the v1 hoursQuad, so flag-off is byte-identical.
function windowQuad(c0, c1, c2, c3, hours, shop) {
const g = hoursQuad(c0, c1, c2, c3, hours);
if (!WINMAP) return g;
const tx = c1[0] - c0[0], ty = c1[1] - c0[1], tz = c1[2] - c0[2];
const tl = Math.hypot(tx, ty, tz) || 1;
const tan = [tx / tl, ty / tl, tz / tl];
const tint = roomTint(shop);
const seed = shop ? ((shop.seed >>> 0) % 997) / 997 : 0.5;
const tanA = new Float32Array(18), tintA = new Float32Array(18), seedA = new Float32Array(6);
for (let i = 0; i < 6; i++) {
tanA[i * 3] = tan[0]; tanA[i * 3 + 1] = tan[1]; tanA[i * 3 + 2] = tan[2];
tintA[i * 3] = tint[0]; tintA[i * 3 + 1] = tint[1]; tintA[i * 3 + 2] = tint[2];
seedA[i] = seed;
}
g.setAttribute('aTangent', new THREE.BufferAttribute(tanA, 3));
g.setAttribute('aTint', new THREE.BufferAttribute(tintA, 3));
g.setAttribute('aSeed', new THREE.BufferAttribute(seedA, 1));
return g;
}
// Transform a local (lx,ly,lz) point to world via a lot's (x,z,ry).
function toWorld(lot, lx, ly, lz, out) {
const cos = Math.cos(lot.ry || 0), sin = Math.sin(lot.ry || 0);
@ -238,10 +339,10 @@ export function buildChunkBuildings(chunkData, ctx) {
const winW = Math.max(0.7, (w - dw) / 2 - 0.6), winH = 1.5, wy = 1.55, zw = zf + 0.025;
const offs = [-(dw / 2 + 0.4 + winW / 2), (dw / 2 + 0.4 + winW / 2)];
for (const ox of offs) {
windowGeos.push(hoursQuad(
windowGeos.push(windowQuad(
toWorld(lot, ox - winW / 2, wy - winH / 2, zw, []), toWorld(lot, ox + winW / 2, wy - winH / 2, zw, []),
toWorld(lot, ox + winW / 2, wy + winH / 2, zw, []), toWorld(lot, ox - winW / 2, wy + winH / 2, zw, []),
hrs,
hrs, shop,
));
}
// CLOSED plate over the shopfront — visible only when the shop is shut (shader gates on aHours)
@ -305,10 +406,10 @@ export function buildChunkBuildings(chunkData, ctx) {
toWorld(lot, 0.5, 2.0, zf, []), toWorld(lot, -0.5, 2.0, zf, []),
));
for (const ox of [-w / 2 + 1.2, w / 2 - 1.2]) {
windowGeos.push(hoursQuad(
windowGeos.push(windowQuad(
toWorld(lot, ox - 0.6, 1.1, zf, []), toWorld(lot, ox + 0.6, 1.1, zf, []),
toWorld(lot, ox + 0.6, 2.0, zf, []), toWorld(lot, ox - 0.6, 2.0, zf, []),
[0, 24], // homes glow at night (always "open")
[0, 24], null, // homes glow at night (always "open")
));
}
colliders.push(lotCollider(lot));
@ -412,10 +513,11 @@ export function buildChunkBuildings(chunkData, ctx) {
}
// ── windows (merged, ONE shared material; emissive gated per-shop by the aHours shader) ──
// ?winmap swaps in the parallax interior-mapping glass (v2); default is the v1 window material.
if (windowGeos.length) {
const merged = mergeGeometries(windowGeos, false);
windowGeos.forEach((g) => g.dispose());
const mesh = new THREE.Mesh(merged, windowMaterial()); // shared → NOT disposed per chunk
const mesh = new THREE.Mesh(merged, WINMAP ? interiorWindowMaterial() : windowMaterial()); // shared → NOT disposed
group.add(mesh);
disposables.push(merged);
}