The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it, a magpie owns one street for six weeks of the year, and six point sources put a life off-screen. MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`, :8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre spine steps at yaw 0, each frame fully streamed, through the shell's own composer: worst draws 224 -> 225 (+1) worst tris 89,502 -> 93,298 (+3,796 of ~75k spare) sum draws over 102 frames 16,676 -> 16,663 (-13) The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time. `?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw / +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws. §1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION. `townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key, so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path. The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`, so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns. `gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had; making the slot addressable fixed it at the root, and both are selected now. E's three new grounds landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for. §1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands). THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37 chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin. Zero on every real town (no hoists ⇒ no mesh) and zero at night. §1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never mentions it. `swoopPos()` is exported and pure. The hat stays cut. MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on (citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%. §1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff, AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot; 3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability control — one manifest key renamed away, served from a third port — makes that source report `no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors. TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256 instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height, and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to furniture.js's own streetlight rule. LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The `magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch. furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000. Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38. 0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets, magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones. Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
199 lines
11 KiB
JavaScript
199 lines
11 KiB
JavaScript
// PROCITY Lane B — washing.js (v8 WAVE 1 §1.4 — the washing on the Hills Hoist)
|
||
//
|
||
// The rest of §1.4 (fence, hoist, aerial, tank) lives inside `buildings.js`'s existing `boxMats`
|
||
// InstancedMesh and costs **+0 draws**. The washing cannot: a sheet is a flat quad with a fabric
|
||
// map, `boxMats` is a BoxGeometry sharing ONE untextured shell material with every building in the
|
||
// chunk, and per-instance textures do not exist. So the washing needs a mesh of its own — and this
|
||
// is where the brief's shape and the budget law disagree, loudly:
|
||
//
|
||
// • A per-chunk washing mesh is "+1 draw" the way a per-chunk anything is: **+1 × up to 31 live
|
||
// chunks at the dispose window = up to +31 draws** against a pinned margin of **8**. That is not
|
||
// a rounding error, it is 4× the entire remaining budget.
|
||
// • **ONE TOWN-WIDE `InstancedMesh` is 1 draw at any N** (v8 law, charter §Laws). So the washing
|
||
// is town-wide, capped at the settled shared cap of **256**, spent **proximity-first**, and
|
||
// refilled off `chunks.onChunkBuilt` / `onChunkDisposed` — the seam R37 §0.4 wired precisely so
|
||
// a v8 standing layer would not have to invent its own.
|
||
//
|
||
// **FREE WHEN EMPTY, and to the vendored line rather than "almost".** `three.module.js:2193-2199`
|
||
// returns before the GL call when an InstancedMesh's count is 0, and `render.calls++` lives
|
||
// downstream at `:4094`. So: all 21 real towns (zero house/yard lots — 100% `use:'shop'`, charter
|
||
// ruling 2) pay **exactly 0**, and so does any night, because the washing comes in after dark.
|
||
//
|
||
// **THE SWAY IS THE WHOLE POINT AND THE SHIPPED WEIGHT WAS UPSIDE DOWN.** `wind.js`'s v3.2 weight is
|
||
// `wH = clamp(y/4.4,0,1); wH *= wH` — top-weighted, base planted, correct for a gum. A sheet is the
|
||
// other way round: pegged at the top, free at the hem. Under the canopy weight a 1.6 m line of
|
||
// washing moves 0.1–0.7 cm AT THE HEM (and most at the pegs, which is backwards). This layer runs
|
||
// `wind.js`'s new `pegged` mode instead — hem weight 1, peg weight 0 — measured at **4.5 cm calm /
|
||
// 31 cm gusty**, i.e. ~52× the shipped weight and in the right place. Amplitude still rides the ONE
|
||
// shared `uWindAmp`, so at dead calm (?weather=0, ?classic=1) the displacement is exactly `* 0.0`.
|
||
//
|
||
// PROCEDURAL-FIRST: the fabric is a canvas weave (no fetch, `?noassets`-clean by construction). When
|
||
// Lane E's asset wave lands a garment atlas, this becomes a `map` swap + a per-instance UV rect —
|
||
// still ONE mesh, still ONE draw.
|
||
|
||
import * as THREE from 'three';
|
||
import { rng, frange } from '../core/prng.js';
|
||
import { hoistAnchors } from './buildings.js';
|
||
import { applyWind } from './wind.js';
|
||
|
||
// The shared standing-layer instance cap settled in R37 §0.3. One number, written down once.
|
||
export const WASHING_CAP = 256;
|
||
const PEG_DROP = 1.0; // local height of the quad: pegs at y=1, hem at y=0 (the `pegged` topY)
|
||
|
||
// 90s Australian laundry, not a fashion range: sheets, towels, school uniforms, tea towels.
|
||
const GARMENTS = [
|
||
{ w: 1.15, h: 1.25, tints: ['#e8e4dc', '#dfe4ea', '#e6ded0'] }, // sheet / pillowcase
|
||
{ w: 0.62, h: 0.95, tints: ['#c8683f', '#3f6f8c', '#7a9a52', '#c8b04a'] }, // towel
|
||
{ w: 0.54, h: 0.78, tints: ['#e2e2e6', '#8ea8c4', '#d8d2c0'] }, // shirt
|
||
{ w: 0.42, h: 0.48, tints: ['#dcd6c6', '#b8c8b0', '#e0cfc0'] }, // tea towel / smalls
|
||
];
|
||
|
||
// A plain woven-cloth texture: warp/weft noise + a soft vertical shadow so a hanging quad reads as
|
||
// cloth rather than as a coloured card. Canvas only — no fetch, no manifest, no ?noassets branch.
|
||
let _tex = null;
|
||
function fabricTexture() {
|
||
if (_tex) return _tex;
|
||
const c = document.createElement('canvas'); c.width = c.height = 64;
|
||
const x = c.getContext('2d');
|
||
x.fillStyle = '#ffffff'; x.fillRect(0, 0, 64, 64);
|
||
let s = 0x9e3779b9;
|
||
const rnd = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
|
||
for (let i = 0; i < 64; i++) { // weave
|
||
x.fillStyle = `rgba(0,0,0,${0.03 + rnd() * 0.05})`;
|
||
x.fillRect(0, i, 64, 1);
|
||
x.fillStyle = `rgba(255,255,255,${0.04 + rnd() * 0.05})`;
|
||
x.fillRect(i, 0, 1, 64);
|
||
}
|
||
const g = x.createLinearGradient(0, 0, 0, 64); // hangs darker at the fold, lighter at the hem
|
||
g.addColorStop(0, 'rgba(0,0,0,0.20)'); g.addColorStop(0.35, 'rgba(0,0,0,0.04)'); g.addColorStop(1, 'rgba(255,255,255,0.06)');
|
||
x.fillStyle = g; x.fillRect(0, 0, 64, 64);
|
||
_tex = new THREE.CanvasTexture(c);
|
||
_tex.colorSpace = THREE.SRGBColorSpace; _tex.generateMipmaps = false; _tex.minFilter = THREE.LinearFilter;
|
||
return _tex;
|
||
}
|
||
|
||
let _mat = null;
|
||
function washingMaterial() {
|
||
if (_mat) return _mat;
|
||
_mat = new THREE.MeshStandardMaterial({ map: fabricTexture(), roughness: 0.94, metalness: 0, side: THREE.DoubleSide });
|
||
applyWind(_mat, { topY: PEG_DROP, mode: 'pegged' }); // R38: the INVERTED weight (see header)
|
||
return _mat;
|
||
}
|
||
|
||
/**
|
||
* createWashing({ scene, plan, citySeed, chunks, camera, lighting }) → { group, update, dispose, state }
|
||
* The shell constructs this only on a non-classic boot with ?washing≠0. Constructed on a town with
|
||
* no hoists it allocates nothing and never adds a mesh — so every real town is provably free.
|
||
*/
|
||
export function createWashing({ scene, plan, citySeed, chunks, camera, lighting }) {
|
||
const group = new THREE.Group(); group.name = 'washing';
|
||
const state = { hoists: 0, items: 0, active: 0, cap: WASHING_CAP, rebuilds: 0, night: false };
|
||
|
||
// ── the item table: derived ONCE from buildings.js's own yardLayout, never re-invented ──
|
||
// hoistAnchors() is a pure function of (plan, citySeed) that reads the SAME `yardLayout` the
|
||
// geometry is built from, so a peg can never end up where no arm is.
|
||
const anchors = hoistAnchors(plan, citySeed >>> 0);
|
||
state.hoists = anchors.length;
|
||
const items = [];
|
||
for (const a of anchors) {
|
||
const r = rng(a.seed, 'washing', 0);
|
||
// ~90% of hoists have something on them; a few are bare, because some are.
|
||
const n = r() < 0.10 ? 0 : 1 + Math.floor(r() * 4);
|
||
for (let i = 0; i < n; i++) {
|
||
const g = GARMENTS[Math.floor(r() * GARMENTS.length)];
|
||
const armIdx = Math.floor(r() * 4);
|
||
const armYaw = a.yaw + armIdx * (Math.PI / 2);
|
||
const rad = frange(r, 0.35, Math.max(0.4, a.arm * 0.92));
|
||
const sx = frange(r, 0.85, 1.18), sy = frange(r, 0.82, 1.15);
|
||
items.push({
|
||
x: a.x + Math.cos(armYaw) * rad,
|
||
z: a.z + Math.sin(armYaw) * rad,
|
||
y: a.y - 0.06, // the pegs sit just under the arm
|
||
yaw: armYaw + Math.PI / 2, // the sheet hangs across the arm
|
||
sx: g.w * sx, sy: g.h * sy,
|
||
color: new THREE.Color(g.tints[Math.floor(r() * g.tints.length)]),
|
||
});
|
||
}
|
||
}
|
||
state.items = items.length;
|
||
|
||
let mesh = null, geo = null;
|
||
const _m = new THREE.Matrix4(), _p = new THREE.Vector3(), _q = new THREE.Quaternion(), _s = new THREE.Vector3();
|
||
const _up = new THREE.Vector3(0, 1, 0);
|
||
if (items.length) {
|
||
// unit quad, pegs at y = PEG_DROP, hem at y = 0 — which is what the `pegged` wind weight expects
|
||
geo = new THREE.PlaneGeometry(1, PEG_DROP).translate(0, PEG_DROP / 2, 0);
|
||
mesh = new THREE.InstancedMesh(geo, washingMaterial(), Math.min(items.length, WASHING_CAP));
|
||
mesh.name = 'washing-layer';
|
||
mesh.castShadow = false; mesh.receiveShadow = false;
|
||
mesh.frustumCulled = false; // one town-wide mesh: its bounds are the whole town, so culling it
|
||
// whole is never right and computing them each refill is wasted work
|
||
mesh.count = 0; // nothing until the first refill ⇒ boots at 0 draws
|
||
group.add(mesh);
|
||
scene.add(group);
|
||
}
|
||
|
||
// ── proximity-first spending ────────────────────────────────────────────────────────────────
|
||
// The cap is spent on the NEAREST items, so a 188 km-of-road town spends its 256 where the player
|
||
// is rather than on a suburb 6 km away. Refills are event-driven (the R37 §0.4 chunk seam) plus a
|
||
// movement guard, never per-frame: an InstancedMesh matrix rewrite is cheap but not free.
|
||
let dirty = true, lastX = 1e9, lastZ = 1e9;
|
||
const markDirty = () => { dirty = true; };
|
||
const offBuilt = chunks && chunks.onChunkBuilt ? chunks.onChunkBuilt(markDirty) : () => {};
|
||
const offGone = chunks && chunks.onChunkDisposed ? chunks.onChunkDisposed(markDirty) : () => {};
|
||
|
||
const order = items.map((_, i) => i);
|
||
function refill(px, pz) {
|
||
if (!mesh) return;
|
||
// The washing comes in at night — and an empty count is a REAL zero (the vendored early-return),
|
||
// so the whole layer costs nothing between dusk and dawn.
|
||
const night = !!(lighting && lighting.isNight && lighting.isNight());
|
||
state.night = night;
|
||
if (night) { mesh.count = 0; state.active = 0; return; }
|
||
order.sort((a, b) => {
|
||
const ia = items[a], ib = items[b];
|
||
return ((ia.x - px) ** 2 + (ia.z - pz) ** 2) - ((ib.x - px) ** 2 + (ib.z - pz) ** 2);
|
||
});
|
||
const n = Math.min(order.length, WASHING_CAP);
|
||
for (let i = 0; i < n; i++) {
|
||
const it = items[order[i]];
|
||
// THE PEGS GO ON THE LINE, and the drop is per-instance. The unit quad spans local y 0..1 with
|
||
// the pegs at 1, so the instance origin must sit `sy` below the arm — NOT `PEG_DROP` below it.
|
||
// The first cut used the unscaled constant, which hung every garment with its pegs at
|
||
// `arm − 1 + sy`: a tea towel floated 0.5 m under the line and a whole street of washing was
|
||
// *invisible behind its own 1.75 m back fence* from any angle that could see the yard. The
|
||
// instance count, the draw count and the cap were all green while nothing could be seen.
|
||
_p.set(it.x, it.y - it.sy, it.z);
|
||
_q.setFromAxisAngle(_up, it.yaw);
|
||
_s.set(it.sx, it.sy, 1);
|
||
mesh.setMatrixAt(i, _m.compose(_p, _q, _s));
|
||
if (mesh.instanceColor !== undefined) mesh.setColorAt(i, it.color);
|
||
}
|
||
mesh.count = n;
|
||
mesh.instanceMatrix.needsUpdate = true;
|
||
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||
state.active = n;
|
||
state.rebuilds++;
|
||
}
|
||
|
||
function update() {
|
||
if (!mesh) return;
|
||
const p = camera.position;
|
||
const moved = (p.x - lastX) ** 2 + (p.z - lastZ) ** 2;
|
||
const nightNow = !!(lighting && lighting.isNight && lighting.isNight());
|
||
if (!dirty && moved < 24 * 24 && nightNow === state.night) return;
|
||
dirty = false; lastX = p.x; lastZ = p.z;
|
||
refill(p.x, p.z);
|
||
}
|
||
|
||
function dispose() {
|
||
offBuilt(); offGone();
|
||
if (mesh) { mesh.dispose && mesh.dispose(); group.remove(mesh); }
|
||
if (geo) geo.dispose();
|
||
scene.remove(group);
|
||
// the material + fabric texture are module-shared (one town per session) — not disposed here
|
||
}
|
||
|
||
return { group, update, dispose, state, get count() { return mesh ? mesh.count : 0; } };
|
||
}
|