diff --git a/content/posts/city-of-agents/components/activity-chart.tsx b/content/posts/city-of-agents/components/activity-chart.tsx new file mode 100644 index 0000000..6f1e247 --- /dev/null +++ b/content/posts/city-of-agents/components/activity-chart.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { ACTIVITIES } from "./sim"; +import { FILL } from "./use-city"; + +/** Columns per day: one every ten minutes. */ +export const SLOTS = 144; +const H = 60; +/** Walking and doing nothing are drawn lighter, so the four activities carry the picture. */ +const WEIGHT: Record<(typeof ACTIVITIES)[number], number> = { sleep: 1, work: 1, eat: 1, social: 1, walking: 0.55, idle: 0.25 }; + +export type ActivityLayer = { columns: readonly (ArrayLike | null)[]; opacity?: number }; + +/** + * A day as stacked columns, one every ten minutes: what share of people were doing what, sleep at the bottom. One path + * per activity and layer, not a rectangle per cell, so a live chart redrawn a few times a second stays cheap. Later + * layers are drawn over earlier ones; `now` marks a slot with a line. + */ +export function ActivityColumns({ layers, now, label, className = "h-28" }: { layers: ActivityLayer[]; now?: number; label: string; className?: string }) { + return ( + + {layers.map((layer, l) => ACTIVITIES.map((kind, k) => { + let d = ""; + layer.columns.forEach((column, x) => { + if (!column) return; + let y = H; + for (let j = 0; j < k; j++) y -= column[j] * H; + const h = column[k] * H; + if (h > 0.05) d += `M${x} ${(y - h).toFixed(2)}h1.02v${h.toFixed(2)}h-1.02z`; + }); + return d ? : null; + }))} + {now !== undefined && } + + ); +} + +export function HourTicks() { + return
0006121824
; +} diff --git a/content/posts/city-of-agents/components/city-lab.tsx b/content/posts/city-of-agents/components/city-lab.tsx index 8080c5e..600dd09 100644 --- a/content/posts/city-of-agents/components/city-lab.tsx +++ b/content/posts/city-of-agents/components/city-lab.tsx @@ -4,7 +4,10 @@ import { Pause, Play } from "lucide-react"; import { useRef, useState } from "react"; import { Readout } from "@/components/lab/readout"; import { Button } from "@/components/ui/button"; +import { ActivityColumns, HourTicks } from "./activity-chart"; +import { personTask } from "./describe"; import { personName, useLabels } from "./labels"; +import type { PanelState } from "./session"; import { formatTime, type Mode, PARAMS } from "./sim"; import { SWATCH, useCity } from "./use-city"; @@ -24,6 +27,37 @@ export function Legend() { ); } +/** + * Who the camera follows and what they are up to right now, in the Overseer's words. Only the name is announced: the + * task changes every few seconds at speed, and a screen reader would never stop talking. + */ +export function FollowBadge({ panel }: { panel: PanelState | null }) { + const t = useLabels(), id = panel?.follow ?? -1, p = panel && id >= 0 ? panel.people[id] : undefined; + if (!panel || !p) return null; + return ( +
+ {t.following} · {personName(t, id)} + + {personTask(t, panel.city, p)} +
+ ); +} + +/** + * The day so far under the picture: people go indoors, so the scene alone no longer shows how many are at work or + * asleep. Yesterday shows, faint, only where today has not got to yet. + */ +export function DayStrip({ panel }: { panel: PanelState | null }) { + const t = useLabels(); + if (!panel) return null; + return ( +
+ (panel.today[k] ? null : c)), opacity: 0.3 }, { columns: panel.today }]} now={(panel.t % 1440) / 10} label={t.today} className="h-12" /> + +
+ ); +} + /** * The city and the people in it, with as few controls as the point needs: play, speed, follow someone. With `modes`, * also who decides and whether duty exists — the same city and the same legs under a different head. @@ -41,8 +75,9 @@ export function CityLab({ seed = 1, n = 8, agents = 300, modes = false }: { seed act(() => s?.setFollow(s.pick(e.clientX, e.clientY)))} /> {!ready &&

{t.loading}

} - {follow >= 0 &&

{t.following} · {personName(t, follow)}

} + + {modes && (
diff --git a/content/posts/city-of-agents/components/city-view3d.ts b/content/posts/city-of-agents/components/city-view3d.ts index f4a3212..bba021e 100644 --- a/content/posts/city-of-agents/components/city-view3d.ts +++ b/content/posts/city-of-agents/components/city-view3d.ts @@ -5,29 +5,98 @@ type Three = typeof import("@/lib/three"); /** Scene colours. These are materials of a little world with its own sky, not UI: the page's tokens colour the people. */ const C = { - outskirts: 0x3d5a45, asphalt: 0x3a3d4a, pavement: 0xb9bcc6, stripe: 0xe8e8ee, river: 0x3f7fb5, - lot: { residential: 0x7fa06a, commercial: 0x9a9eab, food: 0xb3a590, park: 0x5f9e57, riverside: 0xd6c79a }, - house: [0xd9b38c, 0xe6c9a8, 0xc98f6b, 0xd4a27a], office: [0x8fa3b8, 0x7b8da6, 0xa3b3c4, 0x6f8199], shop: [0xe9e2d0, 0xdfd3bd], - awning: [0xff6e96, 0x79dafa, 0xb9a5ff, 0xffc46b], trunk: 0x6b4a32, crown: [0x4f8a4a, 0x5c9a52, 0x3f7a44], metal: 0x4a4e5c, bench: 0x8a6a45, - glass: 0x27324a, lit: [0xffd98a, 0xffe9b8, 0xffc46b, 0xcfe6ff], lampOff: 0x5a5e6c, lampOn: 0xffd9a0, - night: 0x070918, dusk: 0xf08a5d, day: 0x9ccff2, sunLow: 0xffb070, sunHigh: 0xfff4e0, moon: 0x8fa8ff, + plinth: 0x232836, verge: 0x4f6e52, asphalt: 0x3b3f4c, pavement: 0xc4c7cf, stripe: 0xeeeef2, laneLine: 0xd9d6cc, centreLine: 0xe8b85a, river: 0x2f6f9f, + lot: { residential: 0x86a570, commercial: 0xa3a7b2, food: 0xb8ab96, park: 0x62a05a, riverside: 0xd8c99e }, + path: 0xd9ceb0, + house: [0xecd8bd, 0xdcbd9d, 0xf2e5d3, 0xcfa98a, 0xe3cbb3], roof: [0xa65d48, 0x8c5344, 0x5d6470, 0x6e5247, 0xb8715a, 0x4f5866], + office: [0xa4b4c6, 0x8797ac, 0xbcc7d3, 0x71819a, 0xcad0d8], podium: 0x7f8796, shop: [0xeee7d6, 0xe2d6c0], + awning: [0xff6e96, 0x79dafa, 0xb9a5ff, 0xffc46b], trunk: 0x6b4a32, crown: [0x4f8a4a, 0x5f9d52, 0x3f7a44, 0x6aa65a, 0x467f3f], metal: 0x4a4e5c, bench: 0x8a6a45, + lit: [0xffd68a, 0xffe6b0, 0xffc466, 0xd6e8ff], lampOff: 0x5a5e6c, lampOn: 0xffd9a0, + sky: { dayZenith: 0x4f8fd6, dayHorizon: 0xd6e8f3, duskZenith: 0x3f4f8c, duskHorizon: 0xf29a6a, nightZenith: 0x03050d, nightHorizon: 0x18203d }, + glass: { nightLow: 0x0a0f1d, nightHigh: 0x172036, dayLow: 0x3a4d68 }, + sunLow: 0xffb070, sunHigh: 0xfff4e0, moon: 0x8fa8ff, hemiDay: 0xdce9ff, hemiNight: 0x5b6a9c, }; /** People are drawn about twice life size: at the distance the whole city is seen from, life size is two pixels. */ const PERSON = 2.2; +/** Top of a block's lot, which buildings stand on. */ +const LOT = 0.26; +/** Green belt between the city and the edge of the model. */ +const MARGIN = 14; +/** Houses up to this tall get a pitched roof; taller ones are flat-roofed flats. */ +const PITCHED = 10; +/** Offices from this tall stand on a podium; from `CROWN` up they end in a set-back crown. */ +const PODIUM = 28, CROWN = 45; export type PeopleColors = Record; -/** What the view needs to draw everyone: positions now and one tick ago, which way they face, what they are up to. */ -export type PeopleFrame = { count: number; x: Float64Array; y: Float64Array; px: Float64Array; py: Float64Array; heading: Float64Array; action: (Action | null)[]; walking: Uint8Array }; +/** + * What the view needs to draw everyone: positions now and one tick ago, which way they face, what they are up to, and + * `at`, the place where they are doing it (−1 unless they are doing something). + */ +export type PeopleFrame = { count: number; x: Float64Array; y: Float64Array; px: Float64Array; py: Float64Array; heading: Float64Array; action: (Action | null)[]; walking: Uint8Array; at: Int32Array }; const hash01 = (n: number) => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); }; const smooth = (x: number) => { const t = Math.min(1, Math.max(0, x)); return t * t * (3 - 2 * t); }; type Box = { x: number; y: number; z: number; sx: number; sy: number; sz: number; color: number }; +/** The sky: a gradient from the zenith to the horizon and on down to a darker ground, a sunset band, and a glow where the sun is. */ +const SKY_VERTEX = /* glsl */ ` +varying vec3 vDir; +void main() { + vDir = position; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +}`; +const SKY_FRAGMENT = /* glsl */ ` +uniform vec3 uZenith; uniform vec3 uHorizon; uniform vec3 uGround; uniform vec3 uSunDir; uniform vec3 uSunColor; uniform vec3 uDusk; uniform float uDuskAmount; +varying vec3 vDir; +void main() { + vec3 d = normalize(vDir); + vec3 c = d.z > 0.0 ? mix(uHorizon, uZenith, pow(smoothstep(0.0, 1.0, d.z), 0.55)) : mix(uHorizon, uGround, smoothstep(0.0, 0.9, -d.z)); + // Dawn and dusk colour a band along the horizon, strongest towards the sun. + float toward = max(dot(normalize(d.xy + 1e-5), normalize(uSunDir.xy + 1e-5)), 0.0); + c = mix(c, uDusk, uDuskAmount * exp(-abs(d.z) * 5.0) * (0.2 + 0.8 * toward * toward)); + float s = max(dot(d, uSunDir), 0.0); + c += uSunColor * (pow(s, 900.0) * 2.0 + pow(s, 12.0) * 0.18) * smoothstep(-0.05, 0.02, d.z); + gl_FragColor = vec4(c, 1.0); + #include + #include +}`; + +/** + * The figure's pose, per vertex: legs swing opposite each other and the arms opposite the legs, by how much the person + * is walking; while talking the right arm gestures and the head nods. `aAnim` is per person: stride phase, walking + * (0–1), talking (0 silent, 0.4 listening, 1 speaking), and a number that picks their skin, hair and trousers. + */ +const FIGURE_VERTEX = /* glsl */ ` +attribute float aPart; attribute vec3 aJoint; attribute vec4 aAnim; +uniform float uTime; +varying float vPart; varying float vLook; +vec3 rotY(vec3 v, float a) { float c = cos(a), s = sin(a); return vec3(c * v.x + s * v.z, v.y, -s * v.x + c * v.z); } +float figureAngle() { + float swing = sin(aAnim.x * 3.14159), walk = aAnim.y, talk = aAnim.z, look = aAnim.w, kind = aJoint.x, side = aJoint.y; + float speak = smoothstep(0.6, 1.0, talk), listen = step(0.2, talk); + if (kind == 1.0) return side * swing * 0.6 * walk; + if (kind == 2.0) return -side * swing * 0.5 * walk - (side > 0.0 ? speak * (1.0 + 0.35 * sin(uTime * 7.0 + look * 20.0)) : speak * 0.15 * sin(uTime * 4.0 + look * 9.0)); + if (kind == 3.0) return listen * (speak > 0.5 ? 0.06 : 0.14) * sin(uTime * (speak > 0.5 ? 3.0 : 5.0) + look * 13.0); + return 0.0; +}`; +/** Parts: 0 shirt (the activity's colour), 1 skin, 2 trousers, 3 hair, 4 shoes. Colours are linear. */ +const FIGURE_FRAGMENT = /* glsl */ ` +uniform float uGlow; +varying float vPart; varying float vLook; +vec3 figureColor(vec3 shirt) { + if (vPart < 0.5) return shirt; + if (vPart < 1.5) return mix(vec3(0.9, 0.64, 0.5), vec3(0.34, 0.18, 0.1), pow(fract(vLook * 7.31), 1.8)); + if (vPart < 2.5) return mix(vec3(0.02, 0.025, 0.05), vec3(0.2, 0.18, 0.15), fract(vLook * 5.13)); + if (vPart < 3.5) { float h = fract(vLook * 3.77); return h < 0.45 ? vec3(0.01, 0.008, 0.007) : h < 0.75 ? vec3(0.09, 0.04, 0.02) : h < 0.9 ? vec3(0.3, 0.16, 0.06) : vec3(0.45, 0.43, 0.4); } + return vec3(0.02); +}`; + /** - * The city as a handful of instanced meshes: every box (ground, blocks, zebra stripes, buildings, awnings, posts, benches) - * is one draw call, tree crowns one, windows one, lamp heads and their pools of light one each, people two. Light, sky, fog, lamps - * and windows are functions of the simulated hour; the view keeps no state of its own about the time of day. + * The city as a handful of instanced meshes: every box (plinth, ground, blocks, road markings, buildings, awnings, posts, + * benches) is one draw call; roofs, tree crowns, windows, lamp heads, pools of lamplight and the soft shadows on the + * ground one each; people three. Light, sky, fog, lamps and windows are functions of the simulated hour; the view keeps no + * state of its own about the time of day. */ export class CityView { readonly renderer: THREE.WebGLRenderer; @@ -44,22 +113,42 @@ export class CityView { private readonly hemi: THREE.HemisphereLight; private readonly fog: THREE.FogExp2; private readonly sky: THREE.Color; + private readonly dome: THREE.Mesh; + private readonly skyUniforms: { uZenith: { value: THREE.Color }; uHorizon: { value: THREE.Color }; uGround: { value: THREE.Color }; uSunDir: { value: THREE.Vector3 }; uSunColor: { value: THREE.Color }; uDusk: { value: THREE.Color }; uDuskAmount: { value: number } }; + private readonly glassUniforms: { uGlassLow: { value: THREE.Color }; uGlassHigh: { value: THREE.Color } }; private readonly lampHeads: THREE.MeshBasicMaterial; private readonly lampPools: THREE.MeshBasicMaterial; private readonly windows: THREE.InstancedMesh | null = null; private boxMesh: THREE.InstancedMesh | null = null; - /** For the cutaway: each building's box (and awning) among the boxes, its windows among the panes, and how far it is lowered (1 = standing). */ - private readonly boxOf: number[] = []; - private readonly awningOf: number[] = []; + private roofMesh: THREE.InstancedMesh | null = null; + /** + * For the cutaway: each building's body among the boxes, the other boxes that belong to it (podium, crown, parapet, + * awning, plant on the roof), its roof, its windows among the panes, and how far it is lowered (1 = standing). + */ + private readonly bodyOf: number[] = []; + private readonly partsOf: number[][] = []; + private readonly roofOf: number[] = []; private readonly boxes: Box[] = []; + private readonly roofMatrices: THREE.Matrix4[] = []; private readonly panesOf: [number, number][] = []; private paneMatrices: THREE.Matrix4[] = []; private stand: Float32Array = new Float32Array(0); private readonly windowHash: Float32Array = new Float32Array(0); private readonly windowLit: Uint8Array = new Uint8Array(0); private windowStamp = -1; - private people: { bodies: THREE.InstancedMesh; heads: THREE.InstancedMesh; colors: Record; stride: Float32Array; shown: (string | null)[] } | null = null; - private readonly tmp: { a: THREE.Color; b: THREE.Color }; + private people: { + bodies: THREE.InstancedMesh; blobs: THREE.InstancedMesh; bubbles: THREE.InstancedMesh; anim: THREE.InstancedBufferAttribute; colors: Record; shown: (string | null)[]; + stride: Float32Array; walk: Float32Array; inside: Float32Array; door: Float32Array; heading: Float32Array; turned: Uint8Array; + talk: Float32Array; speak: Float32Array; partner: Int32Array; ox: Float32Array; oy: Float32Array; lastX: Float32Array; lastY: Float32Array; paired: number; + } | null = null; + private lastPeople = 0; + /** How far the camera stood from what it looked at, last frame. */ + private viewDistance = 0; + /** Where each place's door is, for the places that are buildings; parks and the riverside have none. */ + private readonly doorAt: ([number, number] | undefined)[] = []; + private readonly figureUniforms: { uTime: { value: number }; uGlow: { value: number } }; + private readonly softShadow: THREE.CanvasTexture; + private readonly tmp: { a: THREE.Color; b: THREE.Color; c: THREE.Color }; private readonly lightDir: THREE.Vector3; private readonly up: THREE.Vector3; private readonly origin: THREE.Vector3; @@ -69,33 +158,41 @@ export class CityView { private eased = false; private readonly personMatrix: THREE.Matrix4; private readonly personTurn: THREE.Quaternion; - private readonly personLean: THREE.Quaternion; private readonly personScale: THREE.Vector3; private readonly personAt: THREE.Vector3; + private readonly blobTurn: THREE.Quaternion; + private readonly blobScale: THREE.Vector3; + private readonly bubbleScale: THREE.Vector3; + private readonly leanAxis: THREE.Vector3; + private readonly leanNow: THREE.Quaternion; private readonly disposables: { dispose(): void }[] = []; constructor(private readonly T: Three, readonly canvas: HTMLCanvasElement, readonly city: City) { this.renderer = new T.WebGLRenderer({ canvas, antialias: true }); this.renderer.setPixelRatio(Math.min(2, window.devicePixelRatio)); + // Khronos' neutral tone mapping rolls off the highlights and leaves the palette's own colours alone. + this.renderer.toneMapping = T.NeutralToneMapping; this.renderer.shadowMap.enabled = true; // three r186 folded PCFSoft into PCFShadowMap (asking for PCFSoft only logs a warning): this is the soft one. this.renderer.shadowMap.type = T.PCFShadowMap; this.scene = new T.Scene(); - this.sky = new T.Color(C.night); + this.sky = new T.Color(C.sky.nightHorizon); this.scene.background = this.sky; - this.fog = new T.FogExp2(C.night, 0.28 / city.size); + this.fog = new T.FogExp2(C.sky.nightHorizon, 0.24 / city.size); this.scene.fog = this.fog; this.camera = new T.PerspectiveCamera(32, 2, 1, city.size * 6); this.camera.up.set(0, 0, 1); - this.tmp = { a: new T.Color(), b: new T.Color() }; + this.tmp = { a: new T.Color(), b: new T.Color(), c: new T.Color() }; this.lightDir = new T.Vector3(0, 0, 1); this.up = new T.Vector3(0, 0, 1); this.origin = new T.Vector3(); this.followAt = new T.Vector3(); this.lookAt = new T.Vector3(); this.cameraAt = new T.Vector3(); this.personMatrix = new T.Matrix4(); this.personTurn = new T.Quaternion(); this.personAt = new T.Vector3(); - this.personLean = new T.Quaternion().setFromAxisAngle(new T.Vector3(0, 1, 0), 0.16); this.personScale = new T.Vector3(PERSON, PERSON, PERSON); + this.blobTurn = new T.Quaternion(); this.blobScale = new T.Vector3(1.5 * PERSON, 1.5 * PERSON, 1); this.bubbleScale = new T.Vector3(); + this.leanAxis = new T.Vector3(0, 1, 0); this.leanNow = new T.Quaternion(); + this.figureUniforms = { uTime: { value: 0 }, uGlow: { value: 0.2 } }; - this.hemi = new T.HemisphereLight(0xcfe3ff, 0x4a4a3a, 1); + this.hemi = new T.HemisphereLight(C.hemiDay, 0x3f3f34, 1); this.hemi.position.set(0, 0, 1); this.sun = new T.DirectionalLight(0xffffff, 2); this.sun.castShadow = true; @@ -104,17 +201,32 @@ export class CityView { this.sun.shadow.normalBias = 0.6; this.scene.add(this.hemi, this.sun, this.sun.target); + // The sky is a dome that travels with the camera, drawn first and behind everything. + this.skyUniforms = { uZenith: { value: new T.Color() }, uHorizon: { value: new T.Color() }, uGround: { value: new T.Color() }, uSunDir: { value: new T.Vector3(0, 0, 1) }, uSunColor: { value: new T.Color() }, uDusk: { value: new T.Color(C.sky.duskHorizon) }, uDuskAmount: { value: 0 } }; + const domeGeometry = new T.SphereGeometry(city.size * 2.5, 32, 16), domeMaterial = new T.ShaderMaterial({ uniforms: this.skyUniforms, vertexShader: SKY_VERTEX, fragmentShader: SKY_FRAGMENT, side: T.BackSide, depthWrite: false, fog: false }); + this.dome = new T.Mesh(domeGeometry, domeMaterial); + this.dome.renderOrder = -1; this.dome.frustumCulled = false; + this.scene.add(this.dome); + this.disposables.push(domeGeometry, domeMaterial); + // City coordinates run 0…size; the scene is centred on the origin. this.root = new T.Group(); this.root.position.set(-city.size / 2, -city.size / 2, 0); this.scene.add(this.root); - const boxes: Box[] = [], crowns: [number, number, number][] = [], lamps: [number, number][] = []; - this.collect(boxes, crowns, lamps); + this.softShadow = this.makeSoftShadow(); + this.disposables.push(this.softShadow); + this.glassUniforms = { uGlassLow: { value: new T.Color(C.glass.nightLow) }, uGlassHigh: { value: new T.Color(C.glass.nightHigh) } }; + + const boxes: Box[] = [], roofs: THREE.Matrix4[] = [], roofColors: number[] = [], crowns: [number, number, number, number][] = [], lamps: [number, number][] = [], shadows: THREE.Matrix4[] = []; + this.collect(boxes, roofs, roofColors, crowns, lamps, shadows); this.addBoxes(boxes); + this.addRoofs(roofs, roofColors); this.addCrowns(crowns); - this.lampHeads = new T.MeshBasicMaterial({ color: C.lampOff }); - this.lampPools = new T.MeshBasicMaterial({ color: C.lampOn, transparent: true, opacity: 0, depthWrite: false, blending: T.AdditiveBlending, fog: false }); + this.addShadows(shadows); + this.addRiver(); + this.lampHeads = new T.MeshBasicMaterial({ color: C.lampOff, toneMapped: false }); + this.lampPools = new T.MeshBasicMaterial({ color: C.lampOn, map: this.makeGlow(), transparent: true, opacity: 0, depthWrite: false, blending: T.AdditiveBlending, fog: false, toneMapped: false }); this.addLamps(lamps); const built = this.addWindows(); if (built) { this.windows = built.mesh; this.windowHash = built.hash; this.windowLit = new Uint8Array(built.hash.length); } @@ -123,14 +235,27 @@ export class CityView { // ── building the scene ──────────────────────────────────────────────────── - private collect(boxes: Box[], crowns: [number, number, number][], lamps: [number, number][]): void { - const { city } = this, P = PARAMS.pavementWidth, slab = (r: Rect, top: number, thick: number, color: number) => boxes.push({ x: r.x, y: r.y, z: top - thick / 2, sx: r.w, sy: r.d, sz: thick, color }); - slab({ x: city.size / 2, y: city.size / 2, w: city.size * 5, d: city.size * 5 }, -0.4, 1, C.outskirts); - slab({ x: city.size / 2, y: city.size / 2, w: city.size, d: city.size }, 0, 1, C.asphalt); - slab({ ...city.river, d: city.size * 5 }, -0.2, 1, C.river); + private collect(boxes: Box[], roofs: THREE.Matrix4[], roofColors: number[], crowns: [number, number, number, number][], lamps: [number, number][], shadows: THREE.Matrix4[]): void { + const T = this.T, { city } = this, P = PARAMS.pavementWidth, S = city.size, river = city.river; + const slab = (r: Rect, top: number, thick: number, color: number) => boxes.push({ x: r.x, y: r.y, z: top - thick / 2, sx: r.w, sy: r.d, sz: thick, color }) - 1; + const shadow = (x: number, y: number, w: number, d: number, z = LOT + 0.02) => shadows.push(new T.Matrix4().makeScale(w, d, 1).setPosition(x, y, z)); + + // The model: a dark plinth, a green belt round the city, the river cut into it along the east side. + const west = -MARGIN, east = river.x + river.w / 2 + MARGIN, south = -MARGIN, north = S + MARGIN, mid = (south + north) / 2, span = north - south; + slab({ x: (west + east) / 2, y: mid, w: east - west, d: span }, -1, 7, C.plinth); + slab({ x: west / 2, y: mid, w: -west, d: span }, -0.1, 0.9, C.verge); + slab({ x: (river.x + river.w / 2 + east) / 2, y: mid, w: MARGIN, d: span }, -0.1, 0.9, C.verge); + slab({ x: S / 2, y: -MARGIN / 2, w: S, d: MARGIN }, -0.1, 0.9, C.verge); + slab({ x: S / 2, y: S + MARGIN / 2, w: S, d: MARGIN }, -0.1, 0.9, C.verge); + slab({ x: river.x, y: mid, w: river.w, d: span }, -0.62, 0.4, this.shade(C.river, 0.55)); + slab({ x: S / 2, y: S / 2, w: S, d: S }, 0, 1, C.asphalt); + for (const b of city.blocks) { slab(b.rect, 0.2, 0.6, C.pavement); - slab({ ...b.rect, w: b.rect.w - 2 * P, d: b.rect.d - 2 * P }, 0.26, 0.6, C.lot[b.zone]); + const inner = { ...b.rect, w: b.rect.w - 2 * P, d: b.rect.d - 2 * P }; + slab(inner, LOT, 0.6, C.lot[b.zone]); + // Parks get two paths crossing in the middle. + if (b.zone === "park") { slab({ ...inner, w: 2.2 }, LOT + 0.02, 0.6, C.path); slab({ ...inner, d: 2.2 }, LOT + 0.02, 0.6, C.path); } } for (const r of city.crosswalks) { const alongX = r.w > r.d, span = alongX ? r.w : r.d, count = Math.floor((span - P) / 1.3); @@ -139,32 +264,119 @@ export class CityView { boxes.push({ x: r.x + (alongX ? at : 0), y: r.y + (alongX ? 0 : at), z: 0.02, sx: alongX ? 0.65 : r.w * 0.85, sy: alongX ? r.d * 0.85 : 0.65, sz: 0.04, color: C.stripe }); } } + this.markings(boxes); + for (const b of city.buildings) { - const palette = C[b.kind], { rect } = b; - this.boxOf[b.id] = boxes.length; this.awningOf[b.id] = -1; - boxes.push({ x: rect.x, y: rect.y, z: 0.26 + b.height / 2, sx: rect.w, sy: rect.d, sz: b.height, color: palette[b.id % palette.length] }); - if (b.kind === "shop") { - this.awningOf[b.id] = boxes.length; - const [dx, dy] = this.facing(b); - boxes.push({ x: rect.x + dx * (rect.w / 2 + 0.6), y: rect.y + dy * (rect.d / 2 + 0.6), z: 2.7, sx: dx ? 1.4 : rect.w * 0.9, sy: dy ? 1.4 : rect.d * 0.9, sz: 0.18, color: C.awning[b.id % C.awning.length] }); + const { rect } = b, id = b.id, parts: number[] = [], color = C[b.kind][id % C[b.kind].length]; + this.roofOf[id] = -1; this.partsOf[id] = parts; + const part = (box: Box) => parts.push(boxes.push(box) - 1); + const cap = (r: Rect, top: number, c: number) => part({ x: r.x, y: r.y, z: top + 0.3, sx: r.w + 0.4, sy: r.d + 0.4, sz: 0.6, color: this.shade(c, 0.78) }); + if (b.kind === "office" && b.height >= PODIUM) { + // A podium two floors high over the whole lot, the tower set back on it, and on the tallest a crown set back again. + const tower = { ...rect, w: rect.w * 0.82, d: rect.d * 0.82 }, podium = 7; + part({ x: rect.x, y: rect.y, z: LOT + podium / 2, sx: rect.w + 0.6, sy: rect.d + 0.6, sz: podium, color: C.podium }); + this.bodyOf[id] = boxes.push({ x: rect.x, y: rect.y, z: LOT + b.height / 2, sx: tower.w, sy: tower.d, sz: b.height, color }) - 1; + const top = LOT + b.height; + if (b.height >= CROWN) { + const crown = 3 + hash01(id) * 3; + part({ x: rect.x, y: rect.y, z: top + crown / 2, sx: tower.w * 0.62, sy: tower.d * 0.62, sz: crown, color: this.shade(color, 0.9) }); + cap({ ...tower, w: tower.w * 0.62, d: tower.d * 0.62 }, top + crown, color); + } else cap(tower, top, color); + this.plant(part, tower, top, id); + shadow(rect.x, rect.y, rect.w + 7, rect.d + 7); + } else { + this.bodyOf[id] = boxes.push({ x: rect.x, y: rect.y, z: LOT + b.height / 2, sx: rect.w, sy: rect.d, sz: b.height, color }) - 1; + const top = LOT + b.height; + if (b.kind === "house" && b.height < PITCHED) { + // A gable roof, ridge one way or the other, eaves over the walls. + const turn = hash01(id * 3.1) < 0.5, pitch = Math.min(rect.w, rect.d) * 0.42, m = new T.Matrix4().makeScale(rect.w + 0.8, rect.d + 0.8, pitch); + if (turn) m.premultiply(new T.Matrix4().makeRotationZ(Math.PI / 2)); + m.setPosition(rect.x, rect.y, top); + this.roofOf[id] = roofs.push(m) - 1; roofColors.push(C.roof[Math.floor(hash01(id * 7.7) * C.roof.length)]); + } else { + cap(rect, top, color); + if (b.kind !== "shop") this.plant(part, rect, top, id); + } + if (b.kind === "shop") { + const [dx, dy] = this.facing(b); + part({ x: rect.x + dx * (rect.w / 2 + 0.6), y: rect.y + dy * (rect.d / 2 + 0.6), z: 2.7, sx: dx ? 1.4 : rect.w * 0.9, sy: dy ? 1.4 : rect.d * 0.9, sz: 0.18, color: C.awning[id % C.awning.length] }); + } + shadow(rect.x, rect.y, rect.w + (b.height > 20 ? 6 : 3.5), rect.d + (b.height > 20 ? 6 : 3.5)); } } + // A door on every building, on the side that faces its spot on the pavement: where people go in and come out. + for (const b of city.buildings) { + const { rect } = b, [dx, dy] = this.facing(b), podium = b.kind === "office" && b.height >= PODIUM ? 0.3 : 0, out = (dx ? rect.w : rect.d) / 2 + podium; + const wide = b.kind === "shop" ? 2.4 : b.kind === "office" ? 2.2 : 1.3, high = b.kind === "house" ? 2.2 : 2.8; + this.partsOf[b.id].push(boxes.push({ x: rect.x + dx * (out + 0.06), y: rect.y + dy * (out + 0.06), z: LOT + high / 2, sx: dx ? 0.14 : wide, sy: dy ? 0.14 : wide, sz: high, color: b.kind === "house" ? 0x5a3d2b : 0x1d2433 }) - 1); + this.doorAt[b.place] = [rect.x + dx * (out + 0.3), rect.y + dy * (out + 0.3)]; + } city.props.forEach((p, i) => { if (p.kind === "lamp") { boxes.push({ x: p.x, y: p.y, z: 2.4, sx: 0.16, sy: 0.16, sz: 4.4, color: C.metal }); lamps.push([p.x, p.y]); } else if (p.kind === "bench") boxes.push({ x: p.x, y: p.y, z: 0.5, sx: 1.7, sy: 0.55, sz: 0.45, color: C.bench }); else if (p.kind === "bin") boxes.push({ x: p.x, y: p.y, z: 0.65, sx: 0.5, sy: 0.5, sz: 0.85, color: C.metal }); - else { boxes.push({ x: p.x, y: p.y, z: 1.1, sx: 0.3, sy: 0.3, sz: 1.8, color: C.trunk }); crowns.push([p.x, p.y, 1.2 + hash01(i) * 0.7]); } + else { + const r = 1.2 + hash01(i) * 0.7; + boxes.push({ x: p.x, y: p.y, z: 1.1, sx: 0.3, sy: 0.3, sz: 1.8, color: C.trunk }); + crowns.push([p.x, p.y, 2 + r, r]); + // Every other tree carries a smaller second crown, off to one side and higher up. + if (hash01(i * 5.3) < 0.5) { const a = hash01(i * 9.1) * Math.PI * 2; crowns.push([p.x + Math.cos(a) * r * 0.5, p.y + Math.sin(a) * r * 0.5, 2 + r * 1.9, r * 0.6]); } + shadow(p.x, p.y, r * 3.2, r * 3.2, 0.3); + } }); } + /** Lane markings between the junctions: a double centre line on the avenues, a dashed one on the streets. */ + private markings(boxes: Box[]): void { + const { city } = this, P = PARAMS.pavementWidth, B = PARAMS.blockSize, stretches = city.blocks.filter((b) => b.i === 0).map((b) => [b.rect.y - B / 2 + P + 0.5, b.rect.y + B / 2 - P - 0.5]); + const line = (vertical: boolean, c: number, from: number, to: number, offset: number, color: number) => { + const along = (from + to) / 2, length = to - from; + boxes.push({ x: vertical ? c + offset : along, y: vertical ? along : c + offset, z: 0.015, sx: vertical ? 0.22 : length, sy: vertical ? length : 0.22, sz: 0.03, color }); + }; + for (const road of city.roads) { + const c = road.vertical ? road.rect.x : road.rect.y; + for (const [from, to] of stretches) { + if (road.arterial) { line(road.vertical, c, from, to, -0.22, C.centreLine); line(road.vertical, c, from, to, 0.22, C.centreLine); } + else for (let s = from; s + 2.4 <= to; s += 5) line(road.vertical, c, s, s + 2.4, 0, C.laneLine); + } + } + } + + /** Plant on a flat roof: a box or two of machinery, never over the edge. */ + private plant(part: (box: Box) => number, r: Rect, top: number, id: number): void { + const n = 1 + Math.floor(hash01(id * 2.3) * 2); + for (let k = 0; k < n; k++) { + const w = r.w * (0.18 + hash01(id + k * 4.1) * 0.14), d = r.d * (0.18 + hash01(id + k * 6.7) * 0.14), h = 1.2 + hash01(id + k * 8.9) * 1.8; + const x = r.x + (hash01(id + k * 3.3) - 0.5) * (r.w - w - 1.5), y = r.y + (hash01(id + k * 5.9) - 0.5) * (r.d - d - 1.5); + part({ x, y, z: top + 0.6 + h / 2, sx: w, sy: d, sz: h, color: 0x8b909c }); + } + } + + private shade(hex: number, k: number): number { return this.tmp.c.setHex(hex).multiplyScalar(k).getHex(); } + /** Which way a building's door faces: towards its place on the pavement. */ private facing(b: Building): [number, number] { const [px, py] = this.city.places[b.place].centre, dx = px - b.rect.x, dy = py - b.rect.y; return Math.abs(dx) > Math.abs(dy) ? [Math.sign(dx), 0] : [0, Math.sign(dy)]; } + /** + * Every box in one draw call. Walls darken towards the ground over the first few metres, the way light is blocked + * where a wall meets the street: ambient occlusion by height, worked out in the shader instead of by a pass. + */ private addBoxes(boxes: Box[]): void { - const T = this.T, geometry = new T.BoxGeometry(1, 1, 1), material = new T.MeshStandardMaterial({ roughness: 0.92, metalness: 0 }); + const T = this.T, geometry = new T.BoxGeometry(1, 1, 1), material = new T.MeshStandardMaterial({ roughness: 0.86, metalness: 0 }); + material.onBeforeCompile = (shader) => { + shader.vertexShader = shader.vertexShader.replace("#include ", "#include \nvarying float vAoZ;\nvarying float vAoSide;").replace("#include ", [ + "#include ", + "vec4 aoAt = vec4(transformed, 1.0);", + "#ifdef USE_INSTANCING\naoAt = instanceMatrix * aoAt;\n#endif", + "vAoZ = (modelMatrix * aoAt).z;", + "vAoSide = 1.0 - abs(objectNormal.z);", + ].join("\n")); + shader.fragmentShader = shader.fragmentShader.replace("#include ", "#include \nvarying float vAoZ;\nvarying float vAoSide;").replace("#include ", + "#include \ndiffuseColor.rgb *= mix(1.0, mix(0.5, 1.0, smoothstep(0.2, 4.0, vAoZ)), vAoSide);"); + }; const mesh = new T.InstancedMesh(geometry, material, boxes.length), m = new T.Matrix4(), color = new T.Color(); boxes.forEach((b, i) => { m.makeScale(b.sx, b.sy, b.sz).setPosition(b.x, b.y, b.z); mesh.setMatrixAt(i, m); mesh.setColorAt(i, color.setHex(b.color)); }); mesh.castShadow = true; mesh.receiveShadow = true; mesh.frustumCulled = false; @@ -173,36 +385,78 @@ export class CityView { this.boxMesh = mesh; this.boxes.push(...boxes); this.stand = new Float32Array(this.city.buildings.length).fill(1); } - private addCrowns(crowns: [number, number, number][]): void { + /** A unit gable roof: base 1 × 1 at z = 0, ridge along x at z = 1. */ + private addRoofs(roofs: THREE.Matrix4[], colors: number[]): void { + if (!roofs.length) return; + const T = this.T, a = [-0.5, -0.5, 0], b = [0.5, -0.5, 0], c = [0.5, 0.5, 0], d = [-0.5, 0.5, 0], e = [-0.5, 0, 1], f = [0.5, 0, 1]; + const faces = [a, b, f, a, f, e, c, d, e, c, e, f, d, a, e, b, c, f]; + const geometry = new T.BufferGeometry(); + geometry.setAttribute("position", new T.BufferAttribute(new Float32Array(faces.flat()), 3)); + geometry.computeVertexNormals(); + const material = new T.MeshStandardMaterial({ roughness: 0.8 }), mesh = new T.InstancedMesh(geometry, material, roofs.length), color = new T.Color(); + roofs.forEach((m, i) => { mesh.setMatrixAt(i, m); mesh.setColorAt(i, color.setHex(colors[i])); }); + mesh.castShadow = true; mesh.receiveShadow = true; mesh.frustumCulled = false; + this.root.add(mesh); + this.disposables.push(geometry, material, mesh); + this.roofMesh = mesh; this.roofMatrices.push(...roofs); + } + + private addCrowns(crowns: [number, number, number, number][]): void { if (!crowns.length) return; - const T = this.T, geometry = new T.IcosahedronGeometry(1, 0), material = new T.MeshStandardMaterial({ roughness: 1, flatShading: true }); + const T = this.T, geometry = new T.IcosahedronGeometry(1, 1), material = new T.MeshStandardMaterial({ roughness: 0.95, flatShading: true }); const mesh = new T.InstancedMesh(geometry, material, crowns.length), m = new T.Matrix4(), color = new T.Color(); - crowns.forEach(([x, y, r], i) => { m.makeScale(r, r, r * 1.25).setPosition(x, y, 2 + r); mesh.setMatrixAt(i, m); mesh.setColorAt(i, color.setHex(C.crown[i % C.crown.length])); }); - mesh.castShadow = true; mesh.frustumCulled = false; + crowns.forEach(([x, y, z, r], i) => { m.makeScale(r, r, r * 1.15).setPosition(x, y, z); mesh.setMatrixAt(i, m); mesh.setColorAt(i, color.setHex(C.crown[Math.floor(hash01(i * 1.7) * C.crown.length)])); }); + mesh.castShadow = true; mesh.receiveShadow = true; mesh.frustumCulled = false; + this.root.add(mesh); + this.disposables.push(geometry, material, mesh); + } + + /** A soft dark patch under every building and tree, which grounds them at night when the sun casts nothing. */ + private addShadows(shadows: THREE.Matrix4[]): void { + const T = this.T, geometry = new T.PlaneGeometry(1, 1), material = new T.MeshBasicMaterial({ color: 0x000000, map: this.softShadow, transparent: true, opacity: 0.42, depthWrite: false }); + const mesh = new T.InstancedMesh(geometry, material, shadows.length); + shadows.forEach((m, i) => mesh.setMatrixAt(i, m)); + mesh.frustumCulled = false; this.root.add(mesh); this.disposables.push(geometry, material, mesh); } + /** Water on its own, smooth enough to catch the sun. */ + private addRiver(): void { + const T = this.T, r = this.city.river, geometry = new T.PlaneGeometry(r.w, this.city.size + 2 * MARGIN), material = new T.MeshStandardMaterial({ color: C.river, roughness: 0.18, metalness: 0.1 }); + const mesh = new T.Mesh(geometry, material); + mesh.position.set(r.x, this.city.size / 2, -0.6); + mesh.receiveShadow = true; + this.root.add(mesh); + this.disposables.push(geometry, material); + } + private addLamps(lamps: [number, number][]): void { - const T = this.T, m = new T.Matrix4(), head = new T.BoxGeometry(0.5, 0.5, 0.3), pool = new T.CircleGeometry(3, 20); + const T = this.T, m = new T.Matrix4(), head = new T.BoxGeometry(0.55, 0.55, 0.25), pool = new T.PlaneGeometry(11, 11); const heads = new T.InstancedMesh(head, this.lampHeads, lamps.length), pools = new T.InstancedMesh(pool, this.lampPools, lamps.length); - lamps.forEach(([x, y], i) => { heads.setMatrixAt(i, m.makeTranslation(x, y, 4.7)); pools.setMatrixAt(i, m.makeTranslation(x, y, 0.3)); }); + lamps.forEach(([x, y], i) => { heads.setMatrixAt(i, m.makeTranslation(x, y, 4.7)); pools.setMatrixAt(i, m.makeTranslation(x, y, 0.32)); }); heads.frustumCulled = false; pools.frustumCulled = false; this.root.add(heads, pools); - this.disposables.push(head, pool, heads, pools, this.lampHeads, this.lampPools); + this.disposables.push(head, pool, heads, pools, this.lampHeads, this.lampPools, this.lampPools.map as THREE.Texture); } - /** One quad per window, up to six across and twenty-four floors up on each face. */ + /** + * One quad per window. Homes and shops have punched windows; offices have ribbons of glass, floor after floor. + * An unlit pane shows the sky it faces (a gradient that follows the hour); a lit one shows its own light, untouched + * by tone mapping so it glows. + */ private addWindows(): { mesh: THREE.InstancedMesh; hash: Float32Array } | null { const T = this.T, panes: { m: THREE.Matrix4; hash: number }[] = [], right = new T.Vector3(), up = new T.Vector3(0, 0, 1), normal = new T.Vector3(); for (const b of this.city.buildings) { - const first = panes.length; - const { rect } = b, shop = b.kind === "shop", floors = shop ? 1 : Math.min(24, Math.floor((b.height - 1) / 3.2)), bHash = hash01(b.id + 0.5); + const first = panes.length, shop = b.kind === "shop", office = b.kind === "office", tower = office && b.height >= PODIUM; + const rect = tower ? { ...b.rect, w: b.rect.w * 0.82, d: b.rect.d * 0.82 } : b.rect; + const floors = shop ? 1 : Math.min(24, Math.floor((b.height - 1) / 3.2)), bHash = hash01(b.id + 0.5); for (const [nx, ny] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const width = nx ? rect.d : rect.w, cols = shop ? 1 : Math.min(6, Math.max(1, Math.floor(width / 2.6))), cell = width / cols; normal.set(nx, ny, 0); right.set(-ny, nx, 0); - for (let f = 0; f < floors; f++) for (let c = 0; c < cols; c++) { - const along = (c + 0.5) * cell - width / 2, w = shop ? width * 0.8 : cell * 0.55, h = shop ? 1.6 : 1.5, z = 0.26 + (shop ? 1.3 : 2 + f * 3.2); + for (let f = tower ? 2 : 0; f < floors; f++) for (let c = 0; c < cols; c++) { + const along = (c + 0.5) * cell - width / 2, w = shop ? width * 0.8 : office ? cell * 0.9 : cell * 0.5, h = shop ? 1.6 : office ? 2.2 : 1.4; + const z = LOT + (shop ? 1.3 : (office ? 1.8 : 2) + f * 3.2); const m = new T.Matrix4().makeBasis(right.clone().multiplyScalar(w), up.clone().multiplyScalar(h), normal); m.setPosition(rect.x + nx * (rect.w / 2 + 0.03) - ny * along, rect.y + ny * (rect.d / 2 + 0.03) + nx * along, z); // Most of a building's windows follow the building; each is a little early or late, and a quarter never come on. @@ -210,11 +464,31 @@ export class CityView { panes.push({ m, hash: own < 0.25 ? -1 : Math.min(0.999, bHash * 0.75 + own * 0.25) }); } } + if (tower) { + // The podium's lobby: one tall pane per bay, all the way round. + const r = b.rect; + for (const [nx, ny] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const width = (nx ? r.d : r.w) + 0.6, cols = Math.max(2, Math.floor(width / 3.4)), cell = width / cols; + normal.set(nx, ny, 0); right.set(-ny, nx, 0); + for (let c = 0; c < cols; c++) { + const along = (c + 0.5) * cell - width / 2, m = new T.Matrix4().makeBasis(right.clone().multiplyScalar(cell * 0.86), up.clone().multiplyScalar(4.4), normal); + m.setPosition(r.x + nx * (r.w / 2 + 0.33) - ny * along, r.y + ny * (r.d / 2 + 0.33) + nx * along, LOT + 2.7); + panes.push({ m, hash: Math.min(0.999, bHash * 0.5 + hash01(panes.length * 2.1) * 0.2) }); + } + } + } this.panesOf[b.id] = [first, panes.length]; } if (!panes.length) return null; - const geometry = new T.PlaneGeometry(1, 1), material = new T.MeshBasicMaterial(), mesh = new T.InstancedMesh(geometry, material, panes.length), glass = new T.Color(C.glass); - panes.forEach((p, i) => { mesh.setMatrixAt(i, p.m); mesh.setColorAt(i, glass); }); + const geometry = new T.PlaneGeometry(1, 1), material = new T.MeshBasicMaterial({ toneMapped: false }); + material.onBeforeCompile = (shader) => { + Object.assign(shader.uniforms, this.glassUniforms); + shader.vertexShader = shader.vertexShader.replace("#include ", "#include \nvarying float vPaneY;").replace("#include ", "#include \nvPaneY = position.y + 0.5;"); + shader.fragmentShader = shader.fragmentShader.replace("#include ", "#include \nvarying float vPaneY;\nuniform vec3 uGlassLow;\nuniform vec3 uGlassHigh;").replace("#include ", + "#include \nfloat paneLit = step(0.004, max(diffuseColor.r, max(diffuseColor.g, diffuseColor.b)));\ndiffuseColor.rgb = mix(mix(uGlassLow, uGlassHigh, vPaneY), diffuseColor.rgb * mix(0.85, 1.1, vPaneY), paneLit);"); + }; + const mesh = new T.InstancedMesh(geometry, material, panes.length), dark = new T.Color(0, 0, 0); + panes.forEach((p, i) => { mesh.setMatrixAt(i, p.m); mesh.setColorAt(i, dark); }); mesh.frustumCulled = false; this.root.add(mesh); this.disposables.push(geometry, material, mesh); @@ -222,46 +496,212 @@ export class CityView { return { mesh, hash: Float32Array.from(panes, (p) => p.hash) }; } + /** A square that fades out towards its edges, rounded at the corners: the soft shadow under things. */ + private makeSoftShadow(): THREE.CanvasTexture { + const size = 64, canvas = document.createElement("canvas"), ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + canvas.width = canvas.height = size; + const img = ctx.createImageData(size, size); + for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) { + const u = (x + 0.5) / size - 0.5, v = (y + 0.5) / size - 0.5, dx = Math.max(Math.abs(u) - 0.2, 0), dy = Math.max(Math.abs(v) - 0.2, 0); + const k = (y * size + x) * 4, a = (1 - smooth(Math.hypot(dx, dy) / 0.3)) ** 1.6; + img.data[k] = img.data[k + 1] = img.data[k + 2] = 255; img.data[k + 3] = Math.round(a * 255); + } + ctx.putImageData(img, 0, 0); + return new this.T.CanvasTexture(canvas); + } + + /** A round glow, bright in the middle: a street lamp's light on the pavement. */ + private makeGlow(): THREE.CanvasTexture { + const size = 64, canvas = document.createElement("canvas"), ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + canvas.width = canvas.height = size; + const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); + g.addColorStop(0, "rgba(255,255,255,0.8)"); g.addColorStop(0.45, "rgba(255,255,255,0.32)"); g.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = g; ctx.fillRect(0, 0, size, size); + const texture = new this.T.CanvasTexture(canvas); + texture.colorSpace = this.T.SRGBColorSpace; + return texture; + } + // ── people ──────────────────────────────────────────────────────────────── - /** Two instanced meshes for everyone: a body that takes the light and a head that glows a little, so people read at night. */ + /** + * Everyone is one instanced mesh of a whole figure (legs, shoes, hips, torso, arms, hands, neck, head, hair) whose limbs + * the vertex shader swings, plus a soft shadow at their feet and a speech bubble. The shirt wears the activity's colour + * and glows a little so it reads at night; skin, hair and trousers differ from person to person. + */ setPeople(count: number, colors: PeopleColors): void { - if (this.people) { this.root.remove(this.people.bodies, this.people.heads); this.people.bodies.dispose(); this.people.heads.dispose(); } - const T = this.T, body = new T.BoxGeometry(0.62, 0.42, 1), head = new T.SphereGeometry(0.3, 10, 8); - const bodies = new T.InstancedMesh(body, new T.MeshStandardMaterial({ roughness: 0.8 }), count), heads = new T.InstancedMesh(head, new T.MeshBasicMaterial(), count); - bodies.castShadow = true; bodies.frustumCulled = false; heads.frustumCulled = false; - const white = new T.Color(colors.idle); - for (let i = 0; i < count; i++) { bodies.setColorAt(i, white); heads.setColorAt(i, white); } - this.root.add(bodies, heads); - this.disposables.push(body, head, bodies.material as THREE.Material, heads.material as THREE.Material); - this.people = { bodies, heads, colors: Object.fromEntries(Object.entries(colors).map(([k, v]) => [k, new T.Color(v)])), stride: new Float32Array(count), shown: new Array(count).fill(null) }; + if (this.people) { + const { bodies, blobs, bubbles } = this.people; + this.root.remove(bodies, blobs, bubbles); + for (const mesh of [bodies, blobs, bubbles]) { mesh.geometry.dispose(); (mesh.material as THREE.Material).dispose(); mesh.dispose(); } + (blobs.material as THREE.MeshBasicMaterial).map?.dispose(); + } + const T = this.T, figure = this.makeFigure(), material = new T.MeshStandardMaterial({ roughness: 0.7 }); + const anim = new T.InstancedBufferAttribute(new Float32Array(count * 4), 4); + figure.setAttribute("aAnim", anim); + material.onBeforeCompile = (shader) => { + Object.assign(shader.uniforms, this.figureUniforms); + shader.vertexShader = shader.vertexShader.replace("#include ", `#include \n${FIGURE_VERTEX}`) + .replace("#include ", "#include \nfloat poseAngle = figureAngle();\nobjectNormal = rotY(objectNormal, poseAngle);\nvPart = aPart; vLook = aAnim.w;") + .replace("#include ", "#include \ntransformed = vec3(0.0, 0.0, aJoint.z) + rotY(transformed - vec3(0.0, 0.0, aJoint.z), poseAngle);"); + shader.fragmentShader = shader.fragmentShader.replace("#include ", `#include \n${FIGURE_FRAGMENT}`) + .replace("#include ", "#include \nvec3 shirt = diffuseColor.rgb;\ndiffuseColor.rgb = figureColor(shirt);") + .replace("#include ", "#include \ntotalEmissiveRadiance += vPart < 0.5 ? shirt * uGlow : vec3(0.0);"); + }; + const bodies = new T.InstancedMesh(figure, material, count); + const blobs = new T.InstancedMesh(new T.PlaneGeometry(1, 1), new T.MeshBasicMaterial({ map: this.makeGlow(), transparent: true, opacity: 0.75, depthWrite: false, toneMapped: false }), count); + const bubbles = new T.InstancedMesh(new T.SphereGeometry(0.3, 12, 8), new T.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true, opacity: 0.92 }), count); + bodies.castShadow = true; + for (const mesh of [bodies, blobs, bubbles]) mesh.frustumCulled = false; + const zero = new T.Matrix4().makeScale(0, 0, 0), white = new T.Color(colors.idle); + for (let i = 0; i < count; i++) { bodies.setColorAt(i, white); blobs.setColorAt(i, white); bubbles.setMatrixAt(i, zero); anim.setW(i, hash01(i * 3.7 + 0.1)); } + this.root.add(blobs, bodies, bubbles); + const heading = new Float32Array(count); + this.people = { + bodies, blobs, bubbles, anim, colors: Object.fromEntries(Object.entries(colors).map(([k, v]) => [k, new T.Color(v)])), shown: new Array(count).fill(null), + stride: new Float32Array(count), walk: new Float32Array(count), inside: new Float32Array(count), door: new Float32Array(count * 2), heading, turned: new Uint8Array(count), + talk: new Float32Array(count), speak: new Float32Array(count), partner: new Int32Array(count).fill(-1), ox: new Float32Array(count), oy: new Float32Array(count), lastX: new Float32Array(count), lastY: new Float32Array(count), paired: -Infinity, + }; } /** - * Places everyone between their position one tick ago (`px`, `py`) and now, `alpha` of the way. Walking is drawn, not - * simulated: the body bobs once per stride and leans into the walk. + * Places everyone between their position one tick ago (`px`, `py`) and now, `alpha` of the way. Everything here is + * drawn, not simulated: in the simulation a person at work, at a meal or asleep stands on their spot outside the door, + * and a person in the park stands on theirs. On screen the first walks in through the door (and back out when they + * leave), and the second turns to the nearest other person there, closes in and talks, the two taking turns. */ updatePeople(f: PeopleFrame, alpha: number): void { const people = this.people; if (!people) return; - const m = this.personMatrix, q = this.personTurn, lean = this.personLean, s = this.personScale, at = this.personAt; + const now = performance.now() / 1000, dt = this.lastPeople ? Math.min(0.1, now - this.lastPeople) : 0; + this.lastPeople = now; this.figureUniforms.uTime.value = now % 1000; + if (now - people.paired > 0.4) { this.pairUp(f); people.paired = now; } + // From afar a figure is a few pixels: people and their glow grow as the camera pulls back, up to half as big again. + const far = Math.min(1.5, Math.max(1, this.viewDistance / 120)), glow = 1.6 * Math.min(2.5, Math.max(1, this.viewDistance / 90)); + const m = this.personMatrix, q = this.personTurn, s = this.personScale, at = this.personAt, anim = people.anim, k = 1 - Math.exp(-dt * 3), turn = 1 - Math.exp(-dt * 8); let recolored = false; for (let i = 0; i < f.count; i++) { - const x = f.px[i] + (f.x[i] - f.px[i]) * alpha, y = f.py[i] + (f.y[i] - f.py[i]) * alpha, walking = f.walking[i] === 1; - if (walking) people.stride[i] += 0.16; + let x = f.px[i] + (f.x[i] - f.px[i]) * alpha, y = f.py[i] + (f.y[i] - f.py[i]) * alpha; + const place = f.at[i], door = place >= 0 ? this.doorAt[place] : undefined, social = place >= 0 && !door, partner = social ? people.partner[i] : -1; + if (door) { people.door[2 * i] = door[0]; people.door[2 * i + 1] = door[1]; } + // In through the door or out of it, about a second either way. + const inside = people.inside[i] = Math.min(1, Math.max(0, people.inside[i] + (door ? dt : -dt) * 1.1)), e = smooth(inside); + // A conversation: both step towards each other (or apart) until they stand a pace apart. + let tx = 0, ty = 0; + if (partner >= 0) { + const d = Math.hypot(f.x[partner] - f.x[i], f.y[partner] - f.y[i]), ux = d > 0.01 ? (f.x[partner] - f.x[i]) / d : i < partner ? 1 : -1, uy = d > 0.01 ? (f.y[partner] - f.y[i]) / d : 0, closer = (d - 2.8) / 2; + tx = ux * closer; ty = uy * closer; + } + people.ox[i] += (tx - people.ox[i]) * k; people.oy[i] += (ty - people.oy[i]) * k; + x += people.ox[i]; y += people.oy[i]; + if (e > 0) { x += (people.door[2 * i] - x) * e; y += (people.door[2 * i + 1] - y) * e; } + const moved = Math.hypot(x - people.lastX[i], y - people.lastY[i]); + people.lastX[i] = x; people.lastY[i] = y; if (i === this.follow) this.followAt.set(x - this.city.size / 2, y - this.city.size / 2, 0); - const bob = walking ? Math.abs(Math.sin(people.stride[i])) * 0.22 * PERSON : 0; - q.setFromAxisAngle(this.up, f.heading[i]); - if (walking) q.multiply(lean); - m.compose(at.set(x, y, 0.26 + 0.55 * PERSON + bob), q, s); + + // Which way they face: along the walk, at the door while going in, at the partner while talking. + let face = f.heading[i]; + if (door && inside < 1) face = Math.atan2(people.door[2 * i + 1] - y, people.door[2 * i] - x); + else if (partner >= 0) face = Math.atan2(f.y[partner] + people.oy[partner] - y, f.x[partner] + people.ox[partner] - x); + if (!people.turned[i]) { people.heading[i] = face; people.turned[i] = 1; } + const dh = Math.atan2(Math.sin(face - people.heading[i]), Math.cos(face - people.heading[i])); + people.heading[i] += dh * turn; + + // Legs follow the ground covered: one stride is about a body length. + const walking = f.walking[i] === 1 || (inside > 0 && inside < 1) || Math.hypot(people.ox[i] - tx, people.oy[i] - ty) > 0.15; + people.walk[i] += ((walking ? 1 : 0) - people.walk[i]) * turn; + people.stride[i] += Math.min(moved, 3) / (0.55 * PERSON); + // Partners take turns: one speaks (gestures, and a bubble pops up), the other listens (nods). + let speaking = 0; + if (partner >= 0) { const seed = Math.min(i, partner), lead = Math.sin(now * 0.7 + seed * 1.7) > 0; speaking = lead === i < partner ? 1 : 0; } + people.talk[i] += ((partner >= 0 ? (speaking ? 1 : 0.4) : 0) - people.talk[i]) * turn; + people.speak[i] += (speaking - people.speak[i]) * turn; + anim.setXYZ(i, people.stride[i], people.walk[i], people.talk[i]); + + const bob = Math.abs(Math.sin(people.stride[i] * Math.PI)) * 0.06 * PERSON * people.walk[i], size = PERSON * far * (1 - smooth((inside - 0.55) / 0.45)); + q.setFromAxisAngle(this.up, people.heading[i]); + if (people.walk[i] > 0.01) q.multiply(this.leanBy(people.walk[i])); + m.compose(at.set(x, y, LOT + bob), q, s.setScalar(size)); people.bodies.setMatrixAt(i, m); - m.compose(at.set(x + (walking ? Math.cos(f.heading[i]) * 0.12 * PERSON : 0), y + (walking ? Math.sin(f.heading[i]) * 0.12 * PERSON : 0), 0.26 + 1.38 * PERSON + bob), q, s); - people.heads.setMatrixAt(i, m); + people.blobs.setMatrixAt(i, m.compose(at.set(x, y, LOT + 0.06), this.blobTurn, this.blobScale.set(glow * size, glow * size, 1))); + const pop = people.speak[i] * (1 + 0.08 * Math.sin(now * 9 + i)); + people.bubbles.setMatrixAt(i, m.compose(at.set(x + Math.cos(people.heading[i] + 1.2) * 0.9, y + Math.sin(people.heading[i] + 1.2) * 0.9, LOT + 1.63 * size + 1.1), this.blobTurn, this.bubbleScale.set(1.25 * pop, 1.25 * pop, 0.9 * pop))); const key = f.action[i] ?? "idle"; - if (people.shown[i] !== key) { people.shown[i] = key; people.bodies.setColorAt(i, people.colors[key]); people.heads.setColorAt(i, people.colors[key]); recolored = true; } + if (people.shown[i] !== key) { people.shown[i] = key; people.bodies.setColorAt(i, people.colors[key]); people.blobs.setColorAt(i, people.colors[key]); recolored = true; } + } + anim.needsUpdate = true; + people.bodies.instanceMatrix.needsUpdate = true; people.blobs.instanceMatrix.needsUpdate = true; people.bubbles.instanceMatrix.needsUpdate = true; + if (recolored) for (const mesh of [people.bodies, people.blobs]) (mesh.instanceColor as THREE.InstancedBufferAttribute).needsUpdate = true; + } + + /** A forward lean that grows with the walk. */ + private leanBy(walk: number): THREE.Quaternion { return this.leanNow.setFromAxisAngle(this.leanAxis, 0.12 * walk); } + + /** + * People in the same park paired off two by two, nearest first; anyone left over stands alone. A pair holds until + * one of the two leaves, so nobody hops from conversation to conversation. + */ + private pairUp(f: PeopleFrame): void { + const people = this.people; + if (!people) return; + const byPlace = new Map(), outdoors = (i: number) => f.at[i] >= 0 && !this.doorAt[f.at[i]] && !f.walking[i]; + for (let i = 0; i < f.count; i++) { + const j = people.partner[i]; + if (j >= 0 && (j >= f.count || !outdoors(i) || !outdoors(j) || f.at[j] !== f.at[i] || people.partner[j] !== i)) people.partner[i] = -1; + } + for (let i = 0; i < f.count; i++) { + if (!outdoors(i) || people.partner[i] >= 0) continue; + const list = byPlace.get(f.at[i]); + if (list) list.push(i); else byPlace.set(f.at[i], [i]); + } + for (const list of byPlace.values()) { + for (const i of list) { + if (people.partner[i] >= 0) continue; + let best = -1, bestD = 14 * 14; + for (const j of list) { + if (j === i || people.partner[j] >= 0) continue; + const d = (f.x[j] - f.x[i]) ** 2 + (f.y[j] - f.y[i]) ** 2; + if (d < bestD) { bestD = d; best = j; } + } + if (best >= 0) { people.partner[i] = best; people.partner[best] = i; } + } } - people.bodies.instanceMatrix.needsUpdate = true; people.heads.instanceMatrix.needsUpdate = true; - if (recolored) { (people.bodies.instanceColor as THREE.InstancedBufferAttribute).needsUpdate = true; (people.heads.instanceColor as THREE.InstancedBufferAttribute).needsUpdate = true; } + } + + /** + * One figure, feet at z = 0, facing +x, 1.63 tall before scaling. Each vertex knows its part (for its colour) and its + * joint: what swings it (0 nothing, 1 leg, 2 arm, 3 head), which side it is on, and the height of the pivot. + */ + private makeFigure(): THREE.BufferGeometry { + const T = this.T, positions: number[] = [], normals: number[] = [], parts: number[] = [], joints: number[] = []; + const add = (g: THREE.BufferGeometry, x: number, y: number, z: number, part: number, joint: number, side: number, pivot: number, scale?: [number, number, number]) => { + const flat = g.index ? g.toNonIndexed() : g; + if (scale) flat.scale(...scale); + flat.translate(x, y, z); + const p = flat.getAttribute("position"), n = flat.getAttribute("normal"); + for (let v = 0; v < p.count; v++) { positions.push(p.getX(v), p.getY(v), p.getZ(v)); normals.push(n.getX(v), n.getY(v), n.getZ(v)); parts.push(part); joints.push(joint, side, pivot); } + flat.dispose(); if (flat !== g) g.dispose(); + }; + const upright = (g: G) => g.rotateX(Math.PI / 2); + for (const side of [-1, 1]) { + add(upright(new T.CapsuleGeometry(0.068, 0.62, 3, 8)), 0, side * 0.085, 0.44, 2, 1, side, 0.8); + add(new T.BoxGeometry(0.2, 0.1, 0.08), 0.035, side * 0.085, 0.04, 4, 1, side, 0.8); + add(upright(new T.CapsuleGeometry(0.05, 0.42, 3, 6)), 0, side * 0.215, 0.94, 0, 2, side, 1.18); + add(new T.SphereGeometry(0.056, 8, 6), 0, side * 0.22, 0.66, 1, 2, side, 1.18); + } + add(upright(new T.CylinderGeometry(0.15, 0.14, 0.16, 12)), 0, 0, 0.8, 2, 0, 0, 0); + add(upright(new T.CapsuleGeometry(0.15, 0.3, 4, 12)), 0, 0, 1.0, 0, 0, 0, 0, [0.78, 1.15, 1]); + add(upright(new T.CylinderGeometry(0.05, 0.055, 0.1, 8)), 0, 0, 1.32, 1, 0, 0, 0); + add(new T.SphereGeometry(0.125, 16, 12), 0, 0, 1.47, 1, 3, 0, 1.33); + add(upright(new T.SphereGeometry(0.136, 16, 8, 0, Math.PI * 2, 0, Math.PI * 0.55)), -0.015, 0, 1.49, 3, 3, 0, 1.33); + // The nose: which way the head faces, from close up. + add(new T.SphereGeometry(0.025, 6, 4), 0.125, 0, 1.46, 1, 3, 0, 1.33); + const g = new T.BufferGeometry(); + g.setAttribute("position", new T.Float32BufferAttribute(positions, 3)); + g.setAttribute("normal", new T.Float32BufferAttribute(normals, 3)); + g.setAttribute("aPart", new T.Float32BufferAttribute(parts, 1)); + g.setAttribute("aJoint", new T.Float32BufferAttribute(joints, 3)); + return g; } // ── time of day ─────────────────────────────────────────────────────────── @@ -273,13 +713,29 @@ export class CityView { this.lightDir.set(Math.cos(theta) * side, -0.45 * side, Math.sin(theta) * side).normalize(); this.sun.color.copy(alt >= 0 ? a.setHex(C.sunLow).lerp(b.setHex(C.sunHigh), smooth(alt * 2)) : a.setHex(C.moon)); this.sun.intensity = alt >= 0 ? 2.6 * smooth(alt / 0.15) : 0.7 * smooth(-alt / 0.15); - this.hemi.intensity = 0.5 + 0.7 * day; - const dusk = Math.max(0, 1 - Math.abs(alt) / 0.3); - this.sky.setHex(C.night).lerp(a.setHex(C.day), day).lerp(b.setHex(C.dusk), dusk * 0.55); - this.fog.color.copy(this.sky); + this.hemi.intensity = 0.55 + 0.65 * day; + this.hemi.color.setHex(C.hemiNight).lerp(a.setHex(C.hemiDay), day); + + // The sky: night to day, the zenith deepening and a band on the sun's side of the horizon warming through dawn and dusk. + const dusk = Math.max(0, 1 - Math.abs(alt) / 0.3), { uZenith, uHorizon, uGround, uSunDir, uSunColor, uDuskAmount } = this.skyUniforms; + uHorizon.value.setHex(C.sky.nightHorizon).lerp(a.setHex(C.sky.dayHorizon), day); + uZenith.value.setHex(C.sky.nightZenith).lerp(a.setHex(C.sky.dayZenith), day).lerp(b.setHex(C.sky.duskZenith), dusk * 0.3); + uDuskAmount.value = dusk * 0.75; + uGround.value.copy(uHorizon.value).lerp(uZenith.value, 0.35).multiplyScalar(0.62); + this.fog.color.copy(uHorizon.value).lerp(uGround.value, 0.4); + uSunDir.value.set(Math.cos(theta), -0.45, Math.sin(theta)).normalize(); + uSunColor.value.setHex(C.sunLow).lerp(a.setHex(C.sunHigh), smooth(alt * 3)).multiplyScalar(smooth(alt / 0.1 + 1)); + this.sky.copy(uGround.value); + + // Unlit glass shows the sky: dark by night, by day the zenith low on the pane and the horizon high. + const { uGlassLow, uGlassHigh } = this.glassUniforms; + uGlassLow.value.setHex(C.glass.nightLow).lerp(a.setHex(C.glass.dayLow).lerp(uZenith.value, 0.3), day); + uGlassHigh.value.setHex(C.glass.nightHigh).lerp(a.copy(uHorizon.value).lerp(b.setHex(C.glass.dayLow), 0.5).multiplyScalar(0.85), day); + const lampsOn = smooth(-alt / 0.08 + 0.5); + this.figureUniforms.uGlow.value = 0.15 + 0.4 * (1 - day); this.lampHeads.color.setHex(C.lampOff).lerp(a.setHex(C.lampOn), lampsOn); - this.lampPools.opacity = 0.16 * lampsOn; + this.lampPools.opacity = 0.42 * lampsOn; this.updateWindows(t, hour); } @@ -294,7 +750,8 @@ export class CityView { const lit = this.windowHash[i] >= 0 && windowsLit(hour, this.windowHash[i]) ? 1 : 0; if (lit === this.windowLit[i]) continue; this.windowLit[i] = lit; changed = true; - this.windows.setColorAt(i, a.setHex(lit ? C.lit[i % C.lit.length] : C.glass)); + // Black is the shader's sign for an unlit pane. + this.windows.setColorAt(i, lit ? a.setHex(C.lit[i % C.lit.length]) : a.setRGB(0, 0, 0)); } if (changed && this.windows.instanceColor) this.windows.instanceColor.needsUpdate = true; } @@ -312,6 +769,7 @@ export class CityView { const rect = this.canvas.getBoundingClientRect(), v = this.personAt, half = this.city.size / 2; let best = -1, bestD = tolerance; for (let i = 0; i < f.count; i++) { + if (this.people && this.people.inside[i] > 0.9) continue; // indoors: nobody to click v.set(f.x[i] - half, f.y[i] - half, 2).project(this.camera); if (v.z > 1) continue; const d = Math.hypot(rect.left + ((v.x + 1) / 2) * rect.width - clientX, rect.top + ((1 - v.y) / 2) * rect.height - clientY); @@ -338,6 +796,8 @@ export class CityView { this.cameraAt.set(this.lookAt.x + Math.cos(this.orbit) * flat, this.lookAt.y + Math.sin(this.orbit) * flat, height); this.camera.position.lerp(this.cameraAt, ease); this.camera.lookAt(this.lookAt.x, this.lookAt.y, following ? 2 : 0); + this.dome.position.copy(this.camera.position); + this.viewDistance = this.camera.position.distanceTo(this.lookAt); this.eased = true; this.cutaway(following, dt); this.aimLight(this.lookAt.x, this.lookAt.y, Math.min(this.city.size * 0.75, Math.max(70, this.camera.position.distanceTo(this.lookAt) * 0.8))); @@ -346,16 +806,16 @@ export class CityView { } /** - * While following someone, any building standing between the camera and that person is lowered to a stub (and its - * windows put away), then raised again once it is out of the way: the person is never hidden behind a tower. - * A building is in the way if the sight line crosses its footprint below its roof. + * While following someone, any building standing between the camera and that person is lowered to a stub (its + * windows, roof and everything on it put away), then raised again once it is out of the way: the person is never + * hidden behind a tower. A building is in the way if the sight line crosses its footprint below its roof. */ private cutaway(following: boolean, dt: number): void { const mesh = this.boxMesh; if (!mesh) return; const half = this.city.size / 2, cx = this.camera.position.x + half, cy = this.camera.position.y + half, cz = this.camera.position.z; const tx = this.lookAt.x + half, ty = this.lookAt.y + half, tz = 3, ease = 1 - Math.exp(-dt * 8), m = this.personMatrix; - let moved = false, panesMoved = false; + let moved = false, panesMoved = false, roofsMoved = false; for (const b of this.city.buildings) { let target = 1; if (following && b.height > 3) { @@ -366,23 +826,27 @@ export class CityView { if (pp === 0) { if (q < 0) { hit = false; break; } } else { const t = q / pp; if (pp < 0) t0 = Math.max(t0, t); else t1 = Math.min(t1, t); } } // The sight line descends towards the person, so it is lowest where it leaves the footprint. - if (hit && t0 < t1 && cz + (tz - cz) * t1 < 0.26 + b.height) target = Math.min(1, 1.6 / b.height); + if (hit && t0 < t1 && cz + (tz - cz) * t1 < LOT + b.height) target = Math.min(1, 1.6 / b.height); } const now = this.stand[b.id]; if (Math.abs(now - target) < 0.002) continue; - const next = Math.abs(now - target) < 0.01 ? target : now + (target - now) * ease, box = this.boxes[this.boxOf[b.id]]; + const next = Math.abs(now - target) < 0.01 ? target : now + (target - now) * ease, box = this.boxes[this.bodyOf[b.id]]; this.stand[b.id] = next; moved = true; - mesh.setMatrixAt(this.boxOf[b.id], m.makeScale(box.sx, box.sy, box.sz * next).setPosition(box.x, box.y, 0.26 + (box.sz * next) / 2)); - if (this.awningOf[b.id] >= 0) { const a = this.boxes[this.awningOf[b.id]], s = next < 0.999 ? 0 : 1; mesh.setMatrixAt(this.awningOf[b.id], m.makeScale(a.sx * s, a.sy * s, a.sz * s).setPosition(a.x, a.y, a.z)); } - // Windows go the moment a building starts to sink and come back when it stands again. + mesh.setMatrixAt(this.bodyOf[b.id], m.makeScale(box.sx, box.sy, box.sz * next).setPosition(box.x, box.y, LOT + (box.sz * next) / 2)); + // Everything else that belongs to the building goes the moment it starts to sink and comes back when it stands again. const wasWhole = now >= 0.999, isWhole = next >= 0.999; - if (this.windows && wasWhole !== isWhole) { + if (wasWhole === isWhole) continue; + for (const k of this.partsOf[b.id]) { const p = this.boxes[k]; mesh.setMatrixAt(k, isWhole ? m.makeScale(p.sx, p.sy, p.sz).setPosition(p.x, p.y, p.z) : m.makeScale(0, 0, 0)); } + const roof = this.roofOf[b.id]; + if (roof >= 0 && this.roofMesh) { this.roofMesh.setMatrixAt(roof, isWhole ? this.roofMatrices[roof] : m.makeScale(0, 0, 0)); roofsMoved = true; } + if (this.windows) { const [from, to] = this.panesOf[b.id]; for (let k = from; k < to; k++) this.windows.setMatrixAt(k, isWhole ? this.paneMatrices[k] : m.makeScale(0, 0, 0)); panesMoved = true; } } if (moved) mesh.instanceMatrix.needsUpdate = true; + if (roofsMoved && this.roofMesh) this.roofMesh.instanceMatrix.needsUpdate = true; if (panesMoved && this.windows) this.windows.instanceMatrix.needsUpdate = true; } @@ -400,6 +864,10 @@ export class CityView { dispose(): void { for (const d of this.disposables) d.dispose(); + if (this.people) { + for (const mesh of [this.people.bodies, this.people.blobs, this.people.bubbles]) { mesh.geometry.dispose(); (mesh.material as THREE.Material).dispose(); mesh.dispose(); } + (this.people.blobs.material as THREE.MeshBasicMaterial).map?.dispose(); + } this.renderer.dispose(); } } diff --git a/content/posts/city-of-agents/components/describe.ts b/content/posts/city-of-agents/components/describe.ts index 1445da7..28687cc 100644 --- a/content/posts/city-of-agents/components/describe.ts +++ b/content/posts/city-of-agents/components/describe.ts @@ -1,4 +1,5 @@ import { type Labels, personName } from "./labels"; +import type { PersonRow } from "./session"; import { type City, formatTime, type SimEvent } from "./sim"; /** Place ids only mean something in the city they came from: always pass the city of the same snapshot as the rows. */ @@ -8,6 +9,10 @@ export const placeName = (t: Labels, city: City, id: number): string => { return `${t.places[place.kind]}(${t.zones[block.zone]} ${block.label})`; }; +/** What a person is up to, as the Overseer's table and the follow badge say it: "前往 工作 · 辦公(商業區 D5)", or "沒事". */ +export const personTask = (t: Labels, city: City, p: PersonRow): string => + p.state === "idle" ? t.states.idle : `${p.state === "traveling" ? `${t.states.traveling} ` : ""}${p.action ? t.actions[p.action] : ""} · ${placeName(t, city, p.place)}`; + /** "Day 2 07:40 阿凱 離開 家(住宅區 B3) → 前往 辦公(商業區 D5)" — put together here; the record itself holds no text. */ export function describeEvent(t: Labels, city: City, e: SimEvent): string { const when = formatTime(e.t); diff --git a/content/posts/city-of-agents/components/labels.ts b/content/posts/city-of-agents/components/labels.ts index ba43b91..412cad5 100644 --- a/content/posts/city-of-agents/components/labels.ts +++ b/content/posts/city-of-agents/components/labels.ts @@ -36,6 +36,8 @@ const zh = { anotherPerson: "換一個人", threshold: "門檻", day: "第", dayUnit: "天", walking: "走路", optionA: "選項 A", optionB: "選項 B", need: "需求", walk: "要走幾分鐘", utilityOf: "效用", wins: "勝出", formula: "效用 = 需求² − 0.1 × 路程(小時)", computing: "計算中…", modesChart: "同樣 300 個人、同一座城市,第二天每 10 分鐘各有多少比例的人在做什麼", peakShort: "最擠的 10 分鐘", tripsPerDay: "每人每天出門", tripsUnit: "趟", + asleepAt: "01:00 / 12:00 在家睡覺", bodyClock: "生理時鐘(晚上比較容易累)", + today: "今天每 10 分鐘各有多少比例的人在做什麼;淡色是昨天,直線是現在", todayTitle: "這一天", names: ["阿凱", "小美", "志明", "春嬌", "阿土", "佩君", "冠宇", "怡君", "家豪", "雅婷", "承恩", "宜蓁", "柏翰", "欣怡", "俊傑", "淑芬", "建宏", "美玲", "宗翰", "詩涵", "信宏", "惠雯", "文傑", "佳穎", "明哲", "筱涵", "國華", "雅雯", "子軒", "思妤", "彥廷", "郁婷", "哲瑋", "靜宜", "育成", "婉婷", "志豪", "曉玲", "政憲", "珮瑜"], }; @@ -73,6 +75,8 @@ const en: typeof zh = { anotherPerson: "Another person", threshold: "threshold", day: "day", dayUnit: "", walking: "walking", optionA: "Option A", optionB: "Option B", need: "need", walk: "minutes on foot", utilityOf: "utility", wins: "wins", formula: "utility = need² − 0.1 × walk (hours)", computing: "computing…", modesChart: "The same 300 people in the same city: what share of them is doing what, in each ten minutes of day two", peakShort: "busiest ten minutes", tripsPerDay: "trips per person per day", tripsUnit: "", + asleepAt: "asleep at home at 01:00 / 12:00", bodyClock: "body clock (tiring faster at night)", + today: "What share of people is doing what, in each ten minutes of today; yesterday is faint, the line is now", todayTitle: "the day so far", names: ["Kai", "Mei", "Ming", "Jiao", "Tu", "Pei", "Yu", "Yi", "Hao", "Ting", "En", "Zhen", "Han", "Xin", "Jie", "Fen", "Hong", "Ling", "Zong", "Shi", "Xinh", "Wen", "Wei", "Ying", "Zhe", "Xiao", "Hua", "Ya", "Xuan", "Si", "Yan", "Yuting", "Zhewei", "Jing", "Cheng", "Wan", "Zhihao", "Lin", "Xian", "Peiyu"], }; diff --git a/content/posts/city-of-agents/components/modes-lab.tsx b/content/posts/city-of-agents/components/modes-lab.tsx index d2a7df1..61b763b 100644 --- a/content/posts/city-of-agents/components/modes-lab.tsx +++ b/content/posts/city-of-agents/components/modes-lab.tsx @@ -1,72 +1,90 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { ActivityColumns, HourTicks } from "./activity-chart"; import { useLabels } from "./labels"; -import { departureHistogram, generateCity, type Mode, peakDepartureShare, type SimEvent, World } from "./sim"; -import { FILL, SWATCH } from "./use-city"; +import { ACTIVITIES, type Activity, activityShares, departureHistogram, generateCity, type Mode, PARAMS, peakDepartureShare, type SimEvent, World } from "./sim"; +import { SWATCH } from "./use-city"; import { useVisible } from "./use-visible"; const MODES: Mode[] = ["fsm", "utility", "random"], PEOPLE = 300, FROM = 1440, UNTIL = 2880, CHUNK = 180; -/** Bottom to top of each column. */ -const STACK = ["sleep", "work", "eat", "social", "walking", "idle"] as const; -type Kind = (typeof STACK)[number]; -type Result = { shares: number[][]; peak: number; trips: number }; +/** The columns of day two start at 00:10, one every ten minutes: these are 01:00 and 12:00. */ +const ONE_AM = 5, NOON = 71; +type Result = { shares: Float32Array[]; peak: number; trips: number }; +/** + * A run of one head for two days. The body clock only reaches the head that reads the needs: the timetable and the dice + * never look at fatigue, so their days are the same with it or without it and are not run twice. + */ +type Job = "fsm" | "utility" | "random" | "utility-noclock"; +const jobOf = (mode: Mode, clock: boolean): Job => (mode === "utility" && !clock ? "utility-noclock" : mode); /** * Fig. 04: the three heads side by side. Each is the same 300 people run for two days by the reader's browser, without * a scene, a few simulated hours per frame so the page never stalls. The chart is day two: what everyone was doing. + * Switching the body clock off runs the needs head once more, with fatigue growing at the same rate day and night. */ export function ModesLab() { - const t = useLabels(), root = useRef(null), visible = useVisible(root), [results, setResults] = useState<(Result | null)[]>([null, null, null]); + const t = useLabels(), root = useRef(null), visible = useVisible(root); + const [results, setResults] = useState>>({}), [clock, setClock] = useState(true); + const queue = useRef(["fsm", "utility", "random"]), kick = useRef<() => void>(() => {}); useEffect(() => { - let raf = 0, index = 0, world: World | null = null, events: SimEvent[] = [], shares: number[][] = []; + let raf = 0, job: Job | null = null, world: World | null = null, events: SimEvent[] = [], shares: Float32Array[] = []; const work = () => { - if (index >= MODES.length) return; + raf = 0; + if (!job && !queue.current.length) return; // nothing to do: the loop stops until a switch asks for more raf = requestAnimationFrame(work); if (!visible.current || !root.current || root.current.getBoundingClientRect().top > window.innerHeight) return; // nobody pays for this before they get here - if (!world) { world = new World(generateCity(1, 8), { agents: PEOPLE, mode: MODES[index] }); events = []; shares = []; world.onEvent = (e) => { if (e.type === "departed") events.push(e); }; } + if (!world) { + job = queue.current.shift() as Job; + const mode: Mode = job === "utility-noclock" ? "utility" : job; + world = new World(generateCity(1, 8), { agents: PEOPLE, mode, params: job === "utility-noclock" ? { ...PARAMS, circadian: 0 } : undefined }); + events = []; shares = []; + world.onEvent = (e) => { if (e.type === "departed") events.push(e); }; + } for (let k = 0; k < CHUNK && world.t < UNTIL; k++) { world.tick(); - if (world.t > FROM && world.t % 10 === 0) { - const counts = STACK.map(() => 0); - for (const a of world.agents) counts[STACK.indexOf(a.state === "traveling" ? "walking" : a.state === "acting" && a.action ? a.action : "idle")]++; - shares.push(counts.map((c) => c / PEOPLE)); - } + if (world.t > FROM && world.t % 10 === 0) shares.push(activityShares(world.agents)); } if (world.t < UNTIL) return; - const histogram = departureHistogram(events, FROM, UNTIL), at = index, done = shares; - setResults((all) => all.map((r, k) => (k === at ? { shares: done, peak: peakDepartureShare(histogram, PEOPLE), trips: histogram.reduce((s, v) => s + v, 0) / PEOPLE } : r))); - world = null; index++; + const histogram = departureHistogram(events, FROM, UNTIL), done: Result = { shares, peak: peakDepartureShare(histogram, PEOPLE), trips: histogram.reduce((s, v) => s + v, 0) / PEOPLE }, key = job as Job; + setResults((all) => ({ ...all, [key]: done })); + world = null; job = null; }; - raf = requestAnimationFrame(work); - return () => cancelAnimationFrame(raf); + kick.current = () => { if (!raf) raf = requestAnimationFrame(work); }; + kick.current(); + return () => { cancelAnimationFrame(raf); kick.current = () => {}; }; }, [visible]); - const name = (k: Kind) => (k === "walking" ? t.walking : t.actions[k]); + const toggleClock = (on: boolean) => { + setClock(on); + if (!on && !results["utility-noclock"] && !queue.current.includes("utility-noclock")) { queue.current.push("utility-noclock"); kick.current(); } + }; + const name = (k: Activity) => (k === "walking" ? t.walking : t.actions[k]), pct = (v: number) => `${Math.round(v * 100)}%`; return (
+
- {MODES.map((mode, i) => { - const r = results[i]; + {MODES.map((mode) => { + const r = results[jobOf(mode, clock)], sleep = ACTIVITIES.indexOf("sleep"); return (

{t.modes[mode]}

- - {r?.shares.map((column, x) => { - let y = 60; - return column.map((share, k) => { const h = share * 60; y -= h; return h > 0.05 ? : null; }); - })} - -
0006121824
-

{t.peak} · {t.peakShort}{r ? `${Math.round(r.peak * 100)}%` : t.computing}

+ {r ? + :
{t.computing}
} + +

{t.peak} · {t.peakShort}{r ? pct(r.peak) : t.computing}

{t.tripsPerDay}{r ? `${r.trips.toFixed(1)} ${t.tripsUnit}` : "—"}

+

{t.asleepAt}{r ? `${pct(r.shares[ONE_AM][sleep])} / ${pct(r.shares[NOON][sleep])}` : "—"}

); })}
    - {STACK.map((k) =>
  • {name(k)}
  • )} + {ACTIVITIES.map((k) =>
  • {name(k)}
  • )}
); diff --git a/content/posts/city-of-agents/components/overseer-lab.tsx b/content/posts/city-of-agents/components/overseer-lab.tsx index d8332a6..d1fbd21 100644 --- a/content/posts/city-of-agents/components/overseer-lab.tsx +++ b/content/posts/city-of-agents/components/overseer-lab.tsx @@ -4,8 +4,8 @@ import { Pause, Play } from "lucide-react"; import { useRef, useState } from "react"; import { Readout } from "@/components/lab/readout"; import { Button } from "@/components/ui/button"; -import { Legend } from "./city-lab"; -import { personName, useLabels } from "./labels"; +import { FollowBadge, Legend } from "./city-lab"; +import { useLabels } from "./labels"; import { DepartureChart, EventStream, StatusTable, Timeline } from "./overseer-panel"; import { Param } from "@/components/lab/param"; import { MAX_N, MIN_N, type Mode, PARAMS } from "./sim"; @@ -31,7 +31,7 @@ export function OverseerLab({ seed = 1, n = 8, agents = 300 }: { seed?: number; act(() => s?.setFollow(s.pick(e.clientX, e.clientY)))} /> {!ready &&

{t.loading}

} - {follow >= 0 &&

{t.following} · {personName(t, follow)}

} + {panel?.replaying &&

● {t.replay}

}
diff --git a/content/posts/city-of-agents/components/overseer-panel.tsx b/content/posts/city-of-agents/components/overseer-panel.tsx index 499f41b..4733734 100644 --- a/content/posts/city-of-agents/components/overseer-panel.tsx +++ b/content/posts/city-of-agents/components/overseer-panel.tsx @@ -5,13 +5,13 @@ import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; import { type Labels, personName } from "./labels"; import type { PanelState, PersonRow } from "./session"; -import { describeEvent, placeName } from "./describe"; +import { describeEvent, personTask } from "./describe"; import { type City, formatTime, type SimEvent } from "./sim"; import { SWATCH } from "./use-city"; const ROW = 30, VIEWPORT = 8; -function Dot({ action }: { action: PersonRow["action"] }) { +export function Dot({ action }: { action: PersonRow["action"] }) { return ; } @@ -30,7 +30,7 @@ export function StatusTable({ t, city, people, follow, onFollow }: { t: Labels; {personName(t, p.id)} - {p.state === "idle" ? t.states.idle : `${p.state === "traveling" ? `${t.states.traveling} ` : ""}${p.action ? t.actions[p.action] : ""} · ${placeName(t, city, p.place)}`} + {personTask(t, city, p)} `${t.needNames[i]} ${Math.round(v * 100)}%`).join(", ")} role="img"> {p.needs.map((v, i) => 0.75 ? "bg-signal-2" : "bg-signal"}`} style={{ height: `${Math.round(v * 100)}%` }} />)} diff --git a/content/posts/city-of-agents/components/session.ts b/content/posts/city-of-agents/components/session.ts index 335df26..da2023a 100644 --- a/content/posts/city-of-agents/components/session.ts +++ b/content/posts/city-of-agents/components/session.ts @@ -1,7 +1,7 @@ import type { CityView, PeopleFrame } from "./city-view3d"; import { type Action, type AgentInfo, type AgentState, arrivalsAfter, type City, type Context, cursorAt, departureHistogram, generateCity, initialSnapshot, type Log, - modalShare, type Mode, type Needs, needsAt, PARAMS, peakDepartureShare, record, replayPositions, type SimEvent, snapshotAt, World, + activityShares, modalShare, type Mode, type Needs, needsAt, PARAMS, peakDepartureShare, record, replayPositions, type SimEvent, snapshotAt, World, } from "./sim"; export type Setup = { seed: number; n: number; agents: number }; @@ -13,6 +13,8 @@ export type PanelState = { histogram: number[]; peak: number; sync: number; frame: number; calls: number; /** Who the camera follows, or −1; never past the end of `people`. */ follow: number; + /** What share of people did what in each ten minutes of the live day and of the day before (null: not reached, or before the start). */ + today: (Float32Array | null)[]; yesterday: (Float32Array | null)[]; }; /** At most this many simulated minutes per frame: a slow frame drops simulated time instead of snowballing. */ @@ -41,6 +43,9 @@ export class CitySession { private frames = 0; private since = 0; private frameMs = 0; + /** One sample of what everyone is doing per ten simulated minutes, for today and yesterday; slot k is k × 10 minutes after midnight. */ + private today: (Float32Array | null)[] = []; + private yesterday: (Float32Array | null)[] = []; /** Set by whoever owns the canvas: a rebuilt city needs a rebuilt scene. */ onRebuild: (() => void) | null = null; @@ -57,8 +62,10 @@ export class CitySession { this.ctx = { infos, params: PARAMS }; this.log = { base: initialSnapshot(infos, this.world.agents.map((a) => a.needs), this.world.t, mode, duty), events: [], dropped: 0 }; this.world.onEvent = (e) => record(this.log, e, this.ctx); - this.frame = { count, x: new Float64Array(count), y: new Float64Array(count), px: Float64Array.from(this.world.x), py: Float64Array.from(this.world.y), heading: new Float64Array(count), action: new Array(count).fill(null), walking: new Uint8Array(count) }; + this.frame = { count, x: new Float64Array(count), y: new Float64Array(count), px: Float64Array.from(this.world.x), py: Float64Array.from(this.world.y), heading: new Float64Array(count), action: new Array(count).fill(null), walking: new Uint8Array(count), at: new Int32Array(count).fill(-1) }; this.replayT = null; this.pending = 0; this.dirty = true; + this.today = new Array(144).fill(null); this.yesterday = new Array(144).fill(null); + this.sample(); this.onRebuild?.(); } @@ -74,7 +81,7 @@ export class CitySession { const started = performance.now(), { world, frame } = this, live = this.replayT === null; if (live && this.running) { this.pending += dt * PARAMS.minutesPerSecond * this.rate; - for (let k = 0; this.pending >= 1 && k < MAX_TICKS; k++, this.pending--) { frame.px.set(world.x); frame.py.set(world.y); world.tick(); } + for (let k = 0; this.pending >= 1 && k < MAX_TICKS; k++, this.pending--) { frame.px.set(world.x); frame.py.set(world.y); world.tick(); if (world.t % 10 === 0) this.sample(); } if (this.pending >= 1) this.pending = 0; this.dirty = true; } @@ -93,7 +100,7 @@ export class CitySession { private fillLive(): void { const { world, frame } = this; frame.x.set(world.x); frame.y.set(world.y); frame.heading.set(world.heading); - for (const a of world.agents) { frame.action[a.id] = a.action; frame.walking[a.id] = a.state === "traveling" ? 1 : 0; } + for (const a of world.agents) { frame.action[a.id] = a.action; frame.walking[a.id] = a.state === "traveling" ? 1 : 0; frame.at[a.id] = a.state === "acting" ? a.place : -1; } this.shownReplay = NaN; } @@ -101,10 +108,17 @@ export class CitySession { const { frame, log, ctx } = this, cursor = cursorAt(log, t), snapshot = snapshotAt(log, cursor, ctx); replayPositions(this.city, this.world.paths, snapshot, ctx, arrivalsAfter(log, cursor, snapshot), t, frame.x, frame.y, frame.heading); frame.px.set(frame.x); frame.py.set(frame.y); - snapshot.agents.forEach((a, i) => { frame.action[i] = a.action; frame.walking[i] = a.state === "traveling" ? 1 : 0; }); + snapshot.agents.forEach((a, i) => { frame.action[i] = a.action; frame.walking[i] = a.state === "traveling" ? 1 : 0; frame.at[i] = a.state === "acting" ? a.place : -1; }); this.shownReplay = t; } + /** Records what everyone is doing in the current ten-minute slot; midnight turns today into yesterday. */ + private sample(): void { + const slot = Math.floor((this.world.t % 1440) / 10); + if (slot === 0 && this.today.some((v) => v)) { this.yesterday = this.today; this.today = new Array(144).fill(null); } + this.today[slot] = activityShares(this.world.agents); + } + pick(clientX: number, clientY: number): number { return this.view ? this.view.pick(clientX, clientY, this.frame) : -1; } /** What the panel shows, live or at the replay time — the latter from the record alone. */ @@ -123,7 +137,7 @@ export class CitySession { for (let k = cursor - 1; k >= 0 && events.length < eventCount; k--) if (log.events[k].type !== "arrived") events.push(log.events[k]); const histogram = departureHistogram(log.events, t - 1440, t + 1e-9); return { city: this.city, follow: Math.min(this.follow, people.length - 1), t, from: log.base.t, now: world.t, replaying: !live, mode, duty, people, events, marks: log.events.filter((e) => e.type === "config").map((e) => e.t), histogram, - peak: peakDepartureShare(histogram, people.length), sync, frame: this.frameMs, calls: this.view?.calls ?? 0 }; + peak: peakDepartureShare(histogram, people.length), sync, frame: this.frameMs, calls: this.view?.calls ?? 0, today: this.today.slice(), yesterday: this.yesterday.slice() }; } dispose(): void { this.view?.dispose(); this.view = null; } diff --git a/content/posts/city-of-agents/components/sim/metrics.ts b/content/posts/city-of-agents/components/sim/metrics.ts index 71dcf7c..b696d5f 100644 --- a/content/posts/city-of-agents/components/sim/metrics.ts +++ b/content/posts/city-of-agents/components/sim/metrics.ts @@ -1,6 +1,19 @@ import type { Agent } from "./agent"; import type { SimEvent } from "./types"; +/** What a person counts as in the activity charts, bottom to top of each column. */ +export const ACTIVITIES = ["sleep", "work", "eat", "social", "walking", "idle"] as const; +export type Activity = (typeof ACTIVITIES)[number]; +export const activityOf = (a: Pick): Activity => (a.state === "traveling" ? "walking" : a.state === "acting" && a.action ? a.action : "idle"); + +/** Share of each activity among `agents`, in the order of `ACTIVITIES`. */ +export function activityShares(agents: Pick[]): Float32Array { + const shares = new Float32Array(ACTIVITIES.length); + for (const a of agents) shares[ACTIVITIES.indexOf(activityOf(a))]++; + if (agents.length) for (let k = 0; k < shares.length; k++) shares[k] /= agents.length; + return shares; +} + /** Share of people doing the most common thing right now (an action, walking, or nothing). Near 1 at night in every mode. */ export function modalShare(agents: Agent[]): number { const counts = new Map(); diff --git a/content/posts/city-of-agents/components/use-city.ts b/content/posts/city-of-agents/components/use-city.ts index c477a7a..ea9af50 100644 --- a/content/posts/city-of-agents/components/use-city.ts +++ b/content/posts/city-of-agents/components/use-city.ts @@ -29,6 +29,8 @@ export function useCity(root: RefObject, canvas: RefObject { raf = requestAnimationFrame(loop); diff --git a/content/posts/city-of-agents/en.mdx b/content/posts/city-of-agents/en.mdx index f80c2bf..3f42638 100644 --- a/content/posts/city-of-agents/en.mdx +++ b/content/posts/city-of-agents/en.mdx @@ -14,13 +14,13 @@ import { PARAMS } from "./components/sim/params"; [The previous entry](/en/posts/slam-2d) had one car. This one has three hundred people living in the same city, each going to work, eating, finding company and going home to sleep. -Look first. One second in the city is ten minutes outside, so a day takes two and a half minutes; 20× is faster still. Click any person in the scene, or press "Follow someone", and the camera comes down to the street and walks with them. +Look first. One second outside is ten minutes in the city, so a day takes two and a half minutes; 20× is faster still. Click any person in the scene, or press "Follow someone", and the camera comes down to the street and walks with them. - + -Around eight or nine in the morning the cyan dots gather from the residential blocks towards the towers in the centre; in the evening they spread out again; at night only a few people are left on the streets and the windows go dark one building at a time. +Around eight or nine in the morning the cyan dots gather from the residential blocks towards the towers in the centre and go in at the doors; in the evening they come back out and spread again; at night only a few people are left on the streets and the windows go dark one building at a time. There is no timetable in this city. You will not find a line of code that says "work starts at eight". Each person knows four numbers of their own and one formula for scoring things. @@ -40,7 +40,7 @@ Seed 1 grows 373 buildings: 248 houses, 29 office blocks and 96 shopfronts, the People walk on pavements and zebra crossings only. Each block is ringed by a pavement, joined to its neighbours at the corners, and every building has a door on it. Those points and lines are a graph of 639 nodes and 863 edges; going somewhere is a shortest path on it, found with A\* and remembered for the next person. -Everything on screen takes 10 draw calls, shadow pass included: every box — ground, buildings, crossing stripes, benches, lamp posts — is one InstancedMesh; tree crowns, windows, lamps, people's bodies and people's heads are one each. The sun's height is sin((hour − 6) / 24 · 2π), and light, sky, fog, lamps and windows are all functions of that value: the scene itself does not remember whether it is day or night. +Everything on screen takes 16 draw calls, shadow pass included: every box — plinth, ground, buildings, lane markings, crossing stripes, benches, lamp posts — is one InstancedMesh; roofs, tree crowns, windows, lamps, the lamplight on the pavement, the soft shadows under buildings and trees, the river, the sky, and the people (each a single model whose arms and legs swing in the shader), the glow at their feet and their speech bubbles are one each. Walls darken towards the ground in the shader, by height, with no extra pass. The sun's height is sin((hour − 6) / 24 · 2π), and light, sky, fog, lamps, lit windows and the sky reflected in the glass are all functions of that value: the scene itself does not remember whether it is day or night. ## Four needs @@ -63,7 +63,7 @@ Here are three days of one of them, worked out by your browser just now. Hunger is the most regular: eight hours to reach 0.6, forty minutes to eat, three meals a day. **The backlog** is the one need that is not bodily: it is the work waiting on the desk. By day work keeps coming in, so it keeps rising, and only being at the office brings it down; after {PARAMS.dutyHours[1]}:00 nothing new arrives, so whoever has cleared it can leave. Switching it off means nobody in this city has a job to go to. -Fatigue has one thing the others do not: **it grows faster at night.** Its rate is multiplied by (1 − {PARAMS.circadian} × the sun's height), so the lower the sun, the sooner people tire. I measured what happens without it: the sleep of 300 people spreads evenly over the day, with a fifth to almost half of them asleep at any hour, and the city has no night. With it, two thirds are asleep at one in the morning and 2% at noon. +Fatigue has one thing the others do not: **it grows faster at night.** Its rate is multiplied by (1 − {PARAMS.circadian} × the sun's height), so the lower the sun, the sooner people tire. Without it the sleep of 300 people spreads out over the whole day, midnight is no longer when most of them sleep, and the city's night smears out. Figure 04 below has the switch, so you can turn it off yourself. My first version of this table did not fit into a day. Add up the time the four needs each demand and you get 28.8 hours out of every 24, before any walking. Run, it gave a city where nobody had a free minute, every need sat at 1.0 for more than a quarter of the time, and the median start of work was two in the afternoon. The numbers above were changed against measurements; the comparison is in this project's `docs/research/city-of-agents/RESULTS.md`. @@ -85,7 +85,7 @@ The formula alone is not enough. Two things go wrong: **One bite and gone.** Someone goes to eat when hunger passes 0.6; half an hour later it is below 0.6, eating is no longer an option, so they leave — and are hungry again within minutes. The cure is two thresholds (start past {PARAMS.threshold[1]}, finish at {PARAMS.doneAt}) and {PARAMS.stayBonus} extra for whatever a person is already doing. -**Everyone deciding on the same minute.** People do not think again every frame. They think on arriving, on finishing, and every {PARAMS.reconsiderMinutes} minutes. Count those {PARAMS.reconsiderMinutes} minutes from midnight for everybody and three hundred people set off together, as if someone were conducting. So each person counts from a moment of their own. +**Everyone deciding on the same minute.** People do not think again every frame. They think on finishing, and every {PARAMS.reconsiderMinutes} minutes; arriving starts what was decided and restarts the count. Count those {PARAMS.reconsiderMinutes} minutes from midnight for everybody and three hundred people set off together, as if someone were conducting. So each person counts from a moment of their own. ## Three heads, the same legs @@ -93,9 +93,9 @@ To know whether the daily rhythm really grows out of the needs, swap the head an - **Fixed timetable**: everyone works at {PARAMS.fsm.work}, eats at {PARAMS.fsm.lunch}, goes home at {PARAMS.fsm.home}. - **Each by their needs**: the formula from the last section. -- **At random**: pick any place, stay half an hour to two hours. +- **At random**: pick any place, stay half an hour to two hours; one time in five, do nothing. - + @@ -103,7 +103,9 @@ The timetable's day is cut with a knife. At eight sharp, 100% of people leave wi The random day is flat: three in the morning looks like three in the afternoon. -The one in the middle keeps hours: most people asleep at night, the cyan of work rising through the morning and ebbing through the evening, meals and company scattered across the day. Nobody drew up that rota; it is what three hundred slightly different sets of numbers work out separately. In its busiest ten minutes only 11% of people set off together, much like random's 9% — but random has no shape. +The one in the middle keeps hours: most people asleep at night, the cyan of work rising through the morning and ebbing through the evening, meals and company scattered across the day. Nobody drew up that rota; it is what three hundred slightly different sets of numbers work out separately. In its busiest ten minutes only 12% of people set off together, much like random's 10% — but random has no shape. + +Part of that shape is the body clock's. Untick it and the middle chart's night smears out: people asleep at home at one in the morning fall from 73% to 38%, and at noon rise from 3% to 16%. None of the four needs is missing; fatigue just no longer follows the sun. Switch the backlog off and measure again (offline: 300 people, seed 1, the last of five days): sleeping, eating and company barely change, and free time goes from 11% to 40%. People do not find themselves something to do, and that is the most honest thing about this model: **it only answers the needs you give it.** If you want them to shop or exercise, that is another number. @@ -131,11 +133,11 @@ The "frame time" under the scene is measured on your machine as you watch: the m **This does not simulate people.** I chose the four needs and tuned their rates until it looked like a day. The backlog starts to build at {PARAMS.dutyHours[0]} in the morning so that it passes its threshold around half past eight; that is not how an office worker works. The model can claim one thing only: without a timetable, a few numbers that rise and fall and one scoring formula are enough for a crowd to find a rhythm. -**The walking is drawn.** A position moves along its route, steps aside a little and is kept within the pavement; the bobbing and the lean are only drawing. No cars, no traffic lights, no interiors: people doing something stand at the door, twice life size, or from the air they would be two pixels. +**The walking and the talking are drawn.** A position moves along its route, steps aside a little and is kept within the pavement; the swinging arms and legs and the bobbing are only drawing. No cars, no traffic lights, no interiors: in the simulation people at work, at a meal or asleep stand at the door, and only on screen do they walk in and come back out. People in a park each stand on their own spot in the simulation; on screen each pairs up with the nearest other and the two take turns talking. They are drawn twice life size, and bigger again as the camera pulls back, or from the air they would be two pixels. **What a game's version adds.** Real utility AI gives each option several considerations, passes each through its own curve (not necessarily a square) and multiplies them; objects advertise what they can satisfy; and there are personalities, memory, queues and reservations. What is left here is the smallest skeleton. -The numbers come from three places. Figures 02, 03 and 04 and the read-outs on the bench are computed by your browser as you read; the ones I say I measured were run offline, with seed, population and scripts in `docs/research/city-of-agents/`; the counts of buildings and nodes are what seed 1 grows at 8 × 8, and a test guarantees the same seed always grows the same city. +The numbers come from three places. The strip under figure 01, figures 02, 03 and 04 and the read-outs on the bench are computed by your browser as you read; the ones marked offline, and the first draft in the sidenote, come with their seed, population and scripts in `docs/research/city-of-agents/`; the counts of buildings and nodes are what seed 1 grows at 8 × 8, and a test guarantees the same seed always grows the same city. ## Sources diff --git a/content/posts/city-of-agents/zh.mdx b/content/posts/city-of-agents/zh.mdx index 7adb073..a5fe53e 100644 --- a/content/posts/city-of-agents/zh.mdx +++ b/content/posts/city-of-agents/zh.mdx @@ -12,13 +12,13 @@ import { PARAMS } from "./components/sim/params"; [上一篇](/zh/posts/slam-2d)只有一台車。這一篇有三百個人,住在同一座城市裡,各自上班、吃飯、找人聊天、回家睡覺。 -先看。城市裡的一秒是外面的十分鐘,所以兩分半鐘就是一天。點畫面裡任何一個小人,或按「跟著一個人走」,鏡頭會降到街上跟著他。 +先看。外面的一秒是城市裡的十分鐘,所以兩分半鐘就是一天,按 20× 更快。點畫面裡任何一個小人,或按「跟著一個人走」,鏡頭會降到街上跟著他。 - + -早上八九點,青色的點從住宅區往市中心的高樓聚過去;傍晚散開;半夜街上只剩零星幾個人,窗戶一棟一棟暗下來。 +早上八九點,青色的點從住宅區往市中心的高樓聚過去,一個個走進門;傍晚再從門口出來、散開;半夜街上只剩零星幾個人,窗戶一棟一棟暗下來。 這座城市裡沒有時刻表。程式裡找不到「八點上班」這一行。每個小人只知道自己的四個數字,和一條算分數的式子。 @@ -31,14 +31,14 @@ import { PARAMS } from "./components/sim/params"; 先畫 8 × 8 的街區,再依序決定: - **路**:每四條路有一條是比較寬的主幹道,最東邊一排是河岸。 -- **分區**:看街區離市中心多遠。近的是商業區,再外一圈是餐飲街和一些辦公樓,最外面是住宅區,中間隨機留幾塊公園(種子 1 是兩塊)。 +- **分區**:看街區離市中心多遠。近的是商業區,再外一圈是餐飲街和一些辦公樓,最外面是住宅區,其中隨機留幾塊公園(種子 1 是兩塊)。 - **高度**:「一個隨機數 × 離市中心有多近」,所以中間高、邊緣矮。 種子 1 長出來的是 373 棟建築:248 棟住家、29 棟辦公樓、96 間店面,最高的 80 公尺。 小人只走人行道和斑馬線。每個街區外圍一圈人行道,轉角和隔壁街區用斑馬線接起來,每棟建築在人行道上有一個門口。這些點和線就是一張圖:639 個節點、863 條邊。要去哪裡,就在這張圖上用 A\* 找最短路;同一對門口的路線會被記下來重複使用。 -畫面上的一切只用了 10 次繪圖呼叫(含陰影):所有的方塊,包括地面、建築、斑馬線、長椅、路燈桿,是同一個 InstancedMesh;樹冠、窗戶、路燈、小人的身體和頭各一個。太陽的高度是 sin((時 − 6) / 24 · 2π),光線、天色、霧、路燈和窗戶的燈全部是這個值的函數,場景本身不記得現在是白天還是晚上。 +畫面上的一切只用了 16 次繪圖呼叫(含陰影):所有的方塊,包括底座、地面、建築、車道線、斑馬線、長椅、路燈桿,是同一個 InstancedMesh;屋頂、樹冠、窗戶、路燈、路燈照在地上的光、建築和樹底下的軟影子、河、天空、小人(整個身體是一個模型,手腳在著色器裡擺動)、腳下的光圈和說話的泡泡各一個。牆腳越低越暗,是著色器照高度算的,不用多畫一趟。太陽的高度是 sin((時 − 6) / 24 · 2π),光線、天色、霧、路燈、窗戶的燈和玻璃映出的天空全部是這個值的函數,場景本身不記得現在是白天還是晚上。 ## 四條需求 @@ -61,7 +61,7 @@ import { PARAMS } from "./components/sim/params"; 飢餓是最規律的一條:八小時長到 0.6,吃四十分鐘,一天三餐。**待辦事項**是四條裡唯一不屬於身體的,它是桌上還沒做完的工作:白天工作一直進來,它就一直漲,只有人在辦公室裡才消得掉;{PARAMS.dutyHours[1]} 點以後不再有新的進來,做完就能走。把它關掉,等於這座城市沒有人需要工作。 -疲勞那一條多了一個東西:**它晚上長得比較快。** 成長速度要再乘上 (1 − {PARAMS.circadian} × 太陽高度),太陽越低、人越容易累。少了這一項會怎樣,我量過:300 個人的睡覺時間會平均攤在一整天,任何時刻都有兩成到四成五的人在睡,這座城市就沒有夜晚。加上去之後,凌晨一點有三分之二的人在睡,中午只剩 2%。 +疲勞那一條多了一個東西:**它晚上長得比較快。** 成長速度要再乘上 (1 − {PARAMS.circadian} × 太陽高度),太陽越低、人越容易累。少了這一項,300 個人的睡覺時間會攤開到一整天,半夜不再是大家都在睡的時候,這座城市的夜晚就糊掉了。後面的圖 04 有這個開關,可以自己關掉看。 我第一版的表格根本排不進一天。把四條需求各自要花的時間加起來,一天需要 28.8 小時,還沒算走路。跑出來的結果是:沒有人有任何一分鐘是閒著的,每條需求有四分之一以上的時間頂在 1.0,開始上班的時間中位數是下午兩點。現在的數字是照著量測結果改的,比較的過程留在這個專案的 `docs/research/city-of-agents/RESULTS.md`。 @@ -83,7 +83,7 @@ import { PARAMS } from "./components/sim/params"; **吃一口就走。** 飢餓過了 0.6 才去吃,吃了半小時降到 0.6 以下,「吃飯」就不再是選項,於是他離開,幾分鐘後又餓了。解法是兩個門檻:過了 {PARAMS.threshold[1]} 才開始,降到 {PARAMS.doneAt} 才算做完;正在做的事另外加 {PARAMS.stayBonus} 分,別的事要明顯更急才能打斷它。 -**所有人同一分鐘做決定。** 小人不是每一幀都重新想。他們只在三個時候想:到了目的地、事情做完、或者每 {PARAMS.reconsiderMinutes} 分鐘一次。如果這 {PARAMS.reconsiderMinutes} 分鐘大家都從零點起算,三百個人會在同一分鐘一起出門,像有人在指揮。所以每個人的起算點是錯開的。 +**所有人同一分鐘做決定。** 小人不是每一幀都重新想。他們只在兩個時候想:事情做完,或者每 {PARAMS.reconsiderMinutes} 分鐘一次;到了目的地就開始做決定好的事,{PARAMS.reconsiderMinutes} 分鐘重新起算。如果這 {PARAMS.reconsiderMinutes} 分鐘大家都從零點起算,三百個人會在同一分鐘一起出門,像有人在指揮。所以每個人的起算點是錯開的。 ## 三種腦袋,同一雙腳 @@ -91,9 +91,9 @@ import { PARAMS } from "./components/sim/params"; - **固定時程**:所有人 {PARAMS.fsm.work} 點上班、{PARAMS.fsm.lunch} 點吃飯、{PARAMS.fsm.home} 點回家。 - **各看各的需求**:上一節那條式子。 -- **隨機**:隨便挑一個地方去,待半小時到兩小時。 +- **隨機**:隨便挑一個地方去,待半小時到兩小時;五次裡有一次什麼都不做。 - + @@ -101,7 +101,9 @@ import { PARAMS } from "./components/sim/params"; 隨機的一天是平的,半夜三點和下午三點長得一樣。 -中間那張有作息:夜裡大部分的人在睡,早上工作的青色慢慢漲上來,傍晚慢慢退掉,吃飯和社交散在整天。沒有人排這張班表,它是三百組稍微不一樣的數字各自算出來的結果。最擠的十分鐘只有 11% 的人同時出門,和隨機的 9% 差不多;但隨機沒有那個形狀。 +中間那張有作息:夜裡大部分的人在睡,早上工作的青色慢慢漲上來,傍晚慢慢退掉,吃飯和社交散在整天。沒有人排這張班表,它是三百組稍微不一樣的數字各自算出來的結果。最擠的十分鐘只有 12% 的人同時出門,和隨機的 10% 差不多;但隨機沒有那個形狀。 + +這個形狀有一部分是生理時鐘給的。把它取消,中間那張的夜晚就糊掉了:凌晨一點在家睡覺的人從 73% 掉到 38%,中午反而從 3% 升到 16%。四條需求一條都沒少,只是疲勞不再跟著太陽走。 把「待辦事項」整條關掉再量一次(離線量的:300 人、種子 1、跑五天取最後一天):睡覺、吃飯、社交的時間幾乎不變,空出來的時間從 11% 變成 40%。小人不會自己找事做,這是這個模型最誠實的地方:**它只會回應你給它的需求。** 想要他們買東西、運動,就得再加一條數字。 @@ -129,11 +131,11 @@ import { PARAMS } from "./components/sim/params"; **這不是在模擬人。** 四條需求是我選的,速度是我調到「看起來像一天」為止的。待辦事項從早上 {PARAMS.dutyHours[0]} 點就開始累積,是為了讓它在八點半左右過門檻;真的上班族不是這樣運作的。這個模型能說的只有一件事:不用時刻表,只靠幾條會漲會退的數字和一條打分數的式子,群體的節奏就會自己出現。 -**走路是畫出來的。** 小人的位置沿著路線前進,加上一點互相閃避,會被夾在人行道的範圍裡;彈跳和前傾只是繪圖。沒有車、沒有紅綠燈、沒有室內,做事的人就站在門口。小人畫成真人的兩倍大,不然從空中看只有兩個像素。 +**走路和聊天是畫出來的。** 小人的位置沿著路線前進,加上一點互相閃避,會被夾在人行道的範圍裡;擺手擺腳和彈跳只是繪圖。沒有車、沒有紅綠燈、沒有室內:模擬裡,上班、吃飯、睡覺的人就站在門口,畫面上才讓他走進門、做完再走出來。公園裡的人在模擬裡各站各的,畫面上會和最近的一個人湊成一對,面對面輪流說話。小人畫成真人的兩倍大,鏡頭拉遠時再放大一些,不然從空中看只有兩個像素。 **遊戲裡的版本多了什麼。** 真的 Utility AI 每個選項會有好幾個考量,各自過一條曲線(不一定是平方)再相乘;物件會自己「廣告」它能滿足什麼;還有個性、記憶、排隊、預約。這裡留下的是最小的骨架。 -數字的來源分三種。圖 02、03、04 和工作台上的讀數是你的瀏覽器現場算的;文中標明「我量過」的是離線跑的,種子、人數和腳本都在 `docs/research/city-of-agents/`;城市的棟數和節點數是種子 1、8 × 8 的生成結果,有測試保證同一個種子永遠一樣。 +數字的來源分三種。圖 01 底下的色帶、圖 02、03、04 和工作台上的讀數是你的瀏覽器現場算的;文中標明「離線量的」和旁註裡的第一版,種子、人數和腳本都在 `docs/research/city-of-agents/`;城市的棟數和節點數是種子 1、8 × 8 的生成結果,有測試保證同一個種子永遠一樣。 ## 來源 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 1042183..bbd6beb 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -67,6 +67,28 @@ stays in `lib/rt` with its tests. Read before touching it: - CI has no GPU. The light E2E tests take the "no adapter" branch; what needs a GPU was checked by hand in the Playwright MCP browser, whose own tab must be in front for pointer lock (a `newContext()` window is refused). +### № 015, the Starship landing — PAUSED, and not on any branch (2026-09-23) + +Paul read the draft, found the prose 不有趣又有點雜亂, paused it for Fable to write, and had `dev` reset back to +3cee702 — so none of it is on a branch. **It is all in the tag `rocket-draft-2026-09-23`** (pushed), thirteen +commits: the simulator, the packed Ship 24 geometry, the four-pilot figure, the network, both languages, the cover, +an E2E spec, `docs/research/rocket/RESULTS.md`, and paul-e9's `docs/research/rsa/` notes, which went with it. + +``` +git show rocket-draft-2026-09-23:docs/research/rocket/RESULTS.md # read one file +git checkout -b rocket rocket-draft-2026-09-23 # pick it up again +``` + +The one idea worth not losing: a Raptor spans 0.90–2.26 MN and the ship weighs 1.26 MN, so **one engine straddles +its own weight and two do not**. The pilot asks for a thrust and lights the fewest engines that can deliver it, and +a descent goes 0 → 1 → 2 → 1 on its own. Ten learned constants land 98 %; seven hundred weights copying them land +between 5 % and 98 % depending on where they started. + +What the read-through found, for whoever writes it: the three best moments (it cannot hover, the flip throws it +64 m sideways so it must aim off-target, the network hovering 3 m above the deck for ninety seconds) were all +buried in engineering notes; the engine-count thread is told three separate times; and the last three sections +share one shape — setup, table, moral. + ### The UI/UX batch (2026-09-23) paul-8b measured the whole site and Paul picked thirteen fixes; they are on dev as one commit each. Two he turned diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index b262855..443b12c 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -577,6 +577,15 @@ test("a city of people runs in the page: the clock moves, the three heads differ await expect(page.getByTestId("modes-fsm")).toContainText("100%", { timeout: 60_000 }); await expect(page.getByTestId("modes-utility")).toContainText("%", { timeout: 60_000 }); await expect(page.getByTestId("modes-utility")).not.toContainText("100%"); + // Without the body clock the needs head is run again, and its night smears out. + const asleep = page.getByTestId("modes-utility-asleep"), withClock = await asleep.textContent(); + await page.getByTestId("modes-clock").uncheck(); + await expect(asleep).not.toHaveText(withClock ?? "", { timeout: 60_000 }); + await expect(asleep).not.toContainText("—"); + + // Fig. 01 counts the day under the picture, indoors or not. + await page.getByTestId("city-canvas").first().scrollIntoViewIfNeeded(); + await expect(page.getByTestId("city-day").locator("path").first()).toBeAttached({ timeout: 60_000 }); // The Overseer: the table fills, a row follows a person, the record replays and comes back. await page.getByTestId("city-play").scrollIntoViewIfNeeded(); diff --git a/lib/three.ts b/lib/three.ts index 9197cfe..136b536 100644 --- a/lib/three.ts +++ b/lib/three.ts @@ -10,6 +10,7 @@ export { BoxGeometry, BufferAttribute, BufferGeometry, + CapsuleGeometry, CanvasTexture, CircleGeometry, Color, @@ -17,12 +18,14 @@ export { CylinderGeometry, DirectionalLight, DoubleSide, + Float32BufferAttribute, Fog, FogExp2, GridHelper, Group, HemisphereLight, IcosahedronGeometry, + InstancedBufferAttribute, InstancedMesh, Line, LineBasicMaterial, @@ -33,6 +36,7 @@ export { Mesh, MeshBasicMaterial, MeshStandardMaterial, + NeutralToneMapping, Object3D, PCFShadowMap, PerspectiveCamera, @@ -45,7 +49,9 @@ export { RingGeometry, RepeatWrapping, Scene, + ShaderMaterial, SphereGeometry, + SRGBColorSpace, Vector2, Vector3, WebGLRenderer, diff --git a/playwright.config.ts b/playwright.config.ts index 45f1aeb..6f7fdab 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,7 +7,8 @@ export default defineConfig({ testDir: "e2e", fullyParallel: true, reporter: "list", - use: { baseURL: `http://localhost:${PORT}`, locale: "zh-TW" }, + // A failure keeps a picture of the page: a CI-only failure (one run of two, never locally) can then be read, not guessed. + use: { baseURL: `http://localhost:${PORT}`, locale: "zh-TW", screenshot: "only-on-failure" }, projects: [ { name: "desktop", use: { ...devices["Desktop Chrome"] } }, { name: "mobile", use: { ...devices["Pixel 7"] } },