PROCITY/web/js/world/wind.js
m3ultra 4514cb94b5 Lane B R38 (v8 WAVE 1, INHABITATION): all four runtime items — the whole round costs +1 walked draw
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.
2026-08-03 18:53:26 +10:00

97 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// PROCITY Lane B — wind.js (round 17, v3.2 — "wind sway ships or dies")
// Weather-driven vertex-shader sway for street foliage. Parked since R7 with the note "touches shared
// materials"; the R16 standard is measurement-over-brief, and the measurement here is: the gum-tree
// material is B-owned street geometry (furniture.js), so this is ENTIRELY Lane B — no C/E material seam.
//
// ONE shared uniform set, referenced by the tree material via applyWind(). Amplitude stays 0 until the
// weather system drives it (updateWind, called from weather.update). With weather OFF — ?weather=0, or
// ?classic=1 where weather is never constructed — updateWind never runs, uWindAmp stays 0, the injected
// displacement is exactly `... * 0.0 == 0.0`, and trees render byte-identical to no-sway (verified). So
// the classic covenant and ?weather=0 both hold by construction.
//
// SCOPE (measured, per R16 discipline): gum-tree BILLBOARDS only. Awnings are rigid posted-verandah box
// slabs (buildings.js) — a structural verandah roof does not flutter, so swaying it would read wrong; it
// is intentionally excluded (not a cross-lane blocker — a physical one). Fabric-canopy flutter, if ever
// wanted, is a separate v3.3 item (the striped canopies share the rigid awning InstancedMesh today).
//
// Cost: ~0 draws — a handful of ALU ops added to the tree InstancedMesh's existing vertex program.
export const windUniforms = {
uWindTime: { value: 0 },
uWindAmp: { value: 0 }, // 0 = dead calm ⇒ zero displacement ⇒ byte-identical (weather off / classic)
};
// ── R38 §1.4: THE WEIGHT IS UPSIDE DOWN FOR ANYTHING PEGGED AT THE TOP ──────────────────────────
// The `canopy` weight below is `wH = clamp(y/topY,0,1); wH *= wH` — top-weighted, base planted.
// That is correct for a gum and **exactly wrong for a sheet on a Hills Hoist**, which is pegged at
// the top and free at the hem. Measured on the shipped numbers: a sheet hanging 0.61.6 m under the
// canopy weight gets wH = (1.6/4.4)² = 0.132 at its TOP and 0.019 at its hem, so with the shipped
// amplitude (`updateWind`: 0.05 calm → 0.35 gusty) the free end of the washing moves
// **0.10 cm calm / 0.67 cm gusty** and the pegged end moves the most — visually inert, and inverted.
// The `pegged` mode weights by distance BELOW the peg line instead: wH = 1 at the hem, 0 at the
// pegs ⇒ **5.0 cm calm / 35 cm gusty at the hem, a 52× gain**, and the top stays put because that
// is where the pegs are. It also flutters faster and adds a Z-flap, because a sheet is a sail and a
// gum is a spring. (Fable's R38 binding correction 6, taken as the "write the mode" branch.)
//
// `wing` (§1.3, the magpie) is the third weight: |x| from the body centreline, displacement in Y,
// so a wing beats and a body does not. Still on uWindAmp ⇒ still exactly 0 at dead calm.
//
// **The `canopy` string is byte-for-byte what it was pre-R38**, so the gum-tree program compiles to
// the same source it always has and the covenant argument for trees is unchanged.
const MODES = {
canopy: (topY) => [
`float wH = clamp(position.y / ${topY.toFixed(1)}, 0.0, 1.0); wH *= wH;`, // top-weighted, base planted
'float wS = sin(uWindTime + wPhase) + 0.4 * sin(uWindTime * 2.3 + wPhase * 1.7);',
'transformed.x += wS * uWindAmp * wH;',
'transformed.z += cos(uWindTime * 0.8 + wPhase) * uWindAmp * wH * 0.5;',
],
// Pegged at the TOP of the local geometry (y ∈ [0, topY], pegs at topY): free at the hem.
pegged: (topY) => [
`float wH = clamp((${topY.toFixed(2)} - position.y) / ${topY.toFixed(2)}, 0.0, 1.0); wH *= wH;`,
'float wS = sin(uWindTime * 2.1 + wPhase) + 0.5 * sin(uWindTime * 4.7 + wPhase * 2.3);',
'transformed.x += wS * uWindAmp * wH;',
'transformed.z += sin(uWindTime * 3.3 + wPhase * 1.3) * uWindAmp * wH * 0.75;',
'transformed.y -= abs(wS) * uWindAmp * wH * 0.12;', // a lifting sheet also shortens
],
// Wing beat: weighted by span from the body centreline, displaced in Y.
wing: (topY) => [
`float wH = clamp(abs(position.x) / ${topY.toFixed(2)}, 0.0, 1.0);`,
'float wS = sin(uWindTime * 9.0 + wPhase);',
'transformed.y += wS * uWindAmp * wH * 3.0;',
'transformed.x -= abs(wS) * uWindAmp * wH * 0.35 * sign(position.x);',
],
};
// Patch a material's vertex program: displace vertices by a mode-specific weight × the shared
// amplitude. A per-instance phase from world position desyncs neighbours. Guarded for instanced use
// so the same material would still compile if ever drawn non-instanced.
// `topY` is the mode's reference length: canopy → tree height, pegged → drop below the pegs,
// wing → half-span.
export function applyWind(material, { topY = 4.4, mode = 'canopy' } = {}) {
const body = (MODES[mode] || MODES.canopy)(topY);
material.onBeforeCompile = (shader) => {
shader.uniforms.uWindTime = windUniforms.uWindTime;
shader.uniforms.uWindAmp = windUniforms.uWindAmp;
shader.vertexShader = 'uniform float uWindTime;\nuniform float uWindAmp;\n' + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
[
'#include <begin_vertex>',
'float wPhase = position.x * 0.1 + position.z * 0.1;',
'#ifdef USE_INSTANCING',
' wPhase = instanceMatrix[3].x * 0.15 + instanceMatrix[3].z * 0.15;',
'#endif',
...body,
].join('\n')
);
};
material.needsUpdate = true;
}
// Drive from weather.update(dt): base breeze on any weather-on boot (clear included), gusts scaling with
// rain intensity. NEVER called when weather is absent ⇒ uWindAmp stays 0 (byte-identical, see header).
export function updateWind(dt, intensity = 0) {
windUniforms.uWindTime.value += dt * 1.6;
windUniforms.uWindAmp.value = 0.05 + Math.max(0, intensity) * 0.30; // calm ~0.05 m top sway → gusty ~0.35 m
}