af0d4a7d57
**Lights.** A sited office follows the real sun, and the real sun spends
half its time below the horizon — which was producing a technically
correct and completely useless picture: an unlit floor plate at midnight
in a building whose whole premise is that you can see who is at which
desk. `luminaires.ts` brings the diffusers up as the sun goes down and
reports one scalar for how much interior light there is; `withHouseLights`
adds it to the rig. CONTRACT §4's rule that a fitting emits no light is
kept in full — nothing here is a light source, and the rig still has one
owner.
**And they notice you.** A fitting within four metres of somebody walking
underneath brightens and fades back as they leave, which is what an
occupancy-sensed floor actually does at night. They are one `InstancedMesh`
sharing one material, so `emissiveIntensity` cannot vary between them —
`instanceColor` can, but three multiplies it into the diffuse term only, so
six lines of `onBeforeCompile` carry it into the emissive term as well. The
alternative was one mesh per fitting: forty draw calls of ceiling in a
building that spends about twenty on everything.
**Optimus.** A posable Gen-3 humanoid — eleven articulating joints, pale
shells over a dark frame, a black visor — with a walk cycle driven by
*distance travelled* rather than wall-clock, so the feet do not slide when
a robot slows down. Two per floor, derived from the pack's levels, so the
two-storey tower gets four and the hangar gets two without either pack
knowing robots exist. They wander between reachable points using
`Plan.blocked` — the collider the wall split already produces — and they
are deliberately **not** gated on `depth`: the build-time-exclusion rule is
about occupancy, and a robot is nobody.
**Starlinks stop being pixels.** The sixty-four nearest the centre of view
grow real geometry — a flat bus with ONE large solar array, which is the
actual signature and the thing everybody draws symmetrically and wrong —
fading in so there is no pop where a point becomes a mesh. Two draw calls.
The sun for their attitude comes from `solar.ts` and not from the rig,
because `atmosphere.ts` floors the light direction to keep the shadow
camera usable, and a sun ten degrees *down* is exactly the dusk geometry
that makes a pass visible.
**Aircraft** are airliners now — swept wings, nacelles, a fin — instead of
an arrowhead, still one shared geometry facing +Z as `flights.ts` requires.
**Clouds** drift over the board, driven by observed cover, lit by the rig
rather than by themselves.
Four modules were built by subagents and reviewed by another; every one
came back `needs-work` and the reviews were right. Fixed before wiring:
- The walk cycle's arms were a quarter cycle out of step with its legs —
the legs are cosine-shaped and the arms were on `sin`, so at the
instant the left leg reached full forward the left shoulder was at dead
neutral. Uncanny, and hard to name until it is pointed at.
- Every Optimus shell used a `roundedBox` radius of 0.12–0.22, which that
primitive turns into a near-circular cross-section — the figure was
built out of lozenges, not panels. The rest of the library uses
0.02–0.09.
- The cloud material was `transparent` + `DoubleSide` without
`forceSinglePass`, so three rendered it twice per frame *and* bumped
`material.version` on each pass — rebuilding the program cache key
forever, on the one layer that is fill-rate bound.
- `starlinkMesh.dispose()` freed the geometries but not the
`InstancedMesh`es, orphaning their instance buffers on every city
switch.
- The airliner's tailplane roots sat outside the tail cone and hung in
free air over most of their chord.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
244 lines
10 KiB
TypeScript
244 lines
10 KiB
TypeScript
/**
|
|
* Everything in the rooms: the props a pack placed by hand and the ones its
|
|
* desk banks generated, built once per kind and drawn as instances.
|
|
*
|
|
* ### Why this is instanced per kind rather than merged
|
|
*
|
|
* The city solves the opposite problem. `blocks.ts` has 24,000 identical boxes
|
|
* and one geometry, so a single `InstancedMesh` is the whole answer. An office
|
|
* has roughly 180 objects across two dozen *distinct shapes* — twelve of one
|
|
* desk, forty of one chair, one sofa, one plant — and neither extreme works: one
|
|
* instanced mesh cannot hold two shapes, and merging everything into one buffer
|
|
* throws away the sharing that makes forty chairs cost what one chair costs.
|
|
*
|
|
* So the unit of batching is the **(asset, material) pair**. Forty task chairs
|
|
* across four materials is four draw calls and four geometries no matter how
|
|
* many chairs there are, and a floor of 180 props typically lands somewhere
|
|
* around thirty of each. That is the same order as `parts.ts` merging one
|
|
* asset's boxes, one level up.
|
|
*
|
|
* ### The price, which is worth naming
|
|
*
|
|
* An instance is a matrix and nothing else, so **every instance of a kind is
|
|
* geometrically identical**. `ctx.rand` — the book angles on a shelf, the sag of
|
|
* a cushion — is drawn once per kind here, not once per prop, which means twelve
|
|
* shelves have the same books on them. That is the cost of the batching, it is
|
|
* paid knowingly, and the alternative is twelve times the geometry for jitter
|
|
* nobody looks for. What still varies per prop is position, rotation and scale,
|
|
* which is what the eye actually reads.
|
|
*
|
|
* A `colorKey` is part of the batch key rather than a per-instance colour: it
|
|
* changes which *material* the asset picks up, and two colours of chair are two
|
|
* batches. `InstancedMesh.setColorAt` would have been one, but it multiplies the
|
|
* material's colour rather than replacing it, and it cannot reach the second
|
|
* material on the same chair.
|
|
*/
|
|
|
|
import * as THREE from "three";
|
|
import type { MaterialRegistry } from "../assets/materials.ts";
|
|
import { createAssetContext, kit, type AssetRegistry } from "../assets/kit.ts";
|
|
import type { LevelPlan, Plan, PropPlacement } from "./plan.ts";
|
|
|
|
export interface FurnishOptions {
|
|
materials: MaterialRegistry;
|
|
/** Defaults to the shared `kit`, which is where the built-in assets register. */
|
|
registry?: AssetRegistry;
|
|
/**
|
|
* Resolve a `Prop.colorKey` to a colour. Supplied by the caller exactly as a
|
|
* `MarkerPalette` is, and for the same reason: nothing in here will learn what
|
|
* a colour key means (ARCHITECTURE.md §3.3).
|
|
*/
|
|
colorFor?: (key: string) => number | undefined;
|
|
/** Which levels to furnish. Defaults to every level in the plan. */
|
|
levelIds?: readonly string[];
|
|
/** Seeds the per-kind randomness. Change it to reshuffle the whole office. */
|
|
seed?: number;
|
|
}
|
|
|
|
/**
|
|
* One batch of light fittings, with the world position of every fitting in it.
|
|
*
|
|
* Handed out so something above can turn the lights on — see `luminaires.ts`.
|
|
* The positions are the *placements*, which this file already had to compute and
|
|
* which nothing else can recover: an `InstancedMesh` holds composed matrices,
|
|
* and picking a translation back out of one per frame to answer "is anybody
|
|
* standing under this lamp" would be reading the answer out of the thing that
|
|
* was built from it.
|
|
*/
|
|
export interface LuminaireBatch {
|
|
mesh: THREE.InstancedMesh;
|
|
/** World position of instance `i`, at the fitting's mounting plane. */
|
|
positions: THREE.Vector3[];
|
|
/** True for the glowing part of a fitting rather than its housing. */
|
|
emissive: boolean;
|
|
}
|
|
|
|
export interface Furnishings {
|
|
group: THREE.Group;
|
|
/**
|
|
* Every batch built from a light-fitting asset. Empty in a pack with no
|
|
* fittings, which is a supported state — the room is then lit by the rig
|
|
* alone, exactly as it was before any of this existed.
|
|
*/
|
|
luminaires: LuminaireBatch[];
|
|
/** Raycast targets — the instanced meshes. Resolve a hit with `propAt`. */
|
|
pickables: THREE.Object3D[];
|
|
/** How many draw calls this floor's furniture costs. Handy when tuning a pack. */
|
|
batches: number;
|
|
/** The prop id behind a raycast hit, or `null` if the hit was not ours. */
|
|
propAt(object: THREE.Object3D, instanceId: number | undefined): string | null;
|
|
dispose(): void;
|
|
}
|
|
|
|
export function createFurnishings(plan: Plan, options: FurnishOptions): Furnishings {
|
|
const registry = options.registry ?? kit;
|
|
const group = new THREE.Group();
|
|
group.name = "furnishings";
|
|
const pickables: THREE.Object3D[] = [];
|
|
const luminaires: LuminaireBatch[] = [];
|
|
const owned: THREE.BufferGeometry[] = [];
|
|
|
|
const levels = options.levelIds
|
|
? options.levelIds.map((id) => plan.level(id)).filter((l): l is LevelPlan => l !== null)
|
|
: plan.levels;
|
|
|
|
// Batches are keyed on the *resolved* id, so a self-hoster's
|
|
// `acme:desk.standing` overriding `tera:desk.workstation` still batches with
|
|
// itself rather than splitting on whichever name each prop happened to use.
|
|
const batches = new Map<string, PropPlacement[]>();
|
|
for (const level of levels) {
|
|
for (const prop of level.props) {
|
|
const key = `${registry.resolveId(prop.kind)} |