feat: real fire on the boards, the LA office as a twin, and a night sky worth reading
The world stops being a simulation of California and starts being California. **THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.** On today's live store the SoCal board contains 22 incidents. Every one has NULL acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that contains no other warm colour, so one glyph would be the most salient object on the board and twenty-two would spend its credibility permanently. `acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)` returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day, three correct answers. The empty board is a deliverable, not a fallback: it says "No active fire on this board — CAL FIRE and WFIGS, just now", states that 21 records were gated and why, lists the largest fires burning OUTSIDE the frame with distances, and counts the hot pixels it is deliberately not drawing. **The privacy leak is structurally impossible rather than carefully avoided.** cloud-1 serves a projection; the four home-relative columns never leave that box. `observations.threat` was the one that nearly got through — it is `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so with acreage and containment public it inverts to a distance circle around a house and three fires give an intersection. A grep of the built bundle for distance_km, bearing_deg, threat, 7762 and the street name returns nothing. **Deliberately not used, and both would have produced a confident wrong answer:** the store's `air` table retains only the last parameter of each poll, so all 78 rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive Groups" — haze driven off it would clear the sky during a smoke event. And `weather` is written only inside the NWS alerts loop, so a quiet day stores no wind at all. Tera's own per-region NWS wind is already correct and already what the clouds drift on. Satellite detections are drawn as evidence and never as incidents. The permanent industrial heat source 4.7 km from the owner's house is flagged persistent and dropped, asserted by a test that first proves it is present in the fixture. MODIS integer confidence and VIIRS string confidence are branched on `sat`. **The LA office is a twin.** Its entire authored second storey — Model Loft, Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5, verified against the real pack rather than a synthetic plan. Its two studio devices read real hardware through a field-allowlisted bridge: mute, volume and reachability only. Never level, because there is no passive level upstream and obtaining one would record a room with people in it. Never dB, because upstream is gainPct across four different native scales. The bridge refuses all writes. Fixed at its root: an anonymous visitor was getting permanently at-rest instruments backing off against a 401. The tier moves into `createDeviceSource`, so anon gets the living simulator three file headers already promised. **Item 8 is closed, not fixed, and the correction is the point.** The Bay Area "stutter" was GPU power management — the card sat at 500 MHz of 2725 through every run that reproduced it, 4096/2048/1024/256 shadow maps all render in 1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then 16.7. The allowance is removed and the cell is back to 16.7. Geometry is the gate; frame time is advisory. Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056 triangles were the second submission of the same buildings into the shadow pass. Mobile now has its own triangle caps and bay-area mobile draws 1,266,096. Also: bridges and the freeway corridor light up at night as emission, not lights — 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single change that made US-101 legible was moving its edge lines from the lit material to the unlit one: retroreflective paint, the argument the SFO night frame already makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts, because a rural interurban corridor genuinely is unlit. Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* The two sprites the fire feed draws with, both painted on a canvas at load.
|
||||
*
|
||||
* CONTRACT.md §3 is the reason there is no `.png` here and never will be: this
|
||||
* repo commits no binary art, so nothing in it has a provenance anyone has to
|
||||
* take on trust, and `scripts/check-no-binaries.mjs` enforces it over `src/**`.
|
||||
* Everything below is arithmetic into an `ImageData`.
|
||||
*
|
||||
* The interesting thing about these two is that **they are drawn to look
|
||||
* unalike on purpose**, and that is a truth-telling decision rather than a
|
||||
* stylistic one.
|
||||
*
|
||||
* A satellite hot pixel is not a fire. It is a report that one cell of one
|
||||
* overpass was warm — and the upstream store contains a permanent industrial
|
||||
* heat source 4.7 km from the operator's house that reports at FRP ~1.0 on
|
||||
* every single pass, on every day the store holds, with no matching incident
|
||||
* behind it. If the evidence layer and the incident layer shared a look, that
|
||||
* flare stack would be a wildfire on somebody's house, drawn by us, forever.
|
||||
* So the hot pixel is deliberately *instrumental*: a small hard dot with a
|
||||
* narrow ring, cold at the centre, closer to a plotted sample than to a flame.
|
||||
* The smoke puff is the opposite — no edge anywhere, all silhouette.
|
||||
*
|
||||
* Both builders return `null` where there is no DOM. `npm test` is `node --test`
|
||||
* with type stripping and the server workspace typechecks under Node, so an
|
||||
* asset module that threw on import there would take the zero-config boot
|
||||
* CONTRACT.md §5.1 tests with it. Every consumer has an analytic fallback; see
|
||||
* `src/engine/fires.ts`.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { fbm } from "../engine/world.ts";
|
||||
|
||||
// ---- The palette ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The fire mark's own two colours, base and tip, as linear-ish sRGB triples.
|
||||
*
|
||||
* Held here rather than in the engine because they are the one part of the fire
|
||||
* layer a self-hoster reskinning a world would want to find, and `src/assets`
|
||||
* is where this repo puts "what it looks like" (see `palette.ts`). The engine
|
||||
* owns *when* they are used; this owns what they are.
|
||||
*
|
||||
* Deep red at the base and a pale yellow at the tip, not orange-to-orange. A
|
||||
* single-hue glyph reads as a plastic cone at board altitude; the hue *travel*
|
||||
* is most of what makes a small shape read as combustion.
|
||||
*/
|
||||
export const FIRE_MARK_BASE = 0xb1240c;
|
||||
export const FIRE_MARK_TIP = 0xffd98a;
|
||||
|
||||
/**
|
||||
* The ground extent, by day and by night.
|
||||
*
|
||||
* The day colour is a smoke-brown that *darkens* the terrain under it, and that
|
||||
* is the honest direction: from orbit in daylight a burn is a dark smudge, not
|
||||
* a bright one. The night colour is deliberately over-range — the renderer runs
|
||||
* ACES tone mapping, so a value above 1 lands as a hot core that rolls off into
|
||||
* the surrounding orange instead of clipping to a flat disc of paint.
|
||||
*/
|
||||
export const FIRE_EXTENT_DAY = 0x3a2a20;
|
||||
export const FIRE_EXTENT_NIGHT = 0xff6a1e;
|
||||
|
||||
/**
|
||||
* The hot-pixel colour: a cold steel blue, and the whole point of it.
|
||||
*
|
||||
* Evidence is drawn in the colour of an instrument, never in the colour of the
|
||||
* thing it is evidence for. On the SoCal board — pale sand, white-blue
|
||||
* buildings, green ridges, blue ocean — a warm dot is the most salient object
|
||||
* in the frame, which is exactly the salience a satellite pass has not earned.
|
||||
*/
|
||||
export const HOT_PIXEL_COLOR = 0x8fc4e8;
|
||||
|
||||
/** The demoted colour for a cell that is known furniture. Barely a hue at all. */
|
||||
export const HOT_PIXEL_PERSISTENT_COLOR = 0x7f8892;
|
||||
|
||||
/** Smoke, lit and shaded. The plume borrows the rig's key rather than a light. */
|
||||
export const SMOKE_LIT = 0xd8cec3;
|
||||
export const SMOKE_SHADE = 0x6a615a;
|
||||
|
||||
// ---- Sprites --------------------------------------------------------------
|
||||
|
||||
const HOT_PIXEL_SIZE = 32;
|
||||
const SMOKE_PUFF_SIZE = 128;
|
||||
|
||||
/**
|
||||
* The hot-pixel sprite: a hard dot inside a thin ring, on transparent.
|
||||
*
|
||||
* White throughout — the layer tints per point, because a `persistent` cell and
|
||||
* a fresh one are the same drawing in two colours and a second texture would be
|
||||
* a second upload for a hue.
|
||||
*
|
||||
* The ring is what stops a field of these reading as a soft haze. Detections
|
||||
* cluster — an active fire is ~100 pixels across nine cells — and a hundred
|
||||
* overlapping Gaussians is a smear, whereas a hundred overlapping *rings* still
|
||||
* has countable structure in it. That structure is the signal: the reader is
|
||||
* meant to see a cluster and think "that is a lot of passes over one place",
|
||||
* which is a thought a smear cannot produce.
|
||||
*/
|
||||
export function hotPixelTexture(): THREE.Texture | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const size = HOT_PIXEL_SIZE;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
const image = ctx.createImageData(size, size);
|
||||
const data = image.data;
|
||||
for (let y = 0; y < size; y++) {
|
||||
const v = ((y + 0.5) / size) * 2 - 1;
|
||||
for (let x = 0; x < size; x++) {
|
||||
const u = ((x + 0.5) / size) * 2 - 1;
|
||||
const r = Math.hypot(u, v);
|
||||
// A solid core out to 0.30, a gap, then a ring centred at 0.66. Both ends
|
||||
// feather over about a tenth of the radius, which is a pixel and a half at
|
||||
// this resolution — enough to stop the aliasing, not enough to blur.
|
||||
const core = 1 - smoothstep(0.24, 0.36, r);
|
||||
const ring = (1 - smoothstep(0.72, 0.86, r)) * smoothstep(0.5, 0.62, r) * 0.5;
|
||||
const a = Math.min(1, core + ring);
|
||||
const i = (y * size + x) * 4;
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = Math.round(a * 255);
|
||||
}
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.name = "fireHotPixel";
|
||||
texture.wrapS = THREE.ClampToEdgeWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* One puff of smoke: a lumpy body with no edge on it anywhere.
|
||||
*
|
||||
* The same shape `clouds.ts` arrived at and for the same measured reason — a
|
||||
* plain Gaussian halves its own alpha a third of the way out, so a column of
|
||||
* them never contributes full opacity anywhere and reads as fog rather than as
|
||||
* smoke. Alpha is held flat out to `r = 0.30` and the remaining radius is spent
|
||||
* on the rim, which is where all of a plume's character lives.
|
||||
*
|
||||
* Smoke is noisier than cloud: the noise is turned up and centred lower, so it
|
||||
* bites into the body as well as the rim and a plume has holes in it.
|
||||
*/
|
||||
export function smokePuffTexture(): THREE.Texture | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const size = SMOKE_PUFF_SIZE;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
const image = ctx.createImageData(size, size);
|
||||
const data = image.data;
|
||||
for (let y = 0; y < size; y++) {
|
||||
const v = ((y + 0.5) / size) * 2 - 1;
|
||||
for (let x = 0; x < size; x++) {
|
||||
const u = ((x + 0.5) / size) * 2 - 1;
|
||||
const r = Math.hypot(u, v);
|
||||
let a = 0;
|
||||
if (r < 1) {
|
||||
const core = 1 - smoothstep(0.3, 0.98, r);
|
||||
const n = fbm(u * 3.4 + 41.3, v * 3.4 + 17.9) / 0.94;
|
||||
const lump = 0.28 + 1.0 * clamp01(n);
|
||||
// The rim guarantee: without it the noise can hold alpha out to r = 1,
|
||||
// where the quad's own edge cuts the puff off along a straight line.
|
||||
a = clamp01(core * lump) * (1 - smoothstep(0.84, 1, r));
|
||||
a = a ** 0.85;
|
||||
}
|
||||
const i = (y * size + x) * 4;
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = Math.round(a * 255);
|
||||
}
|
||||
}
|
||||
ctx.putImageData(image, 0, 0);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.name = "fireSmokePuff";
|
||||
texture.wrapS = THREE.ClampToEdgeWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
// ---- Helpers --------------------------------------------------------------
|
||||
|
||||
function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||
if (edge1 === edge0) return x < edge0 ? 0 : 1;
|
||||
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
return v < 0 ? 0 : v > 1 ? 1 : v;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
LUMBRIDGE_EV_METRICS,
|
||||
advanceModelXWheels,
|
||||
buildModelX,
|
||||
buildHeadlampPool,
|
||||
buildLumbridgeEV,
|
||||
cloneModelX,
|
||||
createModelXMaterials,
|
||||
@@ -13,6 +14,7 @@ export {
|
||||
modelXInstanceParts,
|
||||
setModelXSteering,
|
||||
setModelXWheelRotation,
|
||||
type HeadlampPool,
|
||||
type ModelXBuildOptions,
|
||||
type ModelXDetail,
|
||||
type ModelXInstancePart,
|
||||
|
||||
@@ -1563,3 +1563,216 @@ export function disposeModelX(
|
||||
for (const material of materials) material.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The headlamp pool -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* The light the car throws on the road ahead of it — as emission, not as a light.
|
||||
*
|
||||
* From the chase camera the subject is *the back of a car*, so on an unlit
|
||||
* corridor after dark the whole picture of it is two tail lamps and a roof
|
||||
* strip: the `drive-101` night frame measured 7.8 mean luminance against 23.2
|
||||
* for the same body over a city, and it was the subject that had vanished, not
|
||||
* the frame that was underexposed. What makes a car read at night from behind is
|
||||
* not the car. It is the pool of light in front of it.
|
||||
*
|
||||
* **It cannot be a spotlight.** CONTRACT.md §4 gives `Atmosphere` the only light
|
||||
* rig there is, and the reason is not bureaucratic: three.js evaluates every
|
||||
* light in the fragment shader for every lit surface, the corridor is one of the
|
||||
* heaviest boards in the app, and a `SpotLight` with a shadow camera is a second
|
||||
* shadow pass over all of it. So this is what `nightlights.ts` does for the
|
||||
* buildings, applied to a car: the road is not illuminated, the pool *is* the
|
||||
* light — one additive textured quad, two triangles, one draw call, no shadow
|
||||
* pass and nothing for the rig to reconcile.
|
||||
*
|
||||
* Two triangles rather than a tessellated fan with vertex colours, because the
|
||||
* whole shape of a beam is a smooth two-dimensional falloff and that is what a
|
||||
* texture is. A grid fine enough to interpolate this without banding is a couple
|
||||
* of thousand triangles for a thing that is never more than a few hundred pixels.
|
||||
*
|
||||
* The pattern is a real low beam and not a symmetrical blob:
|
||||
*
|
||||
* - it is brightest a few metres ahead and falls away with distance, rather
|
||||
* than being brightest at the lamp, because what you see is the *road*
|
||||
* returning it and the road ahead is at a grazing angle;
|
||||
* - it widens as it goes, from about a car's width at the bumper to three;
|
||||
* - and it kicks to the **right**, which is what a low beam does in a
|
||||
* right-hand-traffic country so that it lights the shoulder and the verge
|
||||
* without putting the hot spot in an oncoming driver's eyes. That asymmetry
|
||||
* is most of what makes the pool read as a headlamp beam rather than as a
|
||||
* glow somebody painted under the car.
|
||||
*
|
||||
* Everything is in vehicle metres, so it scales with whatever the renderer
|
||||
* scales the rig to, exactly as the bodywork does.
|
||||
*/
|
||||
export interface HeadlampPool {
|
||||
mesh: THREE.Mesh;
|
||||
/** 0 = off, 1 = full. Nothing is drawn at 0. */
|
||||
setIntensity(level: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the pool reaches, in vehicle metres — and why the caller gets to say.
|
||||
*
|
||||
* A real low beam throws about 60 m, and that is the wrong number here. The
|
||||
* boards do not draw the world at the vehicle's own scale: the California
|
||||
* corridor is a *diorama*, with its carriageway about 5.7 m wide against a car
|
||||
* that is honestly 5.04 m long, so it is drawn at roughly half the scale of the
|
||||
* thing driving on it. A beam authored at its true range comes out twice as long
|
||||
* as it should relative to the road it is lying on, which is exactly what the
|
||||
* first render of this showed: a pool that ran off the top of the frame.
|
||||
*
|
||||
* So the shape below is authored once, in units of its own reach, and the reach
|
||||
* is a parameter. `roadTraffic.ts` picks about three carriageway-widths, which is
|
||||
* what a low beam looks like from a chase camera. Everything else scales with it,
|
||||
* so the pattern is the same pattern at any range.
|
||||
*/
|
||||
const POOL_REACH_M = 17;
|
||||
/** Width of the plane, as a fraction of the reach. Wide enough to hold the lobe. */
|
||||
const POOL_WIDTH_FRACTION = 0.62;
|
||||
/** Where the returned brightness has halved, as a fraction of the reach. */
|
||||
const POOL_FALLOFF_FRACTION = 0.40;
|
||||
/** Half-width at the bumper, and how fast the beam opens out. */
|
||||
const POOL_HALF_AT_NOSE_FRACTION = 0.045;
|
||||
const POOL_SPREAD = 0.17;
|
||||
/** How far right the hot spot walks over the whole pool, as a fraction of reach. */
|
||||
const POOL_RIGHT_KICK_FRACTION = 0.075;
|
||||
|
||||
/**
|
||||
* A headlamp beam is not white on the road. Sodium-free modern optics are cool,
|
||||
* and asphalt returns them a little cooler still, but a pure blue-white pool
|
||||
* reads as moonlight rather than as a lamp — so this sits just on the warm side
|
||||
* of the LED and lets the marking paint inside it go properly white.
|
||||
*/
|
||||
const POOL_COLOR = 0xd8e4ff;
|
||||
|
||||
/**
|
||||
* Peak radiance of the pool.
|
||||
*
|
||||
* Under 1 for the same reason `WINDOW_GAIN` is in `nightlights.ts`: this blends
|
||||
* additively over an asphalt that already has a moonlit floor under it, and at 1
|
||||
* the near field clips to white and takes the lane markings inside it with it.
|
||||
* What has to survive is the *ratio* between the pool, the markings in it and
|
||||
* the black outside it.
|
||||
*/
|
||||
const POOL_GAIN = 0.8;
|
||||
|
||||
function headlampPoolTexture(): THREE.Texture {
|
||||
const width = 128;
|
||||
const height = 256;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("2D canvas context unavailable");
|
||||
const image = ctx.createImageData(width, height);
|
||||
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
// Everything is in fractions of the reach, so one texture serves any range.
|
||||
// `flipY` is on by default, so image row 0 is v = 1, which the geometry
|
||||
// below puts at the far end of the pool.
|
||||
const ahead = 1 - row / (height - 1);
|
||||
// Grazing incidence plus inverse-square, collapsed into one curve fitted to
|
||||
// what a low beam looks like from behind the car: a bright throw that starts
|
||||
// just off the bumper, peaks about a fifth of the way out, and is gone at
|
||||
// the far end. The `smoothstep` at the near end is the bumper's own shadow.
|
||||
const near = Math.min(1, Math.max(0, ahead / 0.05));
|
||||
// …and a taper to nothing at the far edge of the quad. Without it the beam
|
||||
// still has an eighth of its brightness where the geometry stops, and a
|
||||
// headlamp pool that ends in a ruled horizontal line across the carriageway
|
||||
// is the single most obvious way this reads as a decal rather than as light.
|
||||
// The same is true across: `POOL_WIDTH_FRACTION` is wider than the lobe
|
||||
// needs so that the sides run out inside the quad rather than at its edge.
|
||||
const far = 1 - Math.min(1, Math.max(0, (ahead - 0.68) / 0.32));
|
||||
const along =
|
||||
(near * near * (3 - 2 * near) * (far * far * (3 - 2 * far))) /
|
||||
(1 + (ahead / POOL_FALLOFF_FRACTION) ** 2.1);
|
||||
const half = POOL_HALF_AT_NOSE_FRACTION + ahead * POOL_SPREAD * POOL_WIDTH_FRACTION;
|
||||
const centre = ahead * POOL_RIGHT_KICK_FRACTION;
|
||||
for (let column = 0; column < width; column += 1) {
|
||||
const across = (column / (width - 1) - 0.5) * POOL_WIDTH_FRACTION - centre;
|
||||
const lobe = Math.exp(-((across / half) ** 2) * 2.0);
|
||||
const value = Math.max(0, Math.min(1, along * lobe));
|
||||
const at = (row * width + column) * 4;
|
||||
image.data[at] = 255;
|
||||
image.data[at + 1] = 255;
|
||||
image.data[at + 2] = 255;
|
||||
image.data[at + 3] = Math.round(value * 255);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(image, 0, 0);
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
export function buildHeadlampPool(options: { reachM?: number } = {}): HeadlampPool {
|
||||
const reach = options.reachM ?? POOL_REACH_M;
|
||||
const geometry = new THREE.PlaneGeometry(reach * POOL_WIDTH_FRACTION, reach);
|
||||
geometry.rotateX(-Math.PI / 2);
|
||||
/**
|
||||
* `+Y` of the plane becomes `-Z` after that rotation, which is the nose, so
|
||||
* the pool runs forward from the bumper.
|
||||
*
|
||||
* It sits **on the rig's own origin plane** — the tyre contact plane — and not
|
||||
* at the height of the asphalt, which took a magenta test quad to work out.
|
||||
* The corridor renderer lifts the car a whisker clear of the road, and that
|
||||
* lift is not decoration: the vehicle is placed from the *transport pack's*
|
||||
* route while the road ribbon is draped from the *city pack's* road, and the
|
||||
* two are close but not the same line, so the ground under the car and the
|
||||
* ground under the ribbon differ by a metre or two along a 700 km corridor.
|
||||
* At thirteen times vertical exaggeration that is more than enough to put a
|
||||
* pool authored at the asphalt's nominal height underneath it, where the depth
|
||||
* test quietly discards every pixel of it. Sitting where the tyres sit is the
|
||||
* one height that is right wherever the car is, and it is the same answer the
|
||||
* renderer already had to reach for the car itself.
|
||||
*/
|
||||
geometry.translate(0, 0, -reach / 2 - MODEL_X_METRICS.length / 2);
|
||||
|
||||
/**
|
||||
* No DOM, no pool — and that is the safe failure, not a degraded one.
|
||||
*
|
||||
* The beam pattern *is* the texture: every bit of shape this thing has lives
|
||||
* in the alpha channel, so a pool built without one is not a dimmer pool, it
|
||||
* is a hard-edged glowing rectangle lying across the carriageway. The road
|
||||
* layer is constructed in headless unit tests that have no `document` and no
|
||||
* renderer either, so the honest answer there is a pool that is never drawn.
|
||||
*/
|
||||
const map = typeof document === "undefined" ? null : headlampPoolTexture();
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
name: "model-x.headlamp-pool",
|
||||
color: POOL_COLOR,
|
||||
map,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.name = "model-x.headlamp-pool";
|
||||
mesh.visible = false;
|
||||
// The pool is a long way in front of its own origin, and a chase camera keeps
|
||||
// the car near the bottom of the frame with the beam filling the middle of it.
|
||||
// Culled on the car's bounding sphere it flickers out at exactly the framing
|
||||
// it exists for.
|
||||
mesh.frustumCulled = false;
|
||||
mesh.renderOrder = 2;
|
||||
|
||||
return {
|
||||
mesh,
|
||||
setIntensity(level: number) {
|
||||
const clamped = Math.max(0, Math.min(1, level));
|
||||
material.opacity = clamped * POOL_GAIN;
|
||||
mesh.visible = map !== null && clamped > 0.002;
|
||||
},
|
||||
dispose() {
|
||||
geometry.dispose();
|
||||
map?.dispose();
|
||||
material.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user