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/assets/materials.ts
T
karti db074e9cf7 feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file
partition.

**The render rig was the quality ceiling.** The renderer ran three's
NoToneMapping default while atmosphere drove the sun to 2.35 and assets set
emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is
why walls blew out and every fitting looked like a white rectangle. ACES filmic
tone mapping and an explicit output colour space land in `stage.ts`, and the
atmosphere intensity table and palette headroom are re-tuned against the new
curve rather than left tuned for the clipping we removed.

`engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally,
so nothing binary is committed. There was no environment map anywhere before, so
every `metalness > 0` role had nothing to reflect and rendered dull grey — a
defect the code already documented against itself in `office/optimus.ts`, where a
whole material role was abandoned over it, and worked around in `modelX.ts` with
a fake emissive that this change deletes. Atmosphere remains the sole light
owner; the rig derives from the `LightingState` it already produced.

**Studio hardware exists.** There was no device concept anywhere in the product:
no type, no route, no state. `devices/types.ts` fixes a declaration/state/
capability/command contract that a smart light, a thermostat, a door sensor and a
charger all fit without a schema change, and both studios now carry a desk mic
and a computer speaker with deterministic simulated behaviour behind an adapter
seam a real API can occupy later. Reads are the demo and are open; commands are a
signed-in action and are kept off the read body entirely, because a shared cache
replaying a GET that turned a microphone on is exactly what the fail-closed
cache default exists to prevent.

**The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the
response was served publicly cacheable, and the attribution hardcoded adsb.lol
regardless of where the endpoint pointed — one env var away from republishing
non-redistributable data under an open-terms credit. The host is now allowlisted,
the credit is derived from the host actually configured, public cacheability is
conditional on redistributability, and a refused endpoint demotes to simulated
flights and says so in `degraded[]`. The gate is on the source, not the feature:
live aircraft and their detail cards stay open to anonymous visitors.

**The LA studio was never the smaller pack** — 16 rooms and 248 props against
SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were
ceiling troffers, it bound no props to seats, placed none of the habitat kit, and
12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather
than more instances, because `furnish.ts` draws once per kind and folds colour
into the batch key, so repeat instances add nothing the eye can read.

**The interface stops being forty imperative mutations.** Every visibility
decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the
chrome has coverage for the first time. Deleted: ~100 lines of CSS and two
bindings targeting elements that no longer exist, and a `body:has()` rule that
shifted the desktop layout by 160px for touch controls hidden there. Fixed: the
office picker tabs that drew their label and their badge on top of each other.
Added: a first-run flow, because the product is two verbs and neither was ever
stated on screen. Mobile is designed on its own terms instead of being the
desktop with things hidden — the plan view comes back, and the keyboard-only
shortcuts button is replaced by touch controls.

`arena/studioOps.ts` frames the whole thing as the multi-variable environment it
is, wrapping the same simulators the renderer drives rather than a headless copy.

Also removed `input/vehicle.ts`, which nothing but its own test imported.

Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six
matrix cells, no-binaries, provenance, dependency licences, zero-config boot and
arena source hashes all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:44:24 -07:00

503 lines
20 KiB
TypeScript

/**
* Every surface in the library, keyed on what it *is* rather than what colour
* it happens to be.
*
* `SurfaceRole` is a closed union on purpose. A registry keyed on strings would
* let an asset ask for `"grey"` and get one, and then the day somebody wants a
* warm office there would be forty files to edit and no list of what to edit.
* Keyed on roles, the whole appearance of the world is `palette.ts` plus the
* table below, and an asset that asks for `deskSurface` gets whatever a desk
* surface is *here* — which is what makes eighteen independently-written asset
* builders look like one library.
*
* The roles are named after the object, never after the finish: `partitionFabric`
* and not `blueFelt`, `screenDisplay` and not `black`. Same rule as
* `Marker.colorKey` one level in (ARCHITECTURE.md §3.3) — this module renders
* surfaces and takes no position on what they mean.
*
* There is exactly one `THREE.Material` per role per registry, and every mesh in
* the office shares it. That sharing is half of the draw-call budget; the other
* half is `parts.ts` merging geometry per material.
*/
import * as THREE from "three";
import { DEFAULT_INTERIOR_PALETTE, type InteriorPalette } from "./palette.ts";
import { TextureBin, type TextureKind } from "./textures.ts";
/**
* The closed set of surfaces this library knows how to be.
*
* Adding a role is a three-line change — here, in `ROLE_SPECS`, and in
* `ROLE_SHIFTS` in `palette.ts` — and the compiler will not let you forget the
* third.
*/
export type SurfaceRole =
// Floors
| "floorSlab"
| "carpet"
| "carpetAccent"
| "woodFloor"
| "polishedConcrete"
| "tile"
// Ceilings
| "ceilingTile"
| "ceilingBaffle"
// The vertical shell
| "plaster"
| "plasterAccent"
| "skirting"
| "glazing"
| "glazingFrame"
| "doorLeaf"
// Partitions
| "partitionFabric"
| "partitionFrame"
// Furniture
| "deskSurface"
| "deskFrame"
| "tableTop"
| "cabinet"
| "shelf"
| "chairShell"
| "chairFabric"
| "chairBase"
| "upholstery"
// Fittings
| "metalTrim"
| "screenBezel"
| "screenDisplay"
| "lightHousing"
| "lightDiffuser"
| "whiteboard"
// Devices
| "deviceShell"
| "deviceMesh"
| "deviceIndicator"
| "screenContent"
// Objects
| "foliage"
| "planter"
| "paper"
| "accent";
/**
* `low` is flat Lambert with no maps — the same material class the city uses,
* and the setting that makes an office open on an integrated GPU. `medium` and
* `high` are physically-shaded and differ only in texture resolution.
*/
export type MaterialQuality = "low" | "medium" | "high";
/**
* `MeshPhysicalMaterial` is a subclass of `MeshStandardMaterial`, so it needs no
* arm of its own here — but it is worth knowing it is in the union, because
* `glazing` is one at `medium` and `high` and a `MeshStandardMaterial` at `low`.
*/
export type SurfaceMaterial = THREE.MeshStandardMaterial | THREE.MeshLambertMaterial;
interface RoleSpec {
/** 0 = mirror, 1 = chalk. Ignored at `low` quality. */
roughness: number;
/** Ignored at `low` quality. */
metalness: number;
texture?: TextureKind;
/** Fraction of the role's own colour emitted. Screens and diffusers only. */
glow?: number;
/**
* Emit through `texture` rather than flat across the surface.
*
* Only meaningful with a `texture` that carries content rather than grain, and
* `screenContent` is the only such role. It is the difference between a
* monitor and a light box: with a flat `glow` the whole panel emits and the
* drawn interface is a pattern printed on a lamp, and with the map bound to
* `emissiveMap` the lit pixels emit and the chrome around them does not.
*/
emissiveFromMap?: boolean;
/**
* A coverage map, and the threshold a fragment has to clear to be drawn.
*
* Cutout, not blend. `alphaTest` discards below the threshold and leaves the
* material opaque, so a leaf still writes depth, still sorts like solid
* geometry and still casts a correctly-shaped shadow — three's depth material
* copies `alphaMap` and `alphaTest` across for exactly this. Making foliage
* `transparent` instead would buy a soft edge and cost the shadow, the depth
* write and the sort order, on the one class of object there are hundreds of.
*/
alphaTexture?: TextureKind;
alphaTest?: number;
/** Opacity below 1 makes the material transparent. */
opacity?: number;
/** Leaf cards and glass want both faces. */
doubleSided?: boolean;
/**
* Refract through the surface instead of blending over it.
*
* `MeshPhysicalMaterial`'s transmission is the difference between glass and a
* grey film: it takes the *lit* colour of what is behind the surface, tints it
* by `color`, bends it by `ior` over `thickness`, and — the part that actually
* sells it — leaves a specular highlight and an environment reflection on top
* that a 22%-opacity blend cannot have. `roughness` becomes frosting rather
* than a matte grey, which is what a fritted partition wants.
*
* It costs a copy of the render target per transmissive draw, which is why it
* is `medium` and `high` only and why exactly one role uses it.
*/
transmission?: number;
/** Refractive index. 1.5 is soda-lime glass. Only read with `transmission`. */
ior?: number;
/** Metres of glass the refraction is integrated over. Only read with `transmission`. */
thickness?: number;
}
const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
floorSlab: { roughness: 0.9, metalness: 0, texture: "polishedConcrete" },
carpet: { roughness: 0.98, metalness: 0, texture: "carpetLoop" },
carpetAccent: { roughness: 0.98, metalness: 0, texture: "carpetLoop" },
woodFloor: { roughness: 0.55, metalness: 0, texture: "woodPlank" },
polishedConcrete: { roughness: 0.4, metalness: 0.05, texture: "polishedConcrete" },
tile: { roughness: 0.3, metalness: 0, texture: "tileGrid" },
ceilingTile: { roughness: 0.95, metalness: 0, texture: "ceilingTile" },
ceilingBaffle: { roughness: 0.9, metalness: 0, texture: "fabricWeave" },
plaster: { roughness: 0.92, metalness: 0, texture: "plasterPaint" },
plasterAccent: { roughness: 0.92, metalness: 0, texture: "plasterPaint" },
skirting: { roughness: 0.6, metalness: 0 },
// Glass writes no depth. With it on, anything behind a window disappears
// depending on which mesh the sorter happens to draw first, and a meeting
// room made of glass is exactly the case where that is most visible.
//
// That reasoning survives the move to transmission unchanged, and it has to be
// said out loud because three.js *encourages* the opposite: a transmissive
// material is drawn in the transmission pass and the usual advice is to let it
// write depth. Here it must not. An office is a box of glass boxes — a meeting
// room seen through a corridor screen through an external window is three
// sheets deep — and depth-writing glass makes whichever sheet the sorter
// reached first erase the other two. The `opacity` stays as well: it is what
// `low` quality falls back to, and it is what keeps the frame visible against
// the glass in the ghosted wall-occlusion copy.
glazing: {
roughness: 0.05,
// Was 0.1, and had to go: three.js scales transmission by `1 - metalness`
// because a metal is opaque by definition, so a tenth of metalness is a
// tenth of the glass quietly turned back into a mirror.
metalness: 0,
opacity: 0.22,
doubleSided: true,
transmission: 0.92,
ior: 1.5,
// Millimetres, not metres of solid glass: `thickness` scales the volumetric
// tint, and a 6 mm pane that tints like a 6 m aquarium is the classic way
// this parameter goes wrong.
thickness: 0.006,
},
glazingFrame: { roughness: 0.35, metalness: 0.7 },
doorLeaf: { roughness: 0.6, metalness: 0 },
partitionFabric: { roughness: 0.95, metalness: 0, texture: "fabricWeave" },
partitionFrame: { roughness: 0.4, metalness: 0.6 },
deskSurface: { roughness: 0.45, metalness: 0, texture: "woodPlank" },
deskFrame: { roughness: 0.4, metalness: 0.65 },
tableTop: { roughness: 0.4, metalness: 0, texture: "woodPlank" },
cabinet: { roughness: 0.6, metalness: 0.05 },
shelf: { roughness: 0.55, metalness: 0, texture: "woodPlank" },
chairShell: { roughness: 0.55, metalness: 0.05 },
chairFabric: { roughness: 0.95, metalness: 0, texture: "fabricWeave" },
chairBase: { roughness: 0.35, metalness: 0.75 },
upholstery: { roughness: 0.92, metalness: 0, texture: "fabricWeave" },
metalTrim: { roughness: 0.3, metalness: 0.85 },
screenBezel: { roughness: 0.5, metalness: 0.2 },
screenDisplay: { roughness: 0.2, metalness: 0, glow: 0.4 },
lightHousing: { roughness: 0.4, metalness: 0.5 },
lightDiffuser: { roughness: 0.9, metalness: 0, glow: 0.85 },
whiteboard: { roughness: 0.15, metalness: 0, texture: "whiteboard" },
/**
* The four device roles.
*
* `deviceMesh` is a grille or a windscreen — the perforated part — and it is
* double-sided because you can see through it to the inside of the housing at
* a glancing angle, which is most of what makes a speaker look like a speaker.
* `deviceIndicator` is the only role in the table with a `glow` of 1: an LED
* is a light source rather than a lit surface, and under the tone curve
* `stage.ts` now runs, a full-strength emissive reads as a lamp instead of
* saturating to the same white as the housing beside it.
*/
deviceShell: { roughness: 0.42, metalness: 0.28 },
deviceMesh: { roughness: 0.52, metalness: 0.8, doubleSided: true },
deviceIndicator: { roughness: 0.35, metalness: 0, glow: 1 },
screenContent: {
roughness: 0.18,
metalness: 0,
texture: "screenUI",
glow: 0.9,
emissiveFromMap: true,
},
// A leaf is a quad with a leaf cut out of it. See `leafAlpha` in textures.ts
// for why that is worth a texture channel, and `alphaTest` at 0.5 for why the
// threshold sits in the middle of a hard-edged drawing rather than at its toe.
foliage: {
roughness: 0.8,
metalness: 0,
doubleSided: true,
alphaTexture: "leafAlpha",
alphaTest: 0.5,
},
planter: { roughness: 0.7, metalness: 0 },
paper: { roughness: 0.9, metalness: 0 },
accent: { roughness: 0.6, metalness: 0.1 },
};
/**
* Authored `SurfaceId` strings to roles.
*
* An office pack carries `SurfaceId` — a loose namespaced string like
* `"tera:carpet.loop"` — because a pack is data and must not depend on this
* module to be parsed or stored (`interiors/types.ts`). Resolution happens here,
* once, and unknown ids fall back rather than throwing: a pack with one typo in
* it should still open, the same way an unregistered `AssetId` gets a
* placeholder box.
*
* The general rule is that the first dot-segment after the namespace is the
* role, so `tera:carpet.loop`, `tera:carpet.broadloom` and a self-hoster's
* `acme:carpet.whatever` all land on `carpet` for free. This table is only for
* the names where that reads badly.
*/
const SURFACE_ALIASES: Record<string, SurfaceRole> = {
paint: "plaster",
plasterboard: "plaster",
wall: "plaster",
wood: "woodFloor",
timber: "woodFloor",
concrete: "polishedConcrete",
glass: "glazing",
ceiling: "ceilingTile",
felt: "partitionFabric",
fabric: "partitionFabric",
laminate: "deskSurface",
steel: "metalTrim",
metal: "metalTrim",
aluminium: "metalTrim",
screen: "screenDisplay",
plant: "foliage",
};
const ROLE_NAMES = new Set(Object.keys(ROLE_SPECS));
export interface MaterialRegistryOptions {
palette?: InteriorPalette;
quality?: MaterialQuality;
/**
* Share a bin with another registry — two registries in one page (an office
* being previewed beside the one you are in) should not draw the carpet
* twice. The registry disposes only a bin it made itself.
*/
textures?: TextureBin;
}
export class MaterialRegistry {
readonly palette: InteriorPalette;
readonly quality: MaterialQuality;
readonly textures: TextureBin;
private readonly ownsTextures: boolean;
private readonly base = new Map<SurfaceRole, SurfaceMaterial>();
private readonly ghosts = new Map<SurfaceRole, SurfaceMaterial>();
private readonly tints = new Map<string, SurfaceMaterial>();
constructor(options: MaterialRegistryOptions = {}) {
this.palette = options.palette ?? DEFAULT_INTERIOR_PALETTE;
this.quality = options.quality ?? "high";
this.ownsTextures = options.textures === undefined;
this.textures = options.textures ?? new TextureBin(this.quality);
}
/** The one shared material for a role. Do not mutate it. */
get(role: SurfaceRole): SurfaceMaterial {
const hit = this.base.get(role);
if (hit) return hit;
const made = this.create(role, this.palette[role]);
made.name = role;
this.base.set(role, made);
return made;
}
/**
* A translucent copy of a role, for the wall-occlusion fade — the walls
* between the camera and where you are looking go ghost rather than being
* hidden, so the floorplan stays readable from outside.
*
* The map is dropped deliberately: carpet grain at 18% opacity is visual
* noise on top of whatever it is supposed to be letting you see. Depth
* writing goes with it, for the same reason glazing does not write depth.
*
* `alphaMap` is deliberately *not* dropped with it. The colour map is
* decoration and the coverage map is shape — a ghosted leaf with its cutout
* removed is not a faint leaf, it is the flat green shard the cutout exists to
* get rid of, at 18% opacity.
*/
ghostOf(role: SurfaceRole): SurfaceMaterial {
const hit = this.ghosts.get(role);
if (hit) return hit;
const ghost = this.get(role).clone();
ghost.name = `${role}:ghost`;
ghost.map = null;
// The relief goes with the colour map and for the same reason. It also has
// to: a normal map on a surface that is 82% see-through is a lighting cue
// for a surface nobody is being asked to look at.
if ("normalMap" in ghost) ghost.normalMap = null;
// A ghost is a hint, not a window. Leaving transmission on would put the
// occlusion fade — which exists to be cheap and is redrawn as the camera
// moves — through the transmission pass and its render-target copy.
if ("transmission" in ghost) {
(ghost as THREE.MeshPhysicalMaterial).transmission = 0;
}
ghost.transparent = true;
ghost.opacity = 0.18;
ghost.depthWrite = false;
ghost.side = THREE.FrontSide;
this.ghosts.set(role, ghost);
return ghost;
}
/**
* A role recoloured for one instance — what an asset calls once the caller's
* palette has turned a `Prop.colorKey` into a number. Cached, because a
* hundred chairs in three colours should still be three materials.
*/
tinted(role: SurfaceRole, color: number): SurfaceMaterial {
const key = `${role}:${color.toString(16)}`;
const hit = this.tints.get(key);
if (hit) return hit;
const made = this.create(role, color);
made.name = key;
this.tints.set(key, made);
return made;
}
/**
* A role drawn with a different layout of its own texture.
*
* Only `screenContent` has more than one today (`SCREEN_UI_VARIANTS` of them),
* and this exists because of a constraint one layer up rather than a wish for
* variety: `furnish.ts` batches props per kind and draws `ctx.rand` **once per
* kind**, so a screen asset cannot roll for a layout per instance. Variety has
* to arrive as a parameter, from a pack authoring separate batches, which
* means it has to arrive as a separate material — one material per layout, all
* of them cached here, and the draw-call cost is one call per layout actually
* used rather than one per screen.
*
* `color` is optional so the common case reads `variant(role, n)`; pass one to
* get a tinted layout, which is the same shape `tinted` offers.
*/
variant(role: SurfaceRole, variant: number, color?: number): SurfaceMaterial {
const texture = ROLE_SPECS[role].texture;
const count = texture ? this.textures.variants(texture) : 1;
const index = count <= 1 ? 0 : (((variant % count) + count) % count) | 0;
const hue = color ?? this.palette[role];
// Variant 0 with the role's own colour *is* the base material. Minting a
// second identical one would be a second draw call for the same picture.
if (index === 0 && color === undefined) return this.get(role);
const key = `${role}:${hue.toString(16)}:${index}`;
const hit = this.tints.get(key);
if (hit) return hit;
const made = this.create(role, hue, index);
made.name = key;
this.tints.set(key, made);
return made;
}
/**
* Turn an authored `SurfaceId` into a role. Unknown ids give `fallback`.
*
* `undefined` in gives `fallback` too, so a caller can pass an optional field
* straight through: `materials.resolve(room.floor, "carpet")`.
*/
resolve(surface: string | undefined, fallback: SurfaceRole): SurfaceRole {
if (!surface) return fallback;
const local = surface.includes(":") ? surface.slice(surface.indexOf(":") + 1) : surface;
const head = local.split(".")[0] ?? "";
if (ROLE_NAMES.has(head)) return head as SurfaceRole;
return SURFACE_ALIASES[head] ?? fallback;
}
/** Convenience for the common `resolve` then `get`. */
forSurface(surface: string | undefined, fallback: SurfaceRole): SurfaceMaterial {
return this.get(this.resolve(surface, fallback));
}
private create(role: SurfaceRole, color: number, variant = 0): SurfaceMaterial {
const spec = ROLE_SPECS[role];
const map = spec.texture ? this.textures.get(spec.texture, variant) : null;
const alphaMap = spec.alphaTexture ? this.textures.get(spec.alphaTexture) : null;
// Relief comes from the same kind as the colour, and the bin answers `null`
// for the kinds that have none — a whiteboard and a display are flat, and
// `low` quality has no maps at all. No role opts in separately: a surface
// either has a texture or it does not, and asking for the grain without the
// relief that produced it is not a combination worth spelling.
const normalMap = spec.texture ? this.textures.normal(spec.texture) : null;
const transparent = spec.opacity !== undefined && spec.opacity < 1;
const shared = {
color,
map,
// `alphaTest` is only set when there is a map to test against. Left on
// with a null `alphaMap` at `low` quality it would test the material's
// flat opacity of 1 against the threshold on every fragment — which
// passes, but compiles a branch into the shader for nothing.
alphaMap,
alphaTest: alphaMap ? (spec.alphaTest ?? 0.5) : 0,
side: spec.doubleSided ? THREE.DoubleSide : THREE.FrontSide,
transparent,
opacity: spec.opacity ?? 1,
depthWrite: !transparent,
// White rather than the role's colour when the map is doing the emitting:
// `emissive` multiplies `emissiveMap`, so anything but white would tint
// the drawn interface a second time on top of `color` already tinting it.
emissive: spec.emissiveFromMap ? 0xffffff : spec.glow ? color : 0x000000,
emissiveMap: spec.emissiveFromMap ? map : null,
emissiveIntensity: spec.glow ?? 0,
};
// `low` is flat Lambert: no maps, no roughness, no transmission. The
// `normalMap` is not merely unused there — `MeshLambertMaterial` does have
// one, but the whole point of `low` is to compile the cheap shader.
if (this.quality === "low") return new THREE.MeshLambertMaterial(shared);
const physical = {
...shared,
normalMap,
roughness: spec.roughness,
metalness: spec.metalness,
};
if (spec.transmission === undefined) return new THREE.MeshStandardMaterial(physical);
return new THREE.MeshPhysicalMaterial({
...physical,
transmission: spec.transmission,
ior: spec.ior ?? 1.5,
thickness: spec.thickness ?? 0.01,
// Transmission carries the see-through, so the blend must not do it a
// second time. Left transparent at 0.22 the sheet would be four fifths
// invisible *and* refracting the fifth that was left, which reads as a
// smear rather than as glass. `depthWrite` stays false regardless — see
// the note on the role.
transparent: false,
opacity: 1,
depthWrite: false,
});
}
dispose(): void {
for (const m of this.base.values()) m.dispose();
for (const m of this.ghosts.values()) m.dispose();
for (const m of this.tints.values()) m.dispose();
this.base.clear();
this.ghosts.clear();
this.tints.clear();
if (this.ownsTextures) this.textures.dispose();
}
}