1
0

California gets roads, traffic, and a car to follow

This commit is contained in:
2026-08-11 18:24:53 -07:00
parent 9c9e78f6f9
commit fe58290728
43 changed files with 5593 additions and 68 deletions
+61 -6
View File
@@ -73,6 +73,20 @@ interface Box {
commercial: number;
}
/**
* A named building's claim on the anonymous city scatter, in scene units.
*
* Circles are deliberately conservative. Anonymous buildings rotate with
* their districts and named glyphs rotate with their streets; a circle is the
* one cheap overlap test that cannot leave a corner poking through, and there
* are only a handful of reservations to test.
*/
export interface BuildingReservation {
x: number;
z: number;
radius: number;
}
function polygonBounds(poly: [number, number][]) {
let minLat = Infinity;
let maxLat = -Infinity;
@@ -87,7 +101,10 @@ function polygonBounds(poly: [number, number][]) {
return { minLat, maxLat, minLng, maxLng };
}
export function createBlocks(world: World): THREE.InstancedMesh {
export function createBlocks(
world: World,
reservations: readonly BuildingReservation[] = [],
): THREE.InstancedMesh {
const boxes: Box[] = [];
let seedBase = 1337;
@@ -165,15 +182,40 @@ export function createBlocks(world: World): THREE.InstancedMesh {
// towers assemble their sites, and Salesforce Tower is about 5:1.
const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18;
const width = LOT * fill;
const depth = LOT * fill * (0.85 + rand() * 0.3);
const rotation = angle + (rand() - 0.5) * 0.03;
const color = new THREE.Color(
palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6,
);
/**
* Keep a named building legible instead of drawing it inside a random
* one at the same address.
*
* Every random property is drawn before this test. The sequence is
* load-bearing: skipping those calls for one reserved lot would
* reshuffle every anonymous building after it and turn a local change
* into a whole new skyline.
*/
const radius = Math.hypot(width, depth) / 2;
if (
reservations.some(
(reserved) => Math.hypot(x - reserved.x, z - reserved.z) < radius + reserved.radius,
)
) {
continue;
}
boxes.push({
x,
z,
y: world.groundAt(lat, lng),
w: LOT * fill,
d: LOT * fill * (0.85 + rand() * 0.3),
w: width,
d: depth,
h: world.metres(heightM),
rot: angle + (rand() - 0.5) * 0.03,
color: new THREE.Color(palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6),
rot: rotation,
color,
// A tower is an office whatever district it landed in.
commercial: Math.min(1, commercial + (isTower ? 0.4 : 0)),
});
@@ -230,12 +272,25 @@ export function createBlocks(world: World): THREE.InstancedMesh {
* specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
* Hill, the red tripod on the ridge — and a box would not do.
*/
export function createLandmarks(world: World): THREE.Group {
export function createLandmarks(
world: World,
reservations: readonly BuildingReservation[] = [],
): THREE.Group {
const group = new THREE.Group();
group.name = "landmarks";
for (const lm of world.city.landmarks) {
const [x, z] = world.project(lm.lat, lm.lng);
// A richer stable glyph at this address supersedes the coarse landmark
// primitive. Drawing both would hide the glyph inside the old mesh and
// leave two different sources claiming the same real building.
if (
reservations.some(
(reserved) => Math.hypot(x - reserved.x, z - reserved.z) < reserved.radius,
)
) {
continue;
}
const base = world.groundAt(lm.lat, lm.lng);
const h = world.metres(lm.height);
const w = lm.footprint * world.lngScale * 2;
+406
View File
@@ -0,0 +1,406 @@
/**
* Map-scale buildings for destinations that deserve more than a pin.
*
* This is not a general building generator and it intentionally loads no kit.
* At Tera's camera distances a one-metre façade module is sub-pixel; reproducing
* it as geometry would turn three destinations into thousands of triangles and
* several draw calls per material. What survives at map scale is the grammar:
* silhouette, repeated window bays, a distinct ground-floor door and a roof
* line. Those are generated here from a small data shape and one cached unit
* box, with all windows in one InstancedMesh.
*
* The placement idea was researched against achrefelouafi's MIT-licensed
* BasicProceduralBuilding, which classifies façade cells into ground, window,
* corner and roof pieces. No source or binary asset from that project is copied
* here: its GLB part kit and Blender-coordinate port solve a different-scale
* problem. See ASSET_RESEARCH.md.
*/
import * as THREE from "three";
import type { BuildingGlyph } from "./types.ts";
import type { World } from "./world.ts";
export interface BuildingGlyphHandle {
group: THREE.Group;
/** Solid shell pieces only; windows do not need thousands of ray targets. */
pickables: THREE.Object3D[];
/** World-space height above the group's ground datum. */
anchorY: number;
dispose(): void;
}
export interface BuildingSegment {
x: number;
z: number;
width: number;
depth: number;
base: number;
height: number;
}
interface WindowPlacement {
x: number;
y: number;
z: number;
width: number;
height: number;
yaw: number;
lit: boolean;
}
const DEFAULT_BODY = 0x8d9aa4;
const WINDOW_DAY = new THREE.Color(0x334c5d);
const WINDOW_LIT = new THREE.Color(0xd7c68d);
/**
* The silhouette in metres, kept pure so the contract can be unit-tested with
* no WebGL context. Segments never overlap on the same vertical plane except
* where a tower deliberately steps inward above its podium.
*/
export function layoutBuildingGlyph(glyph: BuildingGlyph): BuildingSegment[] {
const width = Math.max(4, glyph.width);
const depth = Math.max(4, glyph.depth);
const height = Math.max(3, glyph.height);
switch (glyph.profile) {
case "tower": {
const podium = height * 0.13;
const shaft = height * 0.72;
return [
{ x: 0, z: 0, width, depth, base: 0, height: podium },
{
x: 0,
z: 0,
width: width * 0.74,
depth: depth * 0.78,
base: podium,
height: shaft,
},
{
x: 0,
z: 0,
width: width * 0.56,
depth: depth * 0.6,
base: podium + shaft,
height: height - podium - shaft,
},
];
}
case "courtyard": {
const wing = Math.max(3.5, Math.min(width, depth) * 0.28);
return [
{ x: 0, z: -(depth - wing) / 2, width, depth: wing, base: 0, height },
{ x: 0, z: (depth - wing) / 2, width, depth: wing, base: 0, height },
{
x: -(width - wing) / 2,
z: 0,
width: wing,
depth: Math.max(wing, depth - wing * 2),
base: 0,
height,
},
{
x: (width - wing) / 2,
z: 0,
width: wing,
depth: Math.max(wing, depth - wing * 2),
base: 0,
height,
},
];
}
case "hangar":
return [{ x: 0, z: 0, width, depth, base: 0, height: height * 0.72 }];
case "block":
default:
return [{ x: 0, z: 0, width, depth, base: 0, height }];
}
}
export function createBuildingGlyph(
world: World,
glyph: BuildingGlyph,
accentColor: number,
): BuildingGlyphHandle {
const group = new THREE.Group();
group.name = `building-glyph:${glyph.profile}`;
// A positive three.js yaw turns local north west. Compass headings increase
// eastward, so the sign is reversed once at the world/building boundary.
const heading = Number.isFinite(glyph.heading) ? glyph.heading : 0;
group.rotation.y = -(heading * Math.PI) / 180;
const pickables: THREE.Object3D[] = [];
const segments = layoutBuildingGlyph(glyph);
const horizontal = 1 / world.metresPerUnit;
const vertical = (m: number) => world.metres(m);
const bodyColor = new THREE.Color(glyph.bodyColor ?? DEFAULT_BODY);
const body = new THREE.MeshLambertMaterial({ color: bodyColor });
body.name = "building shell";
const trim = new THREE.MeshLambertMaterial({ color: bodyColor.clone().multiplyScalar(0.72) });
trim.name = "building roof and trim";
const glass = new THREE.MeshLambertMaterial({
// Instanced colours are multiplied by the base material colour, so white
// is the neutral carrier for the cool and warm pane colours below.
color: 0xffffff,
emissive: new THREE.Color(accentColor).multiplyScalar(0.34),
emissiveIntensity: 0.36,
});
glass.name = "building windows";
const accent = new THREE.MeshLambertMaterial({
color: accentColor,
emissive: accentColor,
emissiveIntensity: 0.42,
});
accent.name = "building door";
const unit = new THREE.BoxGeometry(1, 1, 1);
unit.translate(0, 0.5, 0);
const windows: WindowPlacement[] = [];
const random = mulberry32(glyph.seed ?? hashGlyph(glyph));
for (const segment of segments) {
const shell = new THREE.Mesh(unit, body);
shell.name = "building shell segment";
shell.position.set(segment.x * horizontal, vertical(segment.base), segment.z * horizontal);
shell.scale.set(segment.width * horizontal, vertical(segment.height), segment.depth * horizontal);
shell.castShadow = true;
shell.receiveShadow = true;
group.add(shell);
pickables.push(shell);
// A slightly proud cap makes each setback legible from above, where the
// camera spends nearly all its time. It is one centimetre in the data and a
// deliberately larger fraction of a pixel after vertical exaggeration.
const cap = new THREE.Mesh(unit, trim);
cap.name = "building roof line";
cap.position.set(
segment.x * horizontal,
vertical(segment.base + segment.height),
segment.z * horizontal,
);
cap.scale.set(
(segment.width + 0.7) * horizontal,
Math.max(0.012, vertical(0.28)),
(segment.depth + 0.7) * horizontal,
);
cap.castShadow = true;
group.add(cap);
pickables.push(cap);
collectWindows(windows, segment, glyph, horizontal, vertical, random);
}
const windowGeometry = new THREE.BoxGeometry(1, 1, 1);
const windowMesh = new THREE.InstancedMesh(windowGeometry, glass, windows.length);
windowMesh.name = "building window bays";
windowMesh.castShadow = false;
windowMesh.receiveShadow = false;
const matrix = new THREE.Matrix4();
const quaternion = new THREE.Quaternion();
const position = new THREE.Vector3();
const scale = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
windows.forEach((window, i) => {
position.set(window.x, window.y, window.z);
quaternion.setFromAxisAngle(up, window.yaw);
scale.set(window.width, window.height, 0.012);
matrix.compose(position, quaternion, scale);
windowMesh.setMatrixAt(i, matrix);
// A few warm panes stop the repeated grid reading as graph paper. The
// choice is seeded, so the same office has the same lights after a reload.
windowMesh.setColorAt(i, window.lit ? WINDOW_LIT : WINDOW_DAY);
});
windowMesh.instanceMatrix.needsUpdate = true;
windowMesh.instanceColor!.needsUpdate = true;
windowMesh.computeBoundingSphere();
group.add(windowMesh);
let gable: THREE.BufferGeometry | null = null;
if (glyph.profile === "hangar") {
const bodyHeight = glyph.height * 0.72;
const rise = glyph.height - bodyHeight;
// The triangular prism completes the upper 28% of the silhouette; its two
// sloped faces are what turn a low grey block into a hangar at map scale.
gable = gableGeometry(
Math.max(4, glyph.width) * horizontal,
Math.max(4, glyph.depth) * horizontal,
vertical(rise),
);
const roof = new THREE.Mesh(gable, trim);
roof.name = "hangar roof";
roof.position.y = vertical(bodyHeight);
roof.castShadow = true;
roof.receiveShadow = true;
group.add(roof);
pickables.push(roof);
}
// One bright address on the south/front façade. It is navigation, not a
// physically accurate entrance schedule, and stays readable after the
// repeated bays have merged into their average at distance.
const first = segments[0] as BuildingSegment;
const door = new THREE.Mesh(unit, accent);
door.name = "building entrance";
const doorWidth = glyph.profile === "hangar" ? first.width * 0.42 : Math.min(5, first.width * 0.22);
const doorHeight = glyph.profile === "hangar" ? first.height * 0.6 : Math.min(5, first.height * 0.55);
door.position.set(
first.x * horizontal,
vertical(first.base),
(first.z + first.depth / 2 + 0.18) * horizontal,
);
door.scale.set(doorWidth * horizontal, vertical(doorHeight), 0.028);
door.castShadow = false;
group.add(door);
pickables.push(door);
const anchorY = vertical(glyph.height) + Math.max(0.18, vertical(2));
return {
group,
pickables,
anchorY,
dispose() {
windowMesh.dispose();
unit.dispose();
windowGeometry.dispose();
gable?.dispose();
body.dispose();
trim.dispose();
glass.dispose();
accent.dispose();
pickables.length = 0;
group.clear();
},
};
}
function collectWindows(
out: WindowPlacement[],
segment: BuildingSegment,
glyph: BuildingGlyph,
horizontal: number,
vertical: (m: number) => number,
random: () => number,
) {
const floorShare = segment.height / Math.max(1, glyph.height);
const rows =
glyph.profile === "hangar"
? 1
: Math.max(1, Math.min(18, Math.round(Math.max(1, glyph.storeys) * floorShare)));
const rowPitch = segment.height / (rows + 0.35);
const windowHeightM = Math.max(1.1, rowPitch * (glyph.profile === "tower" ? 0.5 : 0.58));
const y0 = segment.base + rowPitch * 0.6;
const addFacade = (
spanM: number,
face: "north" | "south" | "east" | "west",
) => {
const cols = Math.max(2, Math.min(10, Math.round(spanM / 6.5)));
const bay = spanM / cols;
const width = bay * (0.5 + random() * 0.1) * horizontal;
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < cols; col += 1) {
const along = -spanM / 2 + bay * (col + 0.5);
const y = vertical(y0 + rowPitch * row + windowHeightM / 2);
const lit = random() > 0.77;
switch (face) {
case "north":
out.push({
x: (segment.x + along) * horizontal,
y,
z: (segment.z - segment.depth / 2 - 0.08) * horizontal,
width,
height: vertical(windowHeightM),
yaw: 0,
lit,
});
break;
case "south":
out.push({
x: (segment.x - along) * horizontal,
y,
z: (segment.z + segment.depth / 2 + 0.08) * horizontal,
width,
height: vertical(windowHeightM),
yaw: Math.PI,
lit,
});
break;
case "east":
out.push({
x: (segment.x + segment.width / 2 + 0.08) * horizontal,
y,
z: (segment.z + along) * horizontal,
width,
height: vertical(windowHeightM),
yaw: Math.PI / 2,
lit,
});
break;
case "west":
out.push({
x: (segment.x - segment.width / 2 - 0.08) * horizontal,
y,
z: (segment.z - along) * horizontal,
width,
height: vertical(windowHeightM),
yaw: -Math.PI / 2,
lit,
});
break;
}
}
}
};
addFacade(segment.width, "north");
addFacade(segment.width, "south");
addFacade(segment.depth, "east");
addFacade(segment.depth, "west");
}
/** A triangular prism whose ridge runs along local X. */
function gableGeometry(width: number, depth: number, rise: number): THREE.BufferGeometry {
const x = width / 2;
const z = depth / 2;
const positions = new Float32Array([
-x, 0, -z,
-x, 0, z,
-x, rise, 0,
x, 0, -z,
x, 0, z,
x, rise, 0,
]);
const indices = [
0, 1, 2,
3, 5, 4,
0, 3, 4, 0, 4, 1,
1, 4, 5, 1, 5, 2,
2, 5, 3, 2, 3, 0,
];
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
return geometry;
}
function hashGlyph(glyph: BuildingGlyph): number {
const text = `${glyph.profile}:${glyph.width}:${glyph.depth}:${glyph.height}:${glyph.heading}`;
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i += 1) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
function mulberry32(seed: number): () => number {
let value = seed >>> 0;
return () => {
value = (value + 0x6d2b79f5) >>> 0;
let next = Math.imul(value ^ (value >>> 15), 1 | value);
next = (next + Math.imul(next ^ (next >>> 7), 61 | next)) ^ next;
return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
};
}
+21
View File
@@ -10,6 +10,7 @@
*/
import * as THREE from "three";
import { createBuildingGlyph, type BuildingGlyphHandle } from "./buildingGlyph.ts";
import type { Marker, MarkerPalette } from "./types.ts";
import type { World } from "./world.ts";
@@ -38,6 +39,7 @@ export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerL
group.name = "markers";
const pickables: THREE.Object3D[] = [];
const anchors = new Map<string, THREE.Vector3>();
const buildings: BuildingGlyphHandle[] = [];
// One shared geometry per shape; colour varies per instance material, which
// is cheap enough at the scale markers live at (hundreds, not tens of
@@ -61,6 +63,8 @@ export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerL
};
function clear() {
for (const building of buildings) building.dispose();
buildings.length = 0;
for (const child of [...group.children]) group.remove(child);
pickables.length = 0;
anchors.clear();
@@ -73,6 +77,23 @@ export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerL
const [x, z] = world.project(m.lat, m.lng);
const base = world.groundAt(m.lat, m.lng);
if (located && m.glyph?.kind === "building") {
const building = createBuildingGlyph(
world,
m.glyph,
palette[m.colorKey] ?? FALLBACK_COLOR,
);
building.group.position.set(x, base, z);
for (const target of building.pickables) {
target.userData.marker = m;
pickables.push(target);
}
anchors.set(m.id, new THREE.Vector3(x, base + building.anchorY, z));
buildings.push(building);
group.add(building.group);
continue;
}
const pin = new THREE.Group();
pin.position.set(x, base + PIN_LIFT, z);
+256
View File
@@ -0,0 +1,256 @@
/**
* The road-traffic rendering layer.
*
* Simulation remains geographic and three-free in `transport/vehicleSim.ts`.
* This layer projects those poses onto one `World`, draws one articulated hero
* car, and batches every background car into instanced asset parts. Route
* switching and follow-camera state are imperative because they are viewer
* choices, not properties of the open transport pack.
*/
import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import {
buildModelX,
disposeModelX,
modelXInstanceParts,
setModelXSteering,
setModelXWheelRotation,
} from "../assets/vehicles/index.ts";
import type { TransportPack } from "../transport/types.ts";
import {
NEUTRAL_VEHICLE_ACTIONS,
VehicleController,
normalizeVehicleActions,
type VehicleActionSnapshot,
type VehicleControllerState,
} from "../transport/vehicleController.ts";
import { VehicleSimulation, type VehiclePose } from "../transport/vehicleSim.ts";
import type { World } from "./world.ts";
export interface RoadTrafficOptions {
pack: TransportPack;
routeId: string;
count?: number;
seed?: number;
/** Vehicle metres to scene units. State-scale cars are intentional glyphs. */
scale?: number;
/** Route-distance compression for playable corridor travel. Defaults to 900. */
travelScale?: number;
}
export interface RoadTrafficLayer {
group: THREE.Group;
routeId(): string;
setRoute(routeId: string): void;
setFollowing(following: boolean): void;
following(): boolean;
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
setCameraMode(mode: VehicleCameraMode): void;
cameraMode(): VehicleCameraMode;
hero(): Readonly<VehicleControllerState>;
tick(dt: number): void;
dispose(): void;
}
export type VehicleCameraMode = "chase" | "driver";
interface BatchPart {
mesh: THREE.InstancedMesh;
local: THREE.Matrix4;
}
type RenderPose = Pick<VehiclePose, "lat" | "lng" | "headingDeg" | "wheelRadians"> & {
lane?: number;
lateralOffsetM?: number;
};
export function createRoadTrafficLayer(
world: World,
camera: THREE.PerspectiveCamera,
controls: OrbitControls,
options: RoadTrafficOptions,
): RoadTrafficLayer {
const group = new THREE.Group();
group.name = "road-traffic";
const heroScale = options.scale ?? 0.18;
// Background traffic is deliberately quieter. One oversized convoy reads as
// map symbols; one detailed hero against smaller traffic reads as a camera.
const backgroundScale = heroScale * 0.62;
const count = Math.max(2, Math.min(40, Math.floor(options.count ?? 14)));
const simulation = new VehicleSimulation(options.pack, {
routeId: options.routeId,
count,
seed: options.seed,
});
const controller = new VehicleController(options.pack, {
routeId: options.routeId,
mode: "assisted",
initialSpeedMps: 24,
travelScale: options.travelScale ?? 900,
});
// One articulated, higher-detail car for the follow camera.
const heroRig = buildModelX({ detail: "follow" });
heroRig.root.name = "model-x-hero";
group.add(heroRig.root);
// Background traffic is one draw call per asset part, not per car. The
// neutral prototype itself is never attached to the scene.
const backgroundPrototype = buildModelX({ detail: "corridor" });
const backgroundCount = count - 1;
const batches: BatchPart[] = modelXInstanceParts(backgroundPrototype).map((part) => {
const material = Array.isArray(part.material) ? part.material[0] : part.material;
if (!material) throw new Error(`road traffic: asset part "${part.name}" has no material`);
const mesh = new THREE.InstancedMesh(part.geometry, material, backgroundCount);
mesh.name = `traffic:${part.name}`;
mesh.castShadow = part.castShadow;
mesh.receiveShadow = part.receiveShadow;
mesh.frustumCulled = false;
group.add(mesh);
return { mesh, local: part.matrix };
});
const rootMatrix = new THREE.Matrix4();
const instanceMatrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scaleVector = new THREE.Vector3(backgroundScale, backgroundScale, backgroundScale);
const yawEuler = new THREE.Euler(0, 0, 0, "YXZ");
const followPosition = new THREE.Vector3();
const followTarget = new THREE.Vector3();
const followOffset = new THREE.Vector3();
let isFollowing = false;
let activeCameraMode: VehicleCameraMode = "chase";
let vehicleActions: VehicleActionSnapshot = { ...NEUTRAL_VEHICLE_ACTIONS };
function scenePose(pose: RenderPose, out: THREE.Vector3): THREE.Vector3 {
const [x, z] = world.project(pose.lat, pose.lng);
const heading = (pose.headingDeg * Math.PI) / 180;
// Lane 0 hugs the median. Southbound traffic's right side naturally moves
// to the other carriageway because its heading is reversed.
const laneOffset =
0.2 + (pose.lane ?? 0) * 0.2 + (pose.lateralOffsetM ?? 0) * 0.018;
out.set(
x + Math.cos(heading) * laneOffset,
// The asset origin is on the tyre contact plane. Keep only a tiny lift
// above the generated road to avoid z-fighting; `0.22` here made the
// vehicle hover more than its own rendered height at corridor scale.
world.groundAt(pose.lat, pose.lng) + 0.155,
z + Math.sin(heading) * laneOffset,
);
return out;
}
function applyRoot(pose: RenderPose, steering = 0): void {
scenePose(pose, position);
heroRig.root.position.copy(position);
heroRig.root.rotation.set(0, (-pose.headingDeg * Math.PI) / 180, 0);
heroRig.root.scale.setScalar(heroScale);
setModelXWheelRotation(heroRig, -pose.wheelRadians);
setModelXSteering(heroRig, steering);
}
function applyBatches(poses: readonly VehiclePose[]): void {
for (let index = 1; index < poses.length; index += 1) {
const pose = poses[index];
if (!pose) continue;
scenePose(pose, position);
yawEuler.set(0, (-pose.headingDeg * Math.PI) / 180, 0);
quaternion.setFromEuler(yawEuler);
rootMatrix.compose(position, quaternion, scaleVector);
for (const batch of batches) {
instanceMatrix.multiplyMatrices(rootMatrix, batch.local);
batch.mesh.setMatrixAt(index - 1, instanceMatrix);
}
}
for (const batch of batches) batch.mesh.instanceMatrix.needsUpdate = true;
}
function applyFollow(pose: RenderPose, dt: number): void {
const heading = (pose.headingDeg * Math.PI) / 180;
const forwardX = Math.sin(heading);
const forwardZ = -Math.cos(heading);
if (activeCameraMode === "driver") {
// A hood/driver-height view. The procedural corridor asset has no cabin
// texture to hide, so the camera sits just above its glass and looks far
// enough ahead that route curvature reads before the car reaches it.
followTarget
.copy(heroRig.root.position)
.add(followOffset.set(forwardX * 2.2, 0.14, forwardZ * 2.2));
followPosition
.copy(heroRig.root.position)
.add(followOffset.set(forwardX * 0.03, 0.32, forwardZ * 0.03));
} else {
const rightX = Math.cos(heading);
const rightZ = Math.sin(heading);
followTarget
.copy(heroRig.root.position)
.add(followOffset.set(forwardX * 0.32, 0.1, forwardZ * 0.32));
followPosition
.copy(heroRig.root.position)
.add(
followOffset.set(
-forwardX * 0.9 - rightX * 0.3,
0.4,
-forwardZ * 0.9 - rightZ * 0.3,
),
);
}
const blend = 1 - Math.exp(-Math.max(0, dt) * 4.5);
camera.position.lerp(followPosition, blend);
controls.target.lerp(followTarget, blend);
camera.lookAt(controls.target);
}
function refresh(dt: number): void {
const poses = simulation.poses();
const hero = controller.state();
applyRoot(hero, hero.steering * 0.55);
applyBatches(poses);
if (isFollowing) applyFollow(hero, dt);
}
refresh(0);
return {
group,
routeId: () => simulation.routeId(),
setRoute(routeId) {
simulation.setRoute(routeId);
controller.setRoute(routeId);
refresh(0);
},
setFollowing(following) {
isFollowing = following;
controls.enabled = !following;
if (following) refresh(1);
},
following: () => isFollowing,
setVehicleActions(actions) {
vehicleActions = normalizeVehicleActions(actions);
},
setCameraMode(mode) {
activeCameraMode = mode;
if (isFollowing) refresh(1);
},
cameraMode: () => activeCameraMode,
hero: () => controller.state(),
tick(dt) {
simulation.tick(dt);
controller.tick(dt, vehicleActions);
// Mode/reset requests are edges. Analogue axes remain held until the
// input adapter publishes a changed snapshot.
vehicleActions.modeRequest = "none";
vehicleActions.reset = false;
refresh(dt);
},
dispose() {
controls.enabled = true;
for (const batch of batches) group.remove(batch.mesh);
group.remove(heroRig.root);
disposeModelX(backgroundPrototype);
disposeModelX(heroRig);
},
};
}
+61 -4
View File
@@ -28,7 +28,7 @@
*/
import * as THREE from "three";
import { createBlocks, createLandmarks } from "./blocks.ts";
import { createBlocks, createLandmarks, type BuildingReservation } from "./blocks.ts";
import { createNightLights, type NightLights } from "./nightlights.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
@@ -41,7 +41,17 @@ import {
type SatelliteLayer,
} from "./satellites.ts";
import { createSceneKit, type Pose } from "./scenekit.ts";
import {
createRoadTrafficLayer,
type RoadTrafficLayer,
type RoadTrafficOptions,
type VehicleCameraMode,
} from "./roadTraffic.ts";
import type { Stage, StageScene } from "./stage.ts";
import type {
VehicleActionSnapshot,
VehicleControllerState,
} from "../transport/vehicleController.ts";
import { createBridges, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type {
@@ -58,6 +68,17 @@ import { World, type FieldProgress } from "./world.ts";
export interface SceneOptions {
city: City;
markerPalette?: MarkerPalette;
/**
* Markers available at construction time.
*
* Building glyphs in this first set reserve their footprints in the
* anonymous block scatter. Later `setMarkers()` calls remain cheap and do
* not rebuild a city, so callers should put stable destinations here and use
* updates for genuinely live marker feeds.
*/
markers?: Marker[];
/** Optional deterministic road traffic for a state/corridor-scale board. */
roadTraffic?: RoadTrafficOptions;
flights?: FlightSource;
/**
* Element sets to propagate, if this deployment has any.
@@ -148,6 +169,12 @@ export interface SceneHandle {
flyTo(chapterId: string): void;
current(): string;
onChapterChange(fn: (id: string) => void): void;
/** Device-neutral input for the corridor hero; a no-op on boards without one. */
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
/** Current playable corridor state, or null on a city-scale board. */
vehicleState(): Readonly<VehicleControllerState> | null;
setVehicleCamera(mode: VehicleCameraMode): void;
vehicleCamera(): VehicleCameraMode | null;
setMarkers(markers: Marker[]): void;
/** Take this city off the stage and release everything it built. */
dispose(): void;
@@ -283,9 +310,19 @@ export async function createScene(
scene.add(createShorePlates(world));
scene.add(createTerrain(world));
scene.add(createRoads(world));
const blocks = createBlocks(world);
const buildingReservations: BuildingReservation[] = [];
for (const marker of options.markers ?? []) {
const glyph = marker.glyph;
if (marker.located === false || glyph?.kind !== "building") continue;
const [x, z] = world.project(marker.lat, marker.lng);
// Five metres of breathing room keeps an anonymous wall from sitting
// exactly on the authored façade after both reservation circles touch.
const radius = (Math.hypot(glyph.width, glyph.depth) / 2 + 5) / world.metresPerUnit;
buildingReservations.push({ x, z, radius });
}
const blocks = createBlocks(world, buildingReservations);
scene.add(blocks);
scene.add(createLandmarks(world));
scene.add(createLandmarks(world, buildingReservations));
scene.add(createBridges(world));
/**
@@ -300,8 +337,14 @@ export async function createScene(
scene.add(clouds.group);
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
markerLayer.setMarkers(options.markers ?? []);
scene.add(markerLayer.group);
const roadTraffic: RoadTrafficLayer | null = options.roadTraffic
? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
: null;
if (roadTraffic) scene.add(roadTraffic.group);
let flightLayer: FlightLayer | null = null;
let flightTimer = 0;
if (options.flights) {
@@ -365,7 +408,15 @@ export async function createScene(
function flyTo(chapterId: string) {
const ch = chapterById[chapterId];
if (!ch) return;
kit.flyTo(chapterPose(ch));
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
if (route && roadTraffic) {
roadTraffic.setRoute(route.id);
roadTraffic.setFollowing(true);
} else {
roadTraffic?.setFollowing(false);
roadTraffic?.setVehicleActions({});
kit.flyTo(chapterPose(ch));
}
if (currentChapter !== chapterId) {
currentChapter = chapterId;
for (const fn of chapterListeners) fn(chapterId);
@@ -396,6 +447,7 @@ export async function createScene(
onExit: () => kit.resetPick(),
tick(dt) {
kit.tick(dt);
roadTraffic?.tick(dt);
clouds.tick(dt);
if (options.flights && flightLayer) {
flightTimer -= dt;
@@ -444,6 +496,7 @@ export async function createScene(
clouds.dispose();
nightLights.dispose();
markerLayer.dispose();
roadTraffic?.dispose();
kit.dispose();
scene.traverse((obj) => {
const mesh = obj as THREE.Mesh;
@@ -481,6 +534,10 @@ export async function createScene(
onChapterChange(fn) {
chapterListeners.push(fn);
},
setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
vehicleState: () => roadTraffic?.hero() ?? null,
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
setMarkers(markers) {
markerLayer.setMarkers(markers);
},
+54 -2
View File
@@ -12,7 +12,7 @@ import type { Bridge, LatLng } from "./types.ts";
import type { World } from "./world.ts";
/** Resample a lat/lng path into scene-space points that ride the ground. */
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.05): THREE.Vector3[] {
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14): THREE.Vector3[] {
const out: THREE.Vector3[] = [];
for (let i = 0; i < path.length - 1; i++) {
const from = path[i];
@@ -40,12 +40,64 @@ function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Me
return mesh;
}
/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
function roadRibbon(
points: readonly THREE.Vector3[],
width: number,
color: number,
lift = 0,
): THREE.Mesh {
const positions: number[] = [];
const normals: number[] = [];
const indices: number[] = [];
const half = width / 2;
for (let index = 0; index < points.length; index += 1) {
const point = points[index];
const previous = points[Math.max(0, index - 1)];
const next = points[Math.min(points.length - 1, index + 1)];
if (!point || !previous || !next) continue;
const dx = next.x - previous.x;
const dz = next.z - previous.z;
const length = Math.hypot(dx, dz) || 1;
const nx = -dz / length;
const nz = dx / length;
positions.push(
point.x + nx * half, point.y + lift, point.z + nz * half,
point.x - nx * half, point.y + lift, point.z - nz * half,
);
normals.push(0, 1, 0, 0, 1, 0);
if (index < points.length - 1) {
const a = index * 2;
indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide }),
);
mesh.receiveShadow = true;
return mesh;
}
export function createRoads(world: World): THREE.Group {
const group = new THREE.Group();
group.name = "roads";
for (const road of world.city.roads) {
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
group.add(ribbon(drapePath(world, road.path), road.width, color));
const path = drapePath(world, road.path);
group.add(roadRibbon(path, road.width, color));
if (road.kind === "freeway") {
// One warm median stroke is enough at corridor scale to read as divided
// highway without spending a textured asset or a draw call per lane.
group.add(roadRibbon(path, Math.max(0.025, road.width * 0.035), 0xd7c27c, 0.012));
}
}
return group;
}
+33
View File
@@ -255,6 +255,37 @@ export interface Pin {
blurb?: string;
}
/**
* A small, map-scale building drawn in place of a pin.
*
* This is deliberately a glyph rather than an architectural model. A city is
* normally viewed from kilometres away, so loading a façade kit with one mesh
* per window buys triangles nobody can see and gives up the one-draw-call city
* that `blocks.ts` works hard to preserve. The glyph keeps the useful grammar
* — a ground floor, repeated bays, a roof line and a deterministic silhouette
* — and expresses it in a handful of procedural meshes.
*
* Metres are used here because these values describe a real building even
* though the city scene does not: `World` converts horizontal metres with
* `metresPerUnit` and vertical metres with the city's exaggeration.
*/
export interface BuildingGlyph {
kind: "building";
width: number;
depth: number;
height: number;
/** Approximate occupied floors; used to choose the façade rhythm. */
storeys: number;
/** Compass bearing of local Z, degrees clockwise from true north. */
heading: number;
/** The silhouette family, not a tenant or product category. */
profile: "tower" | "hangar" | "courtyard" | "block";
/** Stable variation for bay widths and lit panes. */
seed?: number;
/** Neutral shell colour. The marker palette still supplies the door/accent. */
bodyColor?: number;
}
/** A `Pin` placed on a city, in degrees. */
export interface Marker extends Pin {
lat: number;
@@ -265,6 +296,8 @@ export interface Marker extends Pin {
* is that it is real is worse than admitting the gap.
*/
located?: boolean;
/** Optional map-scale representation. Omit it for the ordinary pin. */
glyph?: BuildingGlyph;
}
/** Caller-supplied `colorKey` -> colour. */