/** * 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 = { 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 = { 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(); private readonly ghosts = new Map(); private readonly tints = new Map(); 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(); } }