1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/interiors/furnish.ts
T
karti af0d4a7d57 The office keeps its lights on, and something walks around under them
**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>
2026-08-07 00:56:49 -07:00

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)}${prop.colorKey ?? ""}`;
const list = batches.get(key);
if (list) list.push(prop);
else batches.set(key, [prop]);
}
}
const local = new THREE.Matrix4();
const world = new THREE.Matrix4();
const placement = new THREE.Matrix4();
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const euler = new THREE.Euler();
const scale = new THREE.Vector3();
for (const [key, placements] of batches) {
const first = placements[0];
if (!first) continue;
const ctx = createAssetContext({
materials: options.materials,
registry,
// Seeded from the batch key rather than from a counter, so a pack that
// gains a prop does not reshuffle the books on every shelf in the
// building. Same discipline as the city: a world that reshuffles itself
// between visits is a lava lamp.
rand: mulberry32(hash(key) ^ (options.seed ?? 0)),
colorKey: first.colorKey,
colorFor: options.colorFor,
});
const built = registry.build(first.kind, ctx);
built.updateMatrixWorld(true);
// An asset comes back as a group of merged meshes, one per material it used,
// each already carrying its parts' transforms. Every one of those becomes
// one instanced mesh; the group itself is discarded.
built.traverse((child) => {
const mesh = child as THREE.Mesh;
if (!mesh.isMesh || !mesh.geometry) return;
const material = mesh.material;
if (Array.isArray(material)) return;
const instanced = new THREE.InstancedMesh(mesh.geometry, material, placements.length);
instanced.name = `${first.kind}:${material.name || "material"}`;
instanced.castShadow = mesh.castShadow;
instanced.receiveShadow = mesh.receiveShadow;
local.copy(mesh.matrixWorld);
for (let i = 0; i < placements.length; i++) {
const prop = placements[i];
if (!prop) continue;
position.set(prop.position.x, prop.position.y, prop.position.z);
// `Yaw` is `object.rotation.y` with no conversion, which is the whole
// reason `interiors/types.ts` defines it in three.js's terms.
euler.set(0, prop.rotation, 0);
quaternion.setFromEuler(euler);
scale.set(prop.scale[0], prop.scale[1], prop.scale[2]);
placement.compose(position, quaternion, scale);
instanced.setMatrixAt(i, world.multiplyMatrices(placement, local));
}
instanced.instanceMatrix.needsUpdate = true;
// Without this the bounding sphere is the *unplaced* asset's, so a floor
// of desks is culled the moment the camera leaves the origin.
instanced.computeBoundingSphere();
// Instance i is placement i, which is the only address a raycast hit has.
instanced.userData.props = placements.map((p) => p.id);
/**
* A light fitting is recognised by its asset id, not by its material.
*
* The id is what a pack author wrote and is stable; the material is a
* *role*, and `lightDiffuser` is shared with anything else that ever wants
* to glow. Keying on the material would have meant a future glowing sign
* silently joining the ceiling lights and coming on when somebody walked
* under it.
*
* `emissive` separates the two meshes a fitting comes back as — the
* diffuser, which is the part that brightens, and the housing, which is
* a lump of painted metal and stays put.
*/
if (registry.resolveId(first.kind).includes(":light.")) {
const positions = placements.map((p) => new THREE.Vector3(p.position.x, p.position.y, p.position.z));
const emissive = (material as THREE.MeshStandardMaterial).emissiveIntensity > 0;
luminaires.push({ mesh: instanced, positions, emissive });
}
owned.push(mesh.geometry);
pickables.push(instanced);
group.add(instanced);
});
}
return {
group,
pickables,
luminaires,
batches: group.children.length,
propAt(object, instanceId) {
if (instanceId === undefined) return null;
const ids = object.userData.props as string[] | undefined;
return ids?.[instanceId] ?? null;
},
dispose() {
for (const geo of owned) geo.dispose();
owned.length = 0;
for (const child of group.children) (child as THREE.InstancedMesh).dispose();
pickables.length = 0;
group.clear();
},
};
}
/** FNV-1a. Any stable string-to-number would do; this one is four lines. */
function hash(text: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/** A seeded PRNG, so the office looks the same on every reload. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}