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>
This commit is contained in:
+196
-6
@@ -69,6 +69,11 @@ export type SurfaceRole =
|
||||
| "lightHousing"
|
||||
| "lightDiffuser"
|
||||
| "whiteboard"
|
||||
// Devices
|
||||
| "deviceShell"
|
||||
| "deviceMesh"
|
||||
| "deviceIndicator"
|
||||
| "screenContent"
|
||||
// Objects
|
||||
| "foliage"
|
||||
| "planter"
|
||||
@@ -82,6 +87,11 @@ export type SurfaceRole =
|
||||
*/
|
||||
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 {
|
||||
@@ -92,10 +102,50 @@ interface RoleSpec {
|
||||
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> = {
|
||||
@@ -115,7 +165,31 @@ const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
|
||||
// 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.
|
||||
glazing: { roughness: 0.05, metalness: 0.1, opacity: 0.22, doubleSided: true },
|
||||
//
|
||||
// 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 },
|
||||
|
||||
@@ -139,7 +213,38 @@ const ROLE_SPECS: Record<SurfaceRole, RoleSpec> = {
|
||||
lightDiffuser: { roughness: 0.9, metalness: 0, glow: 0.85 },
|
||||
whiteboard: { roughness: 0.15, metalness: 0, texture: "whiteboard" },
|
||||
|
||||
foliage: { roughness: 0.8, metalness: 0, doubleSided: true },
|
||||
/**
|
||||
* 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 },
|
||||
@@ -227,6 +332,11 @@ export class MaterialRegistry {
|
||||
* 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);
|
||||
@@ -234,6 +344,16 @@ export class MaterialRegistry {
|
||||
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;
|
||||
@@ -257,6 +377,38 @@ export class MaterialRegistry {
|
||||
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`.
|
||||
*
|
||||
@@ -276,27 +428,65 @@ export class MaterialRegistry {
|
||||
return this.get(this.resolve(surface, fallback));
|
||||
}
|
||||
|
||||
private create(role: SurfaceRole, color: number): SurfaceMaterial {
|
||||
private create(role: SurfaceRole, color: number, variant = 0): SurfaceMaterial {
|
||||
const spec = ROLE_SPECS[role];
|
||||
const map = spec.texture ? this.textures.get(spec.texture) : null;
|
||||
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,
|
||||
emissive: spec.glow ? color : 0x000000,
|
||||
// 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);
|
||||
return new THREE.MeshStandardMaterial({
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user