SoCal, the whole bay, a moon, and gates that actually run
Six agents in parallel, and the two city packs independently reported the same blocker: `focusRegions` and `coarseFactor` existed on the `City` type and nothing implemented them. Uniform lattices would have been 2.9M points for Southern California and 3.7M for the expanded bay. Both packs were unloadable as written. `buildAxis` is the answer, and it is honest about its limits: refinement is per axis, not per rectangle, so a focus region sharpens its whole row *and* its whole column. Two regions at opposite corners refine nearly everything between them. Measured, not guessed — the bay went 0.53M points with one region and 1.64M with three, for detail nobody is looking at from a board this wide. One region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s. Then three things that were only ever right because San Francisco was the only city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a 230-unit board; the bay is 1003 units across and the camera physically could not retreat far enough to frame it. Fog distances were scene units pinned to the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest weather, which over ninety-four kilometres of bay correctly hides three quarters of it — the night view was a black rectangle for a completely reasonable reason. All three now derive from the board. The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a physical ratio of one to four hundred thousand. What is being reproduced is what a moonlit night looks like on a screen in a lit room. The CI gate caught itself, which is the part worth keeping. Port 8431 was already held by a server from an earlier session, so the boot check polled a healthy stranger while the process it started died on EADDRINUSE. It now refuses to run rather than pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* The city's own lights: lit windows in the buildings, and lamps along the
|
||||
* streets.
|
||||
*
|
||||
* This is the other half of making a night usable. `atmosphere.ts` puts a moon
|
||||
* up so there is something to see the city *by*; this puts light *in* the city,
|
||||
* which is most of what a city at night actually is — from any distance a
|
||||
* skyline after dark is not a shape you can make out, it is a field of small
|
||||
* bright rectangles that happens to have a shape.
|
||||
*
|
||||
* Two constraints shaped everything here:
|
||||
*
|
||||
* - **There are ~24,000 buildings in one `InstancedMesh`.** A point light per
|
||||
* building is not a slow version of this, it is an impossible one: three.js
|
||||
* evaluates every light in the fragment shader for every lit surface, and
|
||||
* the practical ceiling is a few dozen. So the buildings do not emit light
|
||||
* at all. They *are* light — an emissive term added inside the existing
|
||||
* material, which costs one shader patch and no extra draw calls, and which
|
||||
* the moon and the fog and the shadows all continue to work around
|
||||
* untouched.
|
||||
* - **Nothing may reshuffle between frames or reloads.** Which windows are on
|
||||
* is a hash of the window's own cell index and a per-instance seed drawn
|
||||
* from `blocks.ts`'s seeded RNG, evaluated in the fragment shader. It is a
|
||||
* pure function of position, so it is stable across frames for free, and it
|
||||
* costs no memory at all: 24,000 buildings' worth of individual windows
|
||||
* would be millions of booleans and there are none of them anywhere.
|
||||
*
|
||||
* **This is not a lighting owner.** `Atmosphere` owns the rig and CONTRACT.md §4
|
||||
* is explicit that nothing else may touch it; what this module owns is
|
||||
* *emission*, which is a property of the buildings and not of the light rig, and
|
||||
* it never constructs a `THREE.Light` of any kind. The seam is one number in:
|
||||
* `setSolarElevation`, which is the same solar elevation `Atmosphere` is
|
||||
* reading. Night is data, not a mode flag, and there is nothing to switch.
|
||||
*
|
||||
* An interior gets none of this, for the same reason it gets no `Atmosphere`:
|
||||
* an office has its own fixed rig and no idea what time it is outside.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { nightFactor } from "./atmosphere.ts";
|
||||
import { FACADE_ATTRIBUTE } from "./blocks.ts";
|
||||
import { seededRandom, type World } from "./world.ts";
|
||||
|
||||
export interface NightLightsOptions {
|
||||
world: World;
|
||||
/** The buildings, exactly as `createBlocks` returned them. */
|
||||
blocks: THREE.InstancedMesh;
|
||||
/** Lamps along the road network. On by default. */
|
||||
streetLamps?: boolean;
|
||||
/** Metres between street lamps. */
|
||||
lampSpacingM?: number;
|
||||
/**
|
||||
* Ceiling on how many lamps get built, as insurance against a city pack with
|
||||
* a very dense road network. SF's twenty-five roads produce a few thousand.
|
||||
*/
|
||||
maxLamps?: number;
|
||||
}
|
||||
|
||||
export interface NightLights {
|
||||
/** Everything this layer adds to the scene. Added once, then driven. */
|
||||
group: THREE.Group;
|
||||
/**
|
||||
* The seam. Hand it the solar elevation in degrees — the same number
|
||||
* `Atmosphere` is working from — and the city switches itself on.
|
||||
*/
|
||||
setSolarElevation(degrees: number): void;
|
||||
/** How on the lights currently are, 0..1. For a debug readout. */
|
||||
strength(): number;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
// ---- Constants ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A window bay, and a storey, in metres.
|
||||
*
|
||||
* The storey is real. The bay is not: a curtain wall's mullions are nearer 1.5 m
|
||||
* apart, and at San Francisco's ~94 m per scene unit that is a third of a pixel
|
||||
* from anywhere the camera is allowed to be, so an honest bay renders as grey
|
||||
* noise and nothing else. 6.5 m is the coarsest grid that still reads as
|
||||
* windows rather than as panels, which puts about six bays across a 40 m lot
|
||||
* and gives a pane the wide flat shape of a ribbon window. Vertically there is
|
||||
* no such problem — the city's 3.6x exaggeration makes a storey four times a
|
||||
* bay on screen — so the storey stays honest.
|
||||
*/
|
||||
const WINDOW_PITCH_M = 6.5;
|
||||
const STOREY_M = 3.6;
|
||||
|
||||
/**
|
||||
* Fraction of windows left on, by how commercial the building is.
|
||||
*
|
||||
* Both are lower than they look, and deliberately: the aggregate is what the
|
||||
* eye reads, and the first pass at this — half of every office window on — came
|
||||
* out as a city of solid glowing slabs with no building shapes left in it. A
|
||||
* quarter is already a *lot* of light once every pane is at nearly full
|
||||
* emission, and a house showing one window in sixteen is a street with somebody
|
||||
* still up on it.
|
||||
*/
|
||||
const HOUSE_LIT = 0.06;
|
||||
const OFFICE_LIT = 0.24;
|
||||
|
||||
/**
|
||||
* The two colours a lit window comes in.
|
||||
*
|
||||
* Warm is a domestic lamp — tungsten, or the LED everyone buys because it looks
|
||||
* like tungsten — at something like 2,700 K. Cool is an office ceiling left on
|
||||
* by the cleaners, which is the other half of any real skyline and the half
|
||||
* that makes the warm windows read as warm. Passed as `THREE.Color`, so three
|
||||
* converts them out of sRGB into the linear working space on the way to the
|
||||
* uniform and the emissive term lands in the same space as everything else in
|
||||
* the shader.
|
||||
*/
|
||||
const WINDOW_WARM = 0xffc178;
|
||||
const WINDOW_COOL = 0xd8e4ff;
|
||||
|
||||
/** Peak emissive radiance of a lit pane. Below 1 so a window is bright, not blown. */
|
||||
const WINDOW_GAIN = 0.8;
|
||||
|
||||
/** Sodium, because a street lamp is the one light in a city that still is. */
|
||||
const LAMP_COLOR = 0xffb264;
|
||||
const LAMP_HEIGHT_M = 9;
|
||||
const DEFAULT_LAMP_SPACING_M = 55;
|
||||
const DEFAULT_MAX_LAMPS = 24_000;
|
||||
|
||||
/**
|
||||
* Glow radius of a lamp, in scene units.
|
||||
*
|
||||
* Chosen against the far end of the camera's orbit rather than the near end. At
|
||||
* 100 units out — the framing the city is usually looked at from — this is a
|
||||
* few pixels, which is what a street lamp is; flying down to the 12-unit
|
||||
* minimum blooms it to something much larger than a lamp. That is the wrong way
|
||||
* round from a purist's point of view and the right way round for the frame
|
||||
* anyone actually looks at, and the alternative — a fixed pixel size — turns the
|
||||
* whole road network into a sheet of aliasing sparkle the moment you pull back.
|
||||
*/
|
||||
const LAMP_SIZE = 0.3;
|
||||
|
||||
const LAMP_SEED = 61_803;
|
||||
|
||||
/**
|
||||
* When the lamps come on, in degrees of solar elevation.
|
||||
*
|
||||
* Earlier than `nightFactor`, and deliberately so: street lighting switches on
|
||||
* around sunset, an hour before the sky is dark, and offices have been lit
|
||||
* since the afternoon. What `nightFactor` then adds is not more lights but more
|
||||
* *contrast* — the same windows against a sky that has stopped competing with
|
||||
* them. Multiplying the two is what produces the real sequence, where the city
|
||||
* appears to come on gradually over an hour without anything ever switching.
|
||||
*/
|
||||
const LAMPS_ON_HIGH = 5;
|
||||
const LAMPS_ON_LOW = -5;
|
||||
|
||||
/** Below this the layer is hidden outright rather than drawn at zero. */
|
||||
const DARK_ENOUGH = 0.002;
|
||||
|
||||
// ---- The layer ------------------------------------------------------------
|
||||
|
||||
export function createNightLights(options: NightLightsOptions): NightLights {
|
||||
const { world, blocks } = options;
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.name = "nightlights";
|
||||
|
||||
// Shared with the shader by reference: `onBeforeCompile` hands these exact
|
||||
// objects to the program, so writing `.value` here is what drives the frame.
|
||||
const uniforms = {
|
||||
uNight: { value: 0 },
|
||||
uWindowPitch: { value: WINDOW_PITCH_M / world.metresPerUnit },
|
||||
// A storey goes through `world.metres`, so it picks up the city's vertical
|
||||
// exaggeration exactly as the building's own height did. Without that the
|
||||
// floor count would be wrong by the exaggeration factor — a 100 m tower
|
||||
// would come out with a hundred floors in it.
|
||||
uStorey: { value: world.metres(STOREY_M) },
|
||||
uWarm: { value: new THREE.Color(WINDOW_WARM) },
|
||||
uCool: { value: new THREE.Color(WINDOW_COOL) },
|
||||
uGain: { value: WINDOW_GAIN },
|
||||
uHouseLit: { value: HOUSE_LIT },
|
||||
uOfficeLit: { value: OFFICE_LIT },
|
||||
};
|
||||
|
||||
const facade = blocks.geometry.getAttribute(FACADE_ATTRIBUTE);
|
||||
const material = blocks.material;
|
||||
const patched =
|
||||
!Array.isArray(material) && material instanceof THREE.MeshLambertMaterial && facade
|
||||
? patchFacades(material, uniforms)
|
||||
: null;
|
||||
|
||||
const lamps = (options.streetLamps ?? true) ? buildLamps(world, options) : null;
|
||||
if (lamps) group.add(lamps.points);
|
||||
|
||||
let strength = 0;
|
||||
|
||||
function setSolarElevation(degrees: number) {
|
||||
// Two curves, multiplied: when the lights are on, and how much darker than
|
||||
// them the sky is. See `LAMPS_ON_HIGH`.
|
||||
const on = 1 - smoothstep(LAMPS_ON_LOW, LAMPS_ON_HIGH, degrees);
|
||||
strength = on * (0.35 + 0.65 * nightFactor(degrees));
|
||||
|
||||
uniforms.uNight.value = strength;
|
||||
if (lamps) {
|
||||
lamps.points.visible = strength > DARK_ENOUGH;
|
||||
lamps.material.opacity = strength;
|
||||
}
|
||||
}
|
||||
|
||||
setSolarElevation(90);
|
||||
|
||||
return {
|
||||
group,
|
||||
setSolarElevation,
|
||||
strength: () => strength,
|
||||
dispose() {
|
||||
// The buildings are not ours and outlive this layer, so the material goes
|
||||
// back exactly as it was found rather than being left with a dark
|
||||
// uniform in it and a patch nobody remembers applying.
|
||||
patched?.();
|
||||
lamps?.points.geometry.dispose();
|
||||
lamps?.material.map?.dispose();
|
||||
lamps?.material.dispose();
|
||||
group.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Lit windows ----------------------------------------------------------
|
||||
|
||||
type Uniforms = Record<string, { value: unknown }>;
|
||||
|
||||
/**
|
||||
* Add an emissive window grid to the buildings' own material.
|
||||
*
|
||||
* Patching in place rather than replacing the material, because `blocks.ts`
|
||||
* owns what a facade looks like in daylight and this has no business having an
|
||||
* opinion about that. Everything below is additive: a `totalEmissiveRadiance`
|
||||
* term, computed after the lighting has been accumulated and before fog and the
|
||||
* colour-space encode, so a lit window is correctly hazed by the marine layer
|
||||
* and correctly *not* darkened by being in shadow. Which is right — a window is
|
||||
* a hole with a light behind it, and nothing outside the building can shade it.
|
||||
*
|
||||
* Returns the undo.
|
||||
*/
|
||||
function patchFacades(material: THREE.MeshLambertMaterial, uniforms: Uniforms): () => void {
|
||||
const previous = material.onBeforeCompile;
|
||||
|
||||
material.onBeforeCompile = (shader) => {
|
||||
for (const [name, uniform] of Object.entries(uniforms)) {
|
||||
shader.uniforms[name] = uniform as THREE.IUniform;
|
||||
}
|
||||
|
||||
shader.vertexShader = shader.vertexShader
|
||||
.replace("#include <common>", `#include <common>\n${VERTEX_PARS}`)
|
||||
.replace("#include <project_vertex>", `#include <project_vertex>\n${VERTEX_BODY}`);
|
||||
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace("#include <common>", `#include <common>\n${FRAGMENT_PARS}`)
|
||||
.replace(
|
||||
"#include <emissivemap_fragment>",
|
||||
`#include <emissivemap_fragment>\n${FRAGMENT_BODY}`,
|
||||
);
|
||||
};
|
||||
// `Material.customProgramCacheKey` defaults to the source of
|
||||
// `onBeforeCompile`, so the renderer will not hand this material a program
|
||||
// compiled for an unpatched one. Changing the function is still a new
|
||||
// program, hence the flag.
|
||||
material.needsUpdate = true;
|
||||
|
||||
return () => {
|
||||
material.onBeforeCompile = previous;
|
||||
material.needsUpdate = true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The varyings are declared unconditionally in both stages — a varying present
|
||||
* in one and absent from the other is a link error — while the two things that
|
||||
* only exist under instancing are guarded. `aFacade` needs no guard: an
|
||||
* unbound vertex attribute reads as zero, which is a building with no windows
|
||||
* lit, which is a perfectly good failure.
|
||||
*/
|
||||
const VERTEX_PARS = /* glsl */ `
|
||||
attribute vec2 aFacade;
|
||||
varying vec3 vFacadeLocal;
|
||||
varying vec3 vFacadeNormal;
|
||||
varying vec3 vFacadeSize;
|
||||
varying vec2 vFacade;
|
||||
`;
|
||||
|
||||
const VERTEX_BODY = /* glsl */ `
|
||||
vFacadeLocal = transformed;
|
||||
vFacadeNormal = objectNormal;
|
||||
vFacade = aFacade;
|
||||
#ifdef USE_INSTANCING
|
||||
// The instance's scale, recovered from the columns of its own matrix. This is
|
||||
// what puts the window grid in scene units instead of in fractions of a
|
||||
// building: without it every tower would have the same number of floors as
|
||||
// the bungalow next door, stretched to fit.
|
||||
vFacadeSize = vec3(
|
||||
length(instanceMatrix[0].xyz),
|
||||
length(instanceMatrix[1].xyz),
|
||||
length(instanceMatrix[2].xyz)
|
||||
);
|
||||
#else
|
||||
vFacadeSize = vec3(1.0);
|
||||
#endif
|
||||
`;
|
||||
|
||||
const FRAGMENT_PARS = /* glsl */ `
|
||||
uniform float uNight;
|
||||
uniform float uWindowPitch;
|
||||
uniform float uStorey;
|
||||
uniform float uGain;
|
||||
uniform float uHouseLit;
|
||||
uniform float uOfficeLit;
|
||||
uniform vec3 uWarm;
|
||||
uniform vec3 uCool;
|
||||
varying vec3 vFacadeLocal;
|
||||
varying vec3 vFacadeNormal;
|
||||
varying vec3 vFacadeSize;
|
||||
varying vec2 vFacade;
|
||||
|
||||
float facadeHash(vec3 p) {
|
||||
return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The window grid, and the reason it does not sparkle.
|
||||
*
|
||||
* A window bay is about 4 m, which at San Francisco's ~94 m per scene unit is
|
||||
* 0.042 units — and from the distance the city is normally looked at, that is
|
||||
* well under a pixel. Drawn honestly it would be a sheet of moiré that crawls
|
||||
* whenever the camera moves, which is the classic failure of any procedural
|
||||
* pattern with no mip chain behind it. `fwidth` gives the pattern's own
|
||||
* footprint in pixels, and past about one cell per pixel the grid is replaced
|
||||
* by its average — which is exactly what a mip level would have contained. So
|
||||
* the far city is a smooth glow whose brightness is the density of its lit
|
||||
* windows, downtown reads brighter than the avenues because it genuinely has
|
||||
* more of them on, and flying in resolves individual windows out of it.
|
||||
*/
|
||||
const FRAGMENT_BODY = /* glsl */ `
|
||||
if (uNight > 0.002) {
|
||||
vec3 faceNormal = normalize(vFacadeNormal);
|
||||
// Roofs have no windows in them, and this is a box.
|
||||
float wall = 1.0 - smoothstep(0.55, 0.95, abs(faceNormal.y));
|
||||
if (wall > 0.0) {
|
||||
float across = abs(faceNormal.x) > 0.5
|
||||
? vFacadeLocal.z * vFacadeSize.z
|
||||
: vFacadeLocal.x * vFacadeSize.x;
|
||||
float up = vFacadeLocal.y * vFacadeSize.y;
|
||||
vec2 grid = vec2(across / uWindowPitch, up / uStorey);
|
||||
|
||||
vec2 cell = fract(grid);
|
||||
vec2 pane = step(vec2(0.22, 0.34), cell) * step(cell, vec2(0.78, 0.72));
|
||||
float coverage = pane.x * pane.y;
|
||||
|
||||
// Which windows are on: a hash of the cell and the building's own seed, so
|
||||
// it is a pure function of where you are looking and never has to be
|
||||
// stored, animated or reconciled.
|
||||
float roll = facadeHash(vec3(floor(grid), vFacade.y * 137.0));
|
||||
|
||||
// How lit this particular building is, on top of what its district says.
|
||||
// Without it every tower downtown has the same window density, and from a
|
||||
// distance the whole financial district smears into one flat brown
|
||||
// rectangle — which is the one thing a night skyline never looks like. The
|
||||
// curve is squared so most buildings are dim and a few blaze, and scaled so
|
||||
// that the mean of it is exactly 1 and the district's own figure still
|
||||
// means what it says.
|
||||
float variation = 0.15 + 2.55 * vFacade.y * vFacade.y;
|
||||
float chance = clamp(mix(uHouseLit, uOfficeLit, vFacade.x) * variation, 0.0, 0.9);
|
||||
float on = step(1.0 - chance, roll);
|
||||
|
||||
float footprint = max(fwidth(grid.x), fwidth(grid.y));
|
||||
float detail = 1.0 - smoothstep(0.5, 1.4, footprint);
|
||||
// 0.56 x 0.38 is the pane inside its cell, so 0.2128 x chance is the grid's
|
||||
// own mean — and 1.8 times that is what is actually used, which is a lie
|
||||
// worth being explicit about. The mean is the right answer for a display
|
||||
// whose response is linear, and no display's is: a pixel that in reality
|
||||
// contains one small blazing window and three dark ones does not read to
|
||||
// the eye as the average of the four, it reads as lit. With no HDR buffer
|
||||
// and no bloom to arrive at that honestly, the multiplier is the cheap way
|
||||
// to keep the far city as bright as the near city says it ought to be.
|
||||
float glow = mix(1.8 * 0.2128 * chance, coverage * on, detail);
|
||||
|
||||
// Roughly seven windows in ten warm. A skyline is mostly people's lamps and
|
||||
// only partly the floors the cleaners are still on.
|
||||
//
|
||||
// The colour needs the same averaging the mask got, and forgetting it is a
|
||||
// subtle and very visible bug: a mask correctly resolved to its mean, tinted
|
||||
// by a hard per-cell choice between two colours at a frequency far below one
|
||||
// pixel, gives a distant city that is the right brightness and crawling with
|
||||
// orange and white confetti.
|
||||
vec3 tint = mix(mix(uWarm, uCool, 0.3), mix(uWarm, uCool, step(0.7, fract(roll * 7.13))), detail);
|
||||
totalEmissiveRadiance += tint * (glow * wall * uNight * uGain);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ---- Street lamps ---------------------------------------------------------
|
||||
|
||||
interface Lamps {
|
||||
points: THREE.Points;
|
||||
material: THREE.PointsMaterial;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lamps along the road network, as one additive point cloud.
|
||||
*
|
||||
* Cheap enough to be worth it: a few thousand points in a single draw call,
|
||||
* with no lighting, no shadows and no per-frame work beyond an opacity. What
|
||||
* they buy is the thing the buildings cannot — the *ground* has light on it, so
|
||||
* the street grid is still legible at night and the city keeps the shape that
|
||||
* makes it recognisable from above. In San Francisco that shape is the 46°
|
||||
* between the grid north of Market and the grid south of it, and losing it
|
||||
* after dark would lose the city.
|
||||
*
|
||||
* They emit nothing, of course. A real street lamp pooling light on the road
|
||||
* under it is a second set of lights and a second shadow problem, and the
|
||||
* pooling would be invisible at any framing where the lamp itself is a pixel.
|
||||
*/
|
||||
function buildLamps(world: World, options: NightLightsOptions): Lamps | null {
|
||||
const spacing = (options.lampSpacingM ?? DEFAULT_LAMP_SPACING_M) / world.metresPerUnit;
|
||||
const lift = world.metres(LAMP_HEIGHT_M);
|
||||
const limit = options.maxLamps ?? DEFAULT_MAX_LAMPS;
|
||||
const rand = seededRandom(LAMP_SEED);
|
||||
|
||||
const positions: number[] = [];
|
||||
let index = 0;
|
||||
|
||||
for (const road of world.city.roads) {
|
||||
// Carried across segment joins, so the spacing is even along the whole
|
||||
// street rather than restarting at every corner — which would cluster
|
||||
// lamps wherever a road was written with a lot of vertices in it, and
|
||||
// those are exactly the bends.
|
||||
let carry = 0;
|
||||
|
||||
for (let i = 0; i < road.path.length - 1; i++) {
|
||||
const from = road.path[i];
|
||||
const to = road.path[i + 1];
|
||||
if (!from || !to) continue;
|
||||
|
||||
const [lat0, lng0] = from;
|
||||
const [lat1, lng1] = to;
|
||||
const [x0, z0] = world.project(lat0, lng0);
|
||||
const [x1, z1] = world.project(lat1, lng1);
|
||||
const dx = x1 - x0;
|
||||
const dz = z1 - z0;
|
||||
const length = Math.hypot(dx, dz);
|
||||
if (length <= 0) continue;
|
||||
|
||||
// Unit normal to the street, for the kerb offset.
|
||||
const nx = -dz / length;
|
||||
const nz = dx / length;
|
||||
|
||||
let s = carry;
|
||||
for (; s < length; s += spacing) {
|
||||
if (index >= limit) break;
|
||||
const t = s / length;
|
||||
const lat = lat0 + (lat1 - lat0) * t;
|
||||
const lng = lng0 + (lng1 - lng0) * t;
|
||||
// Alternating kerbs, jittered, because a street lit by a perfect ruler
|
||||
// of identical dots reads as a dashed line and not as lighting.
|
||||
const side = index % 2 === 0 ? 1 : -1;
|
||||
const offset = road.width * 0.55 * side * (0.8 + rand() * 0.4);
|
||||
positions.push(
|
||||
x0 + dx * t + nx * offset,
|
||||
world.groundAt(lat, lng) + lift,
|
||||
z0 + dz * t + nz * offset,
|
||||
);
|
||||
index++;
|
||||
}
|
||||
carry = Math.max(0, s - length);
|
||||
}
|
||||
}
|
||||
|
||||
if (positions.length === 0) return null;
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||
|
||||
const material = new THREE.PointsMaterial({
|
||||
color: LAMP_COLOR,
|
||||
map: lampTexture(),
|
||||
size: LAMP_SIZE,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
// Additive, so a hundred lamps down one street saturate into the continuous
|
||||
// line of light that a street at night actually is, rather than staying a
|
||||
// hundred separate dots however far away they are.
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const points = new THREE.Points(geometry, material);
|
||||
points.name = "streetlamps";
|
||||
points.visible = false;
|
||||
return { points, material };
|
||||
}
|
||||
|
||||
/**
|
||||
* The lamp's glow, drawn on a canvas rather than shipped as a file. No binary
|
||||
* assets is a licensing rule and not a stylistic one; see ARCHITECTURE.md.
|
||||
*/
|
||||
function lampTexture(): THREE.Texture {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("2D canvas context unavailable");
|
||||
|
||||
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
|
||||
gradient.addColorStop(0, "rgba(255,255,255,1)");
|
||||
gradient.addColorStop(0.22, "rgba(255,232,190,0.7)");
|
||||
gradient.addColorStop(0.55, "rgba(255,190,110,0.18)");
|
||||
gradient.addColorStop(1, "rgba(255,170,80,0)");
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
// ---- Helpers --------------------------------------------------------------
|
||||
|
||||
/** Hermite ease over a span, flat at both ends. `atmosphere.ts` has the twin. */
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- Sanity checks --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* What this produces for San Francisco, so the numbers above can be argued with.
|
||||
*
|
||||
* At `metresPerUnit` 94.34 and a vertical exaggeration of 3.6, a window bay is
|
||||
* 0.0424 scene units across and a storey is 0.1374 units tall — so a 260 m
|
||||
* tower gets 72 floors and a 40 m lot's frontage gets ten bays, both of which
|
||||
* are about right. The camera orbits between 12 and 340 units, and at 100 units
|
||||
* out with a 42° field of view a bay covers roughly half a pixel, which is why
|
||||
* `FRAGMENT_BODY` spends four lines on `fwidth` and would be unusable without
|
||||
* them.
|
||||
*
|
||||
* The switch-on sequence, by solar elevation:
|
||||
*
|
||||
* - **+5° and above**: 0. The lamps are not drawn at all.
|
||||
* - **+2°**: 0.076. The first offices, barely findable against the sky.
|
||||
* - **0°, sunset**: 0.178.
|
||||
* - **-2°**: 0.381.
|
||||
* - **-5°, most of the way through civil twilight**: 0.814.
|
||||
* - **-8° and below**: 1.0. The lights stopped changing some minutes ago;
|
||||
* what changed after that was the sky behind them.
|
||||
*
|
||||
* Downtown's mean emission at distance is 1.8 x 0.2128 x 0.24 x 0.8 = 0.074,
|
||||
* against the avenues' 0.025 — a ratio of just under 3:1, which is the whole
|
||||
* picture, since the thing that makes a night skyline is not that the towers
|
||||
* are taller but that they are the part of the city with all its lights still
|
||||
* on. Around each of those figures the per-building variation spans 0.15x to
|
||||
* 2.7x, so a run of towers has dark ones in it and the odd one blazing, and the
|
||||
* financial district does not smear into a single rectangle when you pull back.
|
||||
*
|
||||
* On a moonless night the buildings come out at about #4a403d against water at
|
||||
* #191b21 and a sky at #2d3855: the city is the brightest thing in the frame,
|
||||
* as it should be, and the sky is still visibly a sky.
|
||||
*
|
||||
* SF's twenty-nine roads at 55 m spacing come to 12,038 lamps in one draw call,
|
||||
* comfortably under the 24,000 ceiling. The ceiling exists for the city pack
|
||||
* that arrives with a full street network in it rather than twenty-nine
|
||||
* arterials, where the same spacing would produce a point cloud in the millions.
|
||||
*/
|
||||
Reference in New Issue
Block a user