feat: give the boards a horizon, a sea that reflects, and a state worth flying over
The wide shot, which is what an anonymous visitor actually lands on. **The sea was `MeshLambertMaterial`** — a material with no specular term at all, by construction — on a board where water is half the frame. It is now a low-roughness dielectric that reads `scene.environment`, with a runtime-generated tiling swell normal map sampled twice per fragment at two scales and two headings, so the sun breaks into a moving glitter path instead of a mirror point. An `onBeforeCompile` patch takes the body colour toward the deep value looking straight down and leaves it to the reflection at grazing, and walks roughness up past 1.6 board spans so the far water cannot shimmer. The swell spectrum is 1/k^2 and not 1/k because the first attempt was photographed: at 1/k every component carries the same slope, the shortest wave wins, and the sea renders as hard diagonal corduroy. A test holds it now. **The board no longer ends in a diamond.** The sea plane went from 1.8 board spans to 18, past the fog's far plane from anywhere the orbit reaches, and the sky is a world-space dome rather than a screen-space gradient. That gradient was wrong in a way dusk made obvious: the sunset band was painted along the *bottom* of the picture, under the board, while the true horizon at the top of frame stayed zenith blue. `daylight.ts` pinning the horizon stop to the fog colour to hide the seam was a symptom of it. **Terrain casts shadows.** Left off before because double-sided terrain against a ~16 m-per-texel shadow map gives acne; `shadowSide = BackSide` is the cure, shot at four sun elevations down to +0.0 degrees to confirm no stippling. The caster is a stride-2 decimation appended to the same index buffer and swapped in by `onBeforeShadow`/`onAfterShadow` via `drawRange`: no extra draw call, a quarter of the depth cost, and indistinguishable from the full-resolution caster in a side-by-side crop. Stride 1 was measured at +65,566 triangles and would have missed the budget by ~47,000, so it was not shipped. **California reads as California.** It was a beige kite: the eastern edge one ruled line for five degrees of latitude, the south closing in a diagonal V, the whole south-east a featureless tan wedge. Now the coast runs to the Mexican border with San Diego on it, the eastern edge follows the Colorado and the Nevada diagonal, and the south-east is the Basin and Range — forty parallel desert ridges throwing shadows east, Death Valley as a white pan between the Panamints and the Black Mountains, the Salton Sea the one cool value for two hundred kilometres. The opening pose is retuned to the bigger board; the old 452/392 stand-off left a slab of empty ocean where the state should be. **The aircraft were six pixels.** Measured, by enlarging a screenshot 200% to find one at all — indistinguishable from a dead pixel, on a board whose entire claim is that the sky is live. They are airliners now, with planform and trail, and clicking one raises its card for a signed-out visitor. **The Model X is off the wall.** It stood at floor level outside a studio 188 m up a Transbay tower, reading as a car balanced on a parapet. The apron is now chosen from `site.elevation`, which the pack already carries — not from an office id, which is the bug class this repo already hit once when a door marker gated on `id === "sf"` and would have pinned the Los Angeles building to San Francisco. Also fixed, and nearly shipped: sea z-fighting dithered every flat piece of ground on the Bay Area and SoCal boards. And one test asserted an exact source line for the water material, so the better multi-line implementation failed it — it now asserts the property (dielectric, metalness 0, low roughness) rather than the author's first guess at formatting. Tests 964 -> 1015. California desktop 562/650 draw calls and 728,744/750,000 triangles — 2.8% of triangle headroom left, which is the number the next person should check first. No budget was raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+89
-4
@@ -341,6 +341,15 @@ export async function createScene(
|
||||
);
|
||||
|
||||
const orbitMinDistance = Math.max(4, boardSpan * 0.02);
|
||||
/**
|
||||
* The far end of the orbit, held rather than written twice.
|
||||
*
|
||||
* `chapterPose` below has to know it: a pose beyond it is not obeyed, it is
|
||||
* silently clamped by `OrbitControls` on the next update, so a framing
|
||||
* correction that asks for more than this is a framing correction that does
|
||||
* nothing. See the note on the option itself for where 2.0 comes from.
|
||||
*/
|
||||
const orbitMaxDistance = boardSpan * 2.0;
|
||||
const kit = createSceneKit({
|
||||
scene,
|
||||
dom: stage.renderer.domElement,
|
||||
@@ -373,7 +382,7 @@ export async function createScene(
|
||||
* The far plane already covers it: the furthest thing from the camera is then
|
||||
* the back of the dome at 2.7 spans, against `far` at 3.0.
|
||||
*/
|
||||
maxDistance: boardSpan * 2.0,
|
||||
maxDistance: orbitMaxDistance,
|
||||
shadowExtent: boardSpan * 0.75,
|
||||
/**
|
||||
* The middle of the board, which is **not** the origin.
|
||||
@@ -566,12 +575,18 @@ export async function createScene(
|
||||
function chapterPose(ch: Chapter): Pose {
|
||||
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
|
||||
const groundY = world.groundAt(ch.focus.lat, ch.focus.lng);
|
||||
const scale = chapterFraming({
|
||||
aspect: kit.camera.aspect,
|
||||
reach: Math.hypot(ch.focus.distance, ch.focus.height),
|
||||
boardSpan,
|
||||
orbitMax: orbitMaxDistance,
|
||||
});
|
||||
return {
|
||||
target: new THREE.Vector3(x, groundY, z),
|
||||
position: new THREE.Vector3(
|
||||
x + Math.sin(ch.focus.rotation) * ch.focus.distance,
|
||||
groundY + ch.focus.height,
|
||||
z + Math.cos(ch.focus.rotation) * ch.focus.distance,
|
||||
x + Math.sin(ch.focus.rotation) * ch.focus.distance * scale,
|
||||
groundY + ch.focus.height * scale,
|
||||
z + Math.cos(ch.focus.rotation) * ch.focus.distance * scale,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -862,6 +877,76 @@ export async function createScene(
|
||||
* It exists so the engine renders with no server, no clock and no config, which
|
||||
* is the acceptance test the whole repo is held to.
|
||||
*/
|
||||
/**
|
||||
* How wide a screen a `Chapter.focus` was written for.
|
||||
*
|
||||
* Every pose in every pack was authored on one, and `fov: 42` is a **vertical**
|
||||
* field of view: the horizontal half-angle is `atan(tan(21°) × aspect)`, so how
|
||||
* much of a board is in frame sideways is entirely a fact about the shape of the
|
||||
* window. At 16:10 that half-angle is 31.6°. On a phone held upright — 390 × 844,
|
||||
* an aspect of 0.46 — it is 10.1°, a third as wide, and the pose that framed a
|
||||
* whole state on a laptop frames the middle third of it and runs the rest off
|
||||
* both sides of the screen.
|
||||
*/
|
||||
const AUTHORED_ASPECT = 1.6;
|
||||
|
||||
/**
|
||||
* Where a stand-off stops being a whole-board shot and starts being a place on
|
||||
* it, in board spans. See `chapterFraming`.
|
||||
*/
|
||||
const WHOLE_BOARD_FROM = 0.8;
|
||||
const WHOLE_BOARD_TO = 1.2;
|
||||
|
||||
/**
|
||||
* How much further back to stand than the pose asked for, given the window it
|
||||
* is actually being looked at through.
|
||||
*
|
||||
* Returns a multiplier applied to `distance` **and** `height` together, so the
|
||||
* angle the board is seen from — which is most of the character of a pack's
|
||||
* opening shot — is exactly preserved and only the stand-off changes.
|
||||
*
|
||||
* Three things bound it, and the second and third were both learned from a
|
||||
* screenshot rather than from the arithmetic.
|
||||
*
|
||||
* - **The aspect.** The correction wanted is `AUTHORED_ASPECT / aspect`,
|
||||
* because the width in frame at a given distance is linear in the aspect.
|
||||
* A window at least as wide as the authored one gets nothing at all.
|
||||
* - **The orbit's own ceiling.** `setPose` hands the camera to
|
||||
* `OrbitControls`, which clamps to `maxDistance` on its next update, so a
|
||||
* correction that asks for more than that is not a correction — the pose
|
||||
* quietly becomes something nobody wrote.
|
||||
* - **Whether the pose was ever about the whole board.** Correcting every
|
||||
* pose was tried and it ruins the close-ups: Southern California's opening
|
||||
* shot stands off half a board span and is a dense city filling a tall
|
||||
* screen to all four edges, and pulling it back three times put it in the
|
||||
* top third of the frame over half a screen of empty ocean. Widening the
|
||||
* frame is a correction for a shot that was trying to hold something and
|
||||
* no longer can; on a shot that was deliberately *inside* its subject it is
|
||||
* a different photograph. Below `WHOLE_BOARD_FROM` spans of stand-off the
|
||||
* correction is off; above `WHOLE_BOARD_TO` it is fully on.
|
||||
*/
|
||||
export function chapterFraming(options: {
|
||||
aspect: number;
|
||||
reach: number;
|
||||
boardSpan: number;
|
||||
orbitMax: number;
|
||||
}): number {
|
||||
const { aspect, reach, boardSpan, orbitMax } = options;
|
||||
if (!Number.isFinite(reach) || reach <= 0) return 1;
|
||||
const wanted =
|
||||
Number.isFinite(aspect) && aspect > 0 && aspect < AUTHORED_ASPECT
|
||||
? AUTHORED_ASPECT / aspect
|
||||
: 1;
|
||||
const ceiling = Number.isFinite(orbitMax) && orbitMax > 0 ? orbitMax / reach : 1;
|
||||
const wide = Math.max(1, Math.min(wanted, ceiling));
|
||||
const spans = Number.isFinite(boardSpan) && boardSpan > 0 ? reach / boardSpan : 0;
|
||||
const share = Math.max(
|
||||
0,
|
||||
Math.min(1, (spans - WHOLE_BOARD_FROM) / (WHOLE_BOARD_TO - WHOLE_BOARD_FROM)),
|
||||
);
|
||||
return 1 + (wide - 1) * share;
|
||||
}
|
||||
|
||||
export function cityDaylight(palette: ScenePalette, boardSpan = 230): LightingState {
|
||||
return {
|
||||
sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 },
|
||||
|
||||
Reference in New Issue
Block a user