/** * 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(); 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; }; }