/** * Optimus robots walking around the office. * * This is `presence.ts`'s noisy cousin and it is deliberately a different shape, * because it is solving a different problem. A presence is a person *at a seat*: * it has an id, it comes from an API, it never moves, and the whole design * effort went into making sure the geometry and the people stay on opposite * sides of a line. A robot is nobody. It has no id worth publishing, it comes * from nowhere, and it exists to make a still building look like a place where * something is happening. So there is no palette, no `colorKey`, no binding to * anything in the pack, and nothing here can leak: the only inputs are a `Plan` * and a list of levels. * * ### What it costs * * Four robots is the budget and roughly the right number — one is a mascot, ten * is a warehouse. * * - **18 draw calls each, 72 for four.** This is the whole cost and it is not * small; the office shell and its furniture together draw in about forty. * The reason is in `assets/office/optimus.ts`: a figure that has to bend at * eleven joints cannot be one merged mesh, so it is eighteen small merged * meshes instead. If a floor needs the budget back, drop the count — the * cost is exactly linear in it, and a pack that only wants a robot standing * somewhere should place the `tera:robot.optimus` asset, which is two. * - **One set of geometry, 7.1k triangles, however many robots there are.** * `buildOptimus` runs once and every figure after the first is a * `cloneOptimus`, which shares every buffer and both materials. * - **About 20 µs per tick for the crowd**, measured over thirty simulated * minutes on the reference office — roughly 0.1% of a 60 Hz frame. Most of a * tick is `plan.blocked`, which is linear in the level's collision segments * (fifty-four on that floor); a robot spends one or two calls a frame * steering and up to fifteen on the frames where it is boxed in and fanning * out. Nothing here is worth caching. * * It used to be 24 µs, and errands made it *cheaper* rather than dearer. * Choosing a destination with a reason costs one `blocked` call; choosing one * at random cost up to forty-eight, because every candidate had to be tested * for standing room and then again for line of sight. Knowing where you are * going is less work than not. * - **Picking a destination costs one `blocked` call** in the normal case and * at most `SHORTLIST` of them, plus one `roomAt` — and only on the frame a * robot arrives somewhere, which is every few seconds. The fallbacks are the * old prices: up to two `blocked` calls on each of `PICK_ATTEMPTS` random * candidates, then a pass over the doors. A robot that finds nowhere to go * waits `RETRY_PAUSE` before trying again, so even one sealed into a cupboard * costs a burst every second and a half rather than one every frame. * - **An address book costs about 2.5 ms per level, once.** 224 places on the * reference ground floor, each checked for standing room and a run-in, built * the first time a robot on that level picks a destination and never again. * It is deliberately not built at construction: a level with no robots on it * never needs one, and a hitch during the load is a hitch nobody attributes * correctly. * * ### Errands: where a robot goes, and why it is there * * The difference between a robot that is working and a robot that is patrolling * is not the walk. It is the destination and the arrival. * * This used to pick a uniformly random reachable point, walk to it, stop at * whatever angle it happened to be facing, wait between 1.4 and 4.6 seconds, and * repeat. Every part of that is defensible on its own and the sum of it is a * security guard: nowhere it goes is a place, nothing it does when it gets there * is different from anything else it does, and the only thing distinguishing one * stop from the next is a random number. * * So a destination is now an **address** — somewhere `Plan` already has a name * for — and an address comes with an angle and a reason to linger: * * - a **seat**, approached from behind and held at the seat's own `facing`, so * the robot stands at somebody's desk looking at the desk; * - a **fixture** — an authored prop, which is to say a whiteboard, a locker, * a shelf, a meeting chair — stood in front of and looked at; * - a **room's centre**, which is the one that sends a robot to the kitchen. * * `ErrandKind` covers the choosing and `Address` the arriving. Three things * about it are worth knowing before changing any of it: * * - **The kind is drawn before the address**, so the pack cannot decide the * mix by how many desks it happens to author. See `ERRAND_MIX`. * - **Candidates are shortlisted and scored, not taken first-fit**, on how * recently anyone went there, how close it is to another robot or to where * another robot is heading, and whether it is out of the room this robot is * already standing in. That is what spreads four robots over a building * instead of letting them pool. See `scoreAddress`. * - **The last stretch is walked along the angle the robot will hold**, via a * waypoint behind the destination, so it arrives lined up rather than * stopping crooked and pivoting. See `APPROACH_RUNS`. * * Measured over ten thirty-minute runs on each reference pack, against the same * harness running the version this replaced: a robot arriving somewhere with an * angle to hold now arrives a median of 9° off it rather than stopping wherever, * and the crowd enters 15 to 18 of the reference building's 26 rooms in half an * hour rather than 12 to 17 — and all six of the second pack's every single run, * rather than four to six. * * It also stands still more: 42% of the session against 29%. That is the point * rather than a regression. The old 29% was a robot with nothing to do having * nothing to do; the new 42% is four robots holding at desks, at whiteboards and * in doorways, and the number to watch is not that one but the one below it — * how much of the standing is a robot that has genuinely failed to find anywhere * to go, which is what the watchdog and `pickDoor` exist to keep near zero. * * ### Navigation: rejection sampling, not a navmesh * * Underneath the errands, and still the whole of the fallback. * * Building a navmesh for an office would mean a floor decomposition, a portal * graph, A*, string-pulling and a funnel — several hundred lines, a new build * product to keep in step with `Plan`, and a whole second definition of "where * can you stand" beside the one the wall split already produces. All of that to * decide which way a decorative robot walks round a desk. * * So: pick a point on the floor, keep it if `plan.roomAt` says it is indoors and * `plan.blocked` says the straight line from here to there crosses no wall, and * walk at it. Give up after `PICK_ATTEMPTS` and wait a beat. Watch one robot for * a minute and it looks like it is wandering; watch the algorithm and it is * playing join-the-dots with its own line of sight. Both readings are correct * and only one of them is visible. * * Two refinements on top of that, and both exist because the plain version was * measured and found wanting rather than because they seemed like good ideas. * Each is documented where it lives: * * - Candidates are drawn from a **room chosen by area**, not from the level's * bounding box, because a level's rooms cover a fraction of its bounds and * most candidates were landing in the void outside the walls (`samplerFor`). * - A robot that can see nowhere to go walks to a **doorway** instead, via a * waypoint in the opening itself, because line of sight out of a small room * through a 0.9 m gap almost never exists and three of four robots spent * nineteen simulated minutes parked in one (`pickDoor`). * * Together those took the crowd from 82% of the session standing still to under * 30%, which is the difference between an office with robots in it and an office * with four statues. Both still run, and both still matter: an errand needs an * address it can see, and the two things that produce a robot which cannot see * one — a small room, and a pack with nothing in it — are exactly what these two * were built for. * * Two things this deliberately does not know about: * * - **Furniture.** `plan.blocked` is the wall collider and nothing else, so * robots walk through desks. Fixing it means asking the asset registry for * every prop's footprint and building a second collider, which is a real * feature with a real cost and is not this. If it ever matters, the place to * put it is `Plan`, next to the wall split, so that the walk controller and * the robots get the same answer. * * Errands make this more conspicuous rather than less, because a robot now * walks *up to* furniture on purpose. What keeps that looking right is that * it stops short of it: `DESK_STANDOFF` and `FIXTURE_STANDOFF` are the two * places in this file that know a prop takes up room, and both are stated as * distances rather than looked up, precisely so that this stays true. * - **Stairs.** A robot belongs to one level for its whole life. Levels are * connected by nothing in the office contract, so there is nowhere for it to * go, and a robot that walked off a mezzanine would be a bug rather than a * feature. * * ### Getting unstuck, which is the part that actually needs care * * A straight line that was clear when the destination was chosen can stop being * clear, because a robot turns on an arc rather than pivoting on the spot. So * every step is re-checked, and the machinery for that is three rules that have * to hold together — each of them broke on its own during development, and each * failure looked like a robot standing still and thinking: * * 1. **One desired heading, one turn.** The heading is chosen by fanning out * from the direction the destination wants, and then the yaw is turned * toward it once, rate-limited. Turning toward the destination *and* toward * the probe result in the same frame gives two rate-limited turns that * cancel exactly, and a robot locked at a fixed angle off course forever. * 2. **Walk only what was tested.** The rate limit means that after turning, * the robot faces somewhere between where it was and where it probed. That * direction has not been checked, and walking it is how a robot ends up * inside the clearance band. * 3. **Probe a fixed lookahead, never the step length.** `plan.blocked` is * true when the capsule comes within `radius` of a wall *including at its * start*, so a robot already closer to a wall than its own radius has every * direction blocked, away from the wall included. `PROBE_AHEAD` keeps it * well clear of that band, and `PROBE_RELIEF` gets it back out if it ever * gets in. * * And because none of that is a proof, there is a watchdog on top: a robot that * covers less than `STUCK_DISTANCE` in `STUCK_WINDOW` seconds throws its * destination away and picks another. That is what guarantees the worst failure * is a robot standing still and looking thoughtful, rather than one buzzing * against a partition until somebody closes the tab. It also breaks the one * deadlock the yielding rule can produce, where two robots stop nose to nose and * politely wait for each other. * * Soaked over ten thirty-minute runs across both reference packs, four robots * each, with a four-second frame thrown in every fifty seconds to imitate a tab * waking up: no robot left a room and none entered the clearance band. Re-run * unchanged after errands arrived, since a destination with a name is still just * a point as far as everything below here is concerned — same result. * * ### The walk cycle runs on distance, not on time * * `phase = distance / STRIDE`, never `phase += dt`. This is the difference * between feet that push the floor and feet that skate on it: a robot slowing * into a turn takes shorter steps rather than the same steps more slowly, and a * stopped robot's cycle stops with it instead of running on the spot. * * That is necessary and it is not sufficient. The shape of the swing has to * match the distance too, which is what `legAngle` and `STRIDE` are about and is * worth reading before touching either — a hand-picked stride against a * sinusoidal hip measured 65% of distance travelled coming back out as foot * slip, and the fix was arithmetic rather than taste. It now measures 5%. * * Stopping is the other half. A frozen phase is a frozen mid-stride, so the * whole pose is *interpolated* toward the rest stance by `gait`, which eases to * zero over about a quarter of a second. The phase freezes and the amplitude * drains out of it, settling the figure from wherever it was without moving a * foot across the floor. Fading the pose out is the only way to stop that does * not slide; running the cycle on to the end of the stride is the way that does. * * One consequence, and it is the price of the arrival turn. A robot settling * onto its seat's facing rotates with `gait` at zero and its feet planted — the * whole figure swings about its own axis, because there is no shuffle to play * and faking one out of the walk joints would be a stride taken on the spot, * which is the exact thing the paragraph above is about. `SETTLE_RATE` makes * that slow enough to read as deliberate, and `APPROACH_RUNS` makes it small * enough to mostly not happen. A shuffle would need turn-in-place footwork the * rig has never had, and it would have to be driven by yaw the way the walk is * driven by distance, or it would skate for the same reason. */ import * as THREE from "three"; import { createAssetContext } from "../assets/kit.ts"; import type { MaterialRegistry } from "../assets/materials.ts"; import { buildOptimus, cloneOptimus, disposeOptimus, OPTIMUS, OPTIMUS_REST, restOptimus, type OptimusJoints, type OptimusRig, } from "../assets/office/optimus.ts"; import { seededRandom } from "../engine/world.ts"; import type { LevelPlan, Plan, ResolvedOpening, ResolvedRoom } from "./plan.ts"; import type { Point2 } from "./types.ts"; // ---- Tuning --------------------------------------------------------------- /** Metres per second on the straight. A brisk indoor human walk. */ const CRUISE = 1.2; /** * How wide a robot is to the collider. * * The number to size this against is not the shoulder span. `optimus.ts` puts * the shoulder *pivots* 0.35 m apart and this comment used to quote that, which * made 0.28 look like a radius with 100 mm of slack in it. Measured off the * built figure, a standing Optimus is 0.520 m across at its widest — the * shoulder drums, with the splayed hands 4 mm inside them — so 0.56 is 20 mm of * slack a side, not 100. * * That is still the right answer, because a doorway `Plan` calls passable is * 0.9 m and 0.56 goes through one with room to turn in it. But it is the number * to think with if anything about the arms, the shoulders or the stance * changes, and there is far less room in it than the old comment implied: the * figure is within 40 mm of its own collider, so a wider robot silently starts * clipping door frames rather than failing. */ const RADIUS = 0.28; /** Radians per second of yaw. About a second and a half for a half turn. */ const TURN_RATE = 2.2; /** * Hip to ankle in the rest pose — the length of the pendulum the whole gait is. * Two numbers below are derived from it rather than dialled in, which is the * only reason the feet stay on the floor. */ const LEG = OPTIMUS.hipY - OPTIMUS.ankleY; /** How close counts as arrived. Inside this the robot stops looking for the point. */ const ARRIVE = 0.35; /** * How close counts as having reached a waypoint. Tighter than `ARRIVE`, because * the only waypoint there is is a doorway and the whole point of going there is * to end up lined up with it. */ const REACHED = 0.22; /** A destination has to be at least this far away, or a robot shuffles on the spot. */ const MIN_TRIP = 1.8; /** * How far a doorway has to be before it counts as somewhere to go. * * Small on purpose. The obvious value is something like 1.4 m — far enough that * a robot cannot immediately turn round and go back through the door it just * came out of — and it is wrong, because a phone booth is 2.0 × 1.8 m and its * own door is never more than 1.3 m from anywhere inside it. Set it high and the * one room a robot most needs help escaping from is the one room it cannot. The * doubling-back problem is solved by remembering the last door instead, which is * what `Robot.lastDoor` is for. */ const MIN_DOOR = 0.45; /** How far past a doorway to aim, so a robot ends up in the next space and not in the gap. */ const THROUGH_DOOR = 0.85; const PICK_ATTEMPTS = 24; /** Seconds to wait after failing to find anywhere to go. */ const RETRY_PAUSE = 1.5; /** * Seconds a robot stands still after arriving somewhere that was **not** an * errand — a random point on the floor, or the far side of a doorway. An errand * sets its own dwell from `DWELL`; this is the shrug. * * Also the spread on the initial stagger, so four robots do not all set off on * the same frame. */ const PAUSE_MIN = 1.4; const PAUSE_MAX = 4.6; /** How far ahead a step is tested. See the header — this must not be the step length. */ const PROBE_AHEAD = 0.34; /** Headings tried, in order, when the way ahead is blocked. Radians off course. */ const PROBE_TURNS = [0, 0.45, -0.45, 0.95, -0.95, 1.5, -1.5]; /** * Fractions of the collision radius the probe will settle for, in order. The * second one only ever comes into play for a robot that is already wedged; see * the note at the probe. */ const PROBE_RELIEF = [1, 0.4]; const STUCK_WINDOW = 1.6; const STUCK_DISTANCE = 0.15; /** Another robot this close and roughly ahead makes this one wait. */ const YIELD_RANGE = 0.95; const YIELD_CONE = 0.4; /** Seconds for the walk pose to fade in or out when a robot starts or stops. */ const GAIT_EASE = 0.24; /** * The biggest step a single tick may take, in seconds. * * A backgrounded tab hands back a `dt` of whole seconds when it wakes, and an * unclamped robot would move several metres in one step — through a wall, since * the collider is a capsule test against that step and a step that long sweeps * across whole rooms. Clamping means a robot that was in a background tab is * simply where it was, which is right: nobody was watching. */ const MAX_STEP = 0.1; // ---- Errands -------------------------------------------------------------- /** * What a robot went somewhere *for*. * * It decides exactly two things — how often that kind of place gets picked, and * how long a robot stands there once it arrives — and those two are most of the * difference between a crowd that is working and a crowd that is patrolling. * Nothing about the walk itself branches on it. * * - **`desk`** is a seat. `Plan` publishes every one of them with a `facing`, * which is the whole reason this exists: arriving at a named spot and * turning to the angle that spot says is the single detail that reads as * purpose. See `DESK_STANDOFF` for why the robot stops short of the seat * rather than on it. * - **`fixture`** is an authored prop — a whiteboard, a locker, a shelf, a * meeting chair. Identified by what it is *not*: not bound to a seat and not * generated by a desk bank, so it is something the pack author put there on * purpose rather than the second half of a workstation. * - **`room`** is a room's centroid. The only kind with no facing, and the * only kind that is about the building rather than about the furniture — * it is what sends a robot to the kitchen or across the commons. */ type ErrandKind = "desk" | "fixture" | "room"; /** * How the three kinds are mixed, as relative weights. * * **The kind is drawn first and the address second**, and that ordering is the * point. Drawing uniformly over one flat list of addresses would let the pack * decide the mix by accident: the reference office resolves 76 seats, roughly * 150 fixtures and 17 rooms on its ground floor, so a flat draw would send a * robot to a room's centre about 9% of the time and to a desk about 31% — * neither of which anybody chose. Picking the kind first fixes the *behaviour* * and lets the pack decide only which whiteboard. * * Weighted toward desks because a desk is the strongest read and because there * are enough of them that four robots do not visibly repeat. Kinds a level has * none of are skipped and their weight goes to the others, so a pack with no * authored props still gets desks and rooms rather than a stalled robot. */ const ERRAND_MIX: readonly (readonly [ErrandKind, number])[] = [ ["desk", 0.5], ["fixture", 0.2], ["room", 0.3], ]; /** * Seconds spent standing at each kind, low and high of a uniform spread. * * A robot that pauses for the same length of time everywhere reads as a state * machine no matter how good the destinations are, so the dwell is the errand's * and not the walk's. The ordering is the story: you stand at a desk because you * are doing something there, you look at a whiteboard for a moment, and a room's * centre is somewhere you are passing through. * * The upper end matters more than it looks. Four robots with a mean dwell around * six seconds and trips that take rather longer than that leaves most of them * walking at any instant, which is the balance that reads as an office; push the * desk dwell to half a minute and you get four robots standing about. */ const DWELL: Record = { desk: [5, 12], fixture: [3, 7], room: [1.5, 4], }; /** Seconds to stand after stepping through a doorway. Short: the point was to leave. */ const DWELL_DOOR: readonly [number, number] = [0.4, 1.2]; /** * How far behind a seat a robot stops, in metres. * * **A robot must never stand on a seat**, and that is a hard rule rather than a * preference: `presence.ts` puts a person mesh at exactly `seat.position` with * exactly `seat.facing`, so a robot that treated the seat as its own destination * would stand inside whoever is sitting there. Seats are addresses for * occupants; a robot visiting one is a visitor. * * The number is sized off the chair rather than picked. `seating.ts` gives * `tera:seat.task-chair` a 0.64 m footprint — the star base, which is its widest * part — so 0.32 m from the seat centre to the chair's edge, plus the 0.28 m * robot radius, is 0.60 m before the two touch. 0.75 leaves 150 mm of air. * * That is cosmetic and not a collision guarantee: `plan.blocked` is the wall * collider and knows nothing about furniture, as the header says. It is the * difference between a robot standing at somebody's desk and a robot standing * in their chair, which is visible from every camera angle in the building. * * The offset direction falls out of `plan.ts`: a bank puts its seat at the desk * centre plus `(sin f, cos f) · seatOffset`, so stepping further along that same * ray is further from the desk — behind the occupant, looking the way they look. */ const DESK_STANDOFF = 0.75; /** * How far in front of a fixture a robot stops. * * Chosen rather than derived, because deriving it would mean asking the asset * registry for every prop's footprint, and this file deliberately does not know * that the registry exists — the same line the header draws around furniture * collision. 0.9 m is the distance a person stands from a whiteboard, and it is * far enough that the error on a fixture with a deeper footprint than expected * is a robot standing a little close rather than a robot standing inside it. * * The facing convention is the desk's, taken from `plan.ts` and not invented * here: a prop's front is its local **+Z**, `(sin r, cos r)`, because that is the * side a desk bank puts its seat on. A prop authored with a meaningless rotation * — a rug — gets a meaningless standing spot, which costs a robot a few seconds * looking at the floor and breaks nothing. */ const FIXTURE_STANDOFF = 0.9; /** * How far back along its own facing an errand's run-in starts, longest first. * * The last leg of an approach is walked *along* the facing, so the robot arrives * already lined up instead of stopping at a random angle and then pivoting. The * first entry is a turn budget: a quarter turn at `TURN_RATE` takes (π/2)/2.2 = * 0.71 s, which at `CRUISE` is 0.86 m — so 0.9 m is one right-angle's worth of * turning, and better than that in practice, because a turning robot walks * slower and therefore turns further per metre. * * The second entry is there because the first one alone is not available often * enough, and this is the measurement that says so. A run-in has to be somewhere * a robot could stand, and 0.9 m behind the standing spot is 1.65 m behind the * seat itself — which on the reference ground floor is inside a wall for 27 of * 72 desks and outside every room for 9 more, because that is what a meeting * room is. Only 36 desks got a run-in at all. Falling back to 0.45 m takes it to * 62, and the arrivals it buys are as well aligned as the long ones. * * Note what 0.45 does *not* buy, so nobody re-derives it as a bug: `REACHED` is * 0.22 and `ARRIVE` is 0.35, so a robot that clears a 0.45 m run-in can already * be inside the arrival radius and stop on the spot without walking a step of * the final leg. It still helps, because the alignment mostly comes from having * steered at a point on the destination's own axis rather than from the metre * after it. Measured against dropping the fallback entirely, it moves the 75th * percentile of arrival error from 97° to 86°; both against 90° for a robot that * simply stops where it gets to. * * Same shape as `PROBE_RELIEF`, and for the same reason: a value that is right * when there is room for it and a smaller one that is better than nothing. */ const APPROACH_RUNS = [0.9, 0.45]; /** * A prop whose base sits higher than this is not something you walk up to. * * `OPTIMUS.shoulderY` rather than a number, because the question this is asking * is "is this thing in front of the robot or above it". It exists because the * "authored prop" test catches ceiling lights: the reference ground floor * authors 81 troffers and 22 pendants, none bound to a seat, and every one of * them would otherwise be a place to stand and stare upward. * * Measured across both reference packs, the split is not close: the highest * floor-standing fixture base is a wall display at 1.15 m and the lowest light * is a troffer at 2.30 m, so the cutoff sits in the middle of a metre-wide gap * and no plausible pack lands on the boundary. */ const FIXTURE_MAX_BASE = OPTIMUS.shoulderY; /** How many addresses are scored before one is committed to. See `pickErrand`. */ const SHORTLIST = 6; /** * Seconds before somewhere a robot went is fully interesting again. * * Without this the crowd converges: the score is the same every time it is * asked, so the best desk in the building is the best desk for every robot for * the whole session. A minute is long enough that a repeat is a coincidence * rather than a rut, and the floor below keeps a just-visited address merely * unlikely rather than banned — on a level with three addresses and four robots, * banning is how you get a robot with nowhere to go. */ const REVISIT_COOLDOWN = 60; const COOL_FLOOR = 0.05; /** * Distance from the nearest other robot at which a destination stops being * penalised for crowding, and the floor under that penalty. * * "Nearest other robot" counts where they *are* and where they are *going*, so * two robots do not set off for the same whiteboard from opposite ends of the * floor and discover the problem on arrival. */ const SPREAD_FULL = 7; const SPREAD_FLOOR = 0.15; /** * What a destination in the room the robot is already standing in is worth, * against one somewhere else. * * This is the term that actually spreads the crowd through the building rather * than round one floor plate, and it is nearly free: every address knows its * room from the check that admitted it, so the only cost is one `roomAt` for the * robot itself, once per errand. */ const SAME_ROOM = 0.35; /** * Radians per second of yaw while standing still. * * Slower than `TURN_RATE` on purpose. The figure has no pivot-in-place * animation — the gait is driven by distance travelled, so a robot turning * without moving has its feet planted and swings the whole body — and the * faster that happens the more it looks like a turntable. At this rate a half * turn on the spot takes π/(2.2 · 0.55) = 2.6 s, which reads as settling. * * It is usually a small turn anyway, because `APPROACH_RUN` has the robot walk * the last stretch along the angle it is going to hold. */ const SETTLE_RATE = TURN_RATE * 0.55; // ---- Gait ----------------------------------------------------------------- /** Peak hip angle, radians. Everything else about the stride follows from it. */ const HIP_SWING = 0.34; /** * Half the ground a planted foot covers, and the ground covered by one full * two-step cycle. * * These are derived from the swing rather than chosen, and that is what decides * whether the feet push the floor or skate on it. A planted foot sits at * `LEG · sin θ` in front of the hips, so the ground one step covers is fixed by * the swing amplitude and the length of the leg, and the body has to move * exactly that far in the same time or the foot makes up the difference by * sliding. Hand-set at 1.32 m against a 0.4 rad swing, the measured slip was 65% * of distance travelled — the robots were gliding with their legs waving. * * At 1.2 m/s this is about 129 steps a minute, which is a brisk walk and the * right read for a machine with somewhere to be. */ const HALF_STEP = LEG * Math.sin(HIP_SWING); const STRIDE = 4 * HALF_STEP; const KNEE_BEND = 0.58; const ARM_SWING = 0.3; const ELBOW_SWING = 0.22; const SWAY = 0.05; const TWIST = 0.055; /** Forward lean at full speed. Small, but it is what stops a walk looking passive. */ const LEAN = 0.035; /** * A leg's hip angle at `psi`, its own phase in `[0, 2π)`: stance for the first * half, swing for the second. * * **The stance half is an arcsine and not a sine, and that is the whole point of * this function.** A sine looks like the obvious choice and it is wrong for a * reason that is easy to miss: a planted foot has to travel backwards under the * body at *exactly* walking speed, which means its position is linear in time, * which means the hip angle is the arcsine of a straight line. Drive the hip * with a sine instead and the foot's backward speed is fastest as the leg passes * vertical and zero at the ends of the stance, so it matches the body's speed at * one instant per step and slides for the rest of it. Worse, both legs pass * vertical at the same moment, so there is no instant at which either foot is * genuinely planted. Measured: 30% of distance travelled came out as foot slip * with everything else already tuned, and no amount of adjusting the stride * length got it below about a quarter, because the shape was wrong rather than * the scale. * * The swing half is a cubic Hermite from the back of the stride to the front * whose end slopes are the stance's own — `−2 tan(HIP_SWING)` at both — so the * thigh does not visibly jerk at toe-off or at heel strike. Nothing about the * swing affects foot slip, because the foot is in the air for all of it; it only * has to be smooth and to arrive in the right place. */ function legAngle(psi: number): number { if (psi < Math.PI) { const u = psi / Math.PI; return Math.asin(Math.sin(HIP_SWING) * (1 - 2 * u)); } const v = (psi - Math.PI) / Math.PI; const slope = -2 * Math.tan(HIP_SWING); const v2 = v * v; const v3 = v2 * v; return ( (2 * v3 - 3 * v2 + 1) * -HIP_SWING + (v3 - 2 * v2 + v) * slope + (-2 * v3 + 3 * v2) * HIP_SWING + (v3 - v2) * slope ); } /** * How bent a leg's knee is at `psi`, as a fraction of `KNEE_BEND`. * * Zero for the whole of stance, and that is deliberate rather than lazy: a bent * stance knee shortens the leg, and the body's height is computed from the * stance leg being straight. Bend it and the planted foot either floats or sinks * by the difference. So the knee does all of its work in the air, which is also * the only place it is doing anything useful — lifting the foot over the floor. * * `sin²` rather than `sin` so the bend starts and ends with zero rate and there * is no kink at toe-off. */ function kneeFlex(psi: number): number { if (psi < Math.PI) return 0; const wave = Math.sin(psi - Math.PI); return wave * wave; } /** * Pose one figure for a distance travelled and a gait strength. * * `distance` is metres since the robot was created — monotonic, never reset, so * the cycle is continuous across every stop and start. `gait` is 0 for standing * still and 1 for walking, and every joint is *interpolated* between its rest * value and its walking value by it, so `gait === 0` reproduces `restOptimus` * exactly and a robot easing to a halt settles rather than snapping. * * Signs, all of which are the header of `optimus.ts` applied: hips, shoulders * and elbows bend positive, knees bend **negative**, positive `rotation.y` turns * left and positive `rotation.z` leans left. */ function pose(j: OptimusJoints, distance: number, gait: number): void { const phase = (distance / STRIDE) * Math.PI * 2; /** * Two waves, a quarter cycle apart, and using the wrong one is the mistake * this comment exists to stop somebody making a second time. * * `s` peaks when the legs are **passing each other** — mid-stance. Lateral * sway and the head's counter-lean belong on it, because that is genuinely * when a walking body is furthest over its planted foot. * * `swing` peaks when the legs are **furthest apart** — heel strike. Anything * that counter-balances the legs belongs on it: the arms, the elbows, and the * twist through the waist. * * They were all on `s` at first, which put the arms a quarter cycle early: at * the instant the left leg reached full forward the left shoulder was at dead * neutral, and both arms hit their extremes as the legs passed vertical * together. It reads as a figure whose arms are swinging to a different beat * from its legs, which is uncanny in a way that is hard to name until it is * pointed at. * * `swing` is derived from the leg angle itself rather than restated as * `cos(phase)`, so the two cannot drift apart if `legAngle` is ever reshaped. */ const s = Math.sin(phase); // The left leg's own phase, and the right exactly half a cycle behind it — // so one leg is always in stance and the other always in swing, and there is // no moment when the robot is standing on neither. const psiL = ((phase % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); const psiR = (psiL + Math.PI) % (Math.PI * 2); const hipL = legAngle(psiL); const hipR = legAngle(psiR); // −1..1, in step with the legs. See the note on `s` above. `HIP_SWING` is a // non-zero literal, so no guard is needed and TypeScript will say so if that // ever stops being true. const swing = hipL / HIP_SWING; j.hipL.rotation.x = OPTIMUS_REST.hipX + gait * (hipL - OPTIMUS_REST.hipX); j.hipR.rotation.x = OPTIMUS_REST.hipX + gait * (hipR - OPTIMUS_REST.hipX); const kneeL = -KNEE_BEND * kneeFlex(psiL); const kneeR = -KNEE_BEND * kneeFlex(psiR); j.kneeL.rotation.x = OPTIMUS_REST.kneeX + gait * (kneeL - OPTIMUS_REST.kneeX); j.kneeR.rotation.x = OPTIMUS_REST.kneeX + gait * (kneeR - OPTIMUS_REST.kneeX); // Arms counter-swing to the legs: the left arm goes forward with the right // leg. Get this backwards and the figure paces like a soldier at attention, // which is a surprisingly strong and surprisingly wrong-looking effect. j.shoulderL.rotation.x = OPTIMUS_REST.shoulderX - gait * ARM_SWING * swing; j.shoulderR.rotation.x = OPTIMUS_REST.shoulderX + gait * ARM_SWING * swing; // And an elbow closes a little further on the forward stroke, which is what // stops the arms reading as two pendulums bolted to a box. `-swing` is the // left arm's own forward stroke, since it swings against the left leg. j.elbowL.rotation.x = OPTIMUS_REST.elbowX + gait * ELBOW_SWING * Math.max(0, -swing); j.elbowR.rotation.x = OPTIMUS_REST.elbowX + gait * ELBOW_SWING * Math.max(0, swing); // The body's height is not a bob that was dialled in. It is where the hips // have to be for the straight, planted, stance leg to reach the floor: // `LEG · cos θ`, exactly. That puts the body at its highest as the stance leg // passes vertical and at its lowest at heel strike and toe-off, twice per // cycle, which is what a real gait does and is not something this had to be // told. Damping it — an earlier version scaled it to a third, to stop an // imagined pogo — was most of that 65% of foot slip. It does not pogo: the // whole travel is 48 mm, about what a walking person's head does. const stance = psiL < Math.PI ? hipL : hipR; j.pelvis.position.y = OPTIMUS.hipY - gait * LEG * (1 - Math.cos(stance)); // One consequence of all this, stated so nobody spends an afternoon on it: // the figure has no ankle joint, so a foot pitches with its shin and the toe // passes about 50 mm under the floor plane at the ends of each stride. That is // hidden by the floor slab, and what remains visible above it is a heel strike // and a toe-off the rig never had to be given. Correcting it would need a // twelfth joint and would cost every robot two more meshes. // Hips twist one way, shoulders the other. `torso` is a child of `pelvis`, so // its rotation adds: −2× puts the shoulders at −1× in world space. j.pelvis.rotation.y = gait * TWIST * swing; j.torso.rotation.y = -gait * TWIST * 2 * swing; j.torso.rotation.z = gait * SWAY * s; j.torso.rotation.x = gait * LEAN; // The head keeps about half the lean instead of all of it. A head that stays // perfectly level looks gimballed; one that swings with the chest looks // drunk. j.head.rotation.z = -gait * SWAY * 0.55 * s; } // ---- Layer ---------------------------------------------------------------- /** Where one robot lives. A robot belongs to its level for its whole life. */ export interface RobotSpec { levelId: string; /** For the caller's own bookkeeping. Defaults to `robot-1`, `robot-2`, … */ id?: string; } export interface RobotLayerOptions { /** The office's material registry. Its `paper` and `screenBezel` roles are used. */ materials: MaterialRegistry; /** One entry per robot. About four is the budget; see the header. */ robots: readonly RobotSpec[]; /** * Seeds where the robots start and where they wander. Change it to reshuffle * the whole crowd; leave it and a reload puts them back where they were, which * is the same discipline the rest of the scene keeps. */ seed?: number; /** Metres per second on the straight. Defaults to 1.2. */ speed?: number; /** Collision radius. Defaults to 0.28 — see `RADIUS`. */ radius?: number; } /** One robot's world position, live. See `RobotLayer.robots`. */ export interface RobotView { id: string; levelId: string; /** * Office-world metres, at the robot's feet. **Updated in place** every tick — * hold the reference, read it, and do not write to it. */ position: THREE.Vector3; } export interface RobotLayer { group: THREE.Group; tick(dt: number): void; /** * Every robot, for anything that wants to react to one — a ceiling light * brightening as one passes under it, a minimap dot, an occupancy heatmap. * * The array and the `Vector3`s in it are **stable and live**: the same objects * come back every call and their contents change under you. That is * deliberate, because the caller for this is a per-frame loop and allocating * four vectors sixty times a second to answer the same question is exactly the * kind of garbage that shows up as a stutter and not as a profile entry. If * you need a snapshot, clone what you take. */ robots(): readonly RobotView[]; dispose(): void; } /** * A direction a robot may walk in, and the clearance the probe settled for to * find it. There is exactly one of these per layer and it is scratch — see * `chooseHeading`. */ interface Heading { heading: number; clearance: number; } /** Everything about one robot that changes. */ interface Robot { view: RobotView; level: LevelPlan; rig: OptimusRig; yaw: number; target: Point2 | null; /** Seconds left of the current stand-still. Only meaningful with no target. */ wait: number; /** * A yaw to turn to while standing still, or nothing to stand as it stopped. * **Only meaningful with no target**, and cleared once reached, so a robot * that settles onto its seat's facing and then waits out the rest of its dwell * is doing no work at all. */ settle: number | null; /** * The errand's plan for the moment of arrival: the yaw to hold and the seconds * to hold it for. Chosen when the destination is, spent when it is reached — * `arriveFacing` becomes `settle` and `arriveDwell` becomes `wait`. * * Two fields rather than one small object because a destination is picked * every few seconds per robot and this file allocates only where it must. */ arriveFacing: number | null; arriveDwell: number; /** 0 standing, 1 walking. Eased, never snapped. See the header. */ gait: number; /** Metres travelled ever. Drives the walk cycle and is never reset. */ distance: number; /** Metres travelled since the watchdog last looked, and how long ago that was. */ sinceCheck: number; checkAge: number; /** * An intermediate point to reach before `target`, or nothing. * * There is at most one, ever, and that is the rule that keeps this from * becoming a path — see `pickDoor` for why one is enough and two would need a * graph. It is either a **doorway**, when a robot could see nowhere to go and * is leaving the room, or an errand's **run-in**, when the last stretch is * walked along the angle the robot is going to hold on arrival. Both are * checked as two independent legs, which is the only reason either works. */ waypoint: Point2 | null; /** The opening this robot last walked through, so it does not turn straight round. */ lastDoor: string | null; rand: () => number; } export function createRobotLayer(plan: Plan, options: RobotLayerOptions): RobotLayer { const group = new THREE.Group(); group.name = "robots"; const speed = options.speed ?? CRUISE; const radius = options.radius ?? RADIUS; const seed = options.seed ?? 0x0117; // One figure is built and the rest are clones of it, so the crowd costs draw // calls and no memory. The prototype is never added to the scene; it exists // only to be cloned from and to be disposed at the end, because it is the one // that owns the geometry every clone points at. const ctx = createAssetContext({ materials: options.materials }); const prototype = buildOptimus(ctx); const robots: Robot[] = []; const views: RobotView[] = []; // Scratch, reused every frame. Four robots at sixty frames is 240 chances a // second to allocate a `Point2` for nothing. `chosen` is the same discipline // applied to the one place it had been forgotten; see `chooseHeading`, which // is the only thing allowed to write to it. const from: Point2 = { x: 0, z: 0 }; const to: Point2 = { x: 0, z: 0 }; const chosen: Heading = { heading: 0, clearance: 0 }; /** * A level's rooms with a running area total, so a candidate point can be * drawn from the *floor* rather than from the level's bounding box. * * Sampling the bounding box was the first version, and it is why this exists. * A level's bounds are the whole building's extent, a level's rooms cover * maybe a third of it and a mezzanine covers a tenth, so most candidates * landed in the void outside the walls and most picks failed. Measured over * twenty simulated minutes in the reference office, the crowd spent 82% of it * standing still waiting to retry — and worst on exactly the small upper floor * where a single robot is most conspicuous, which walked 49 m to the ground * floor's 393. Choosing a room first puts nearly every candidate somewhere a * robot could actually stand. * * Weighted by area rather than uniformly over rooms, because uniform sends a * robot into the 6 m² phone booth as often as into the 400 m² floor plate, * and what that looks like is four robots queueing for a cupboard. */ interface Sampler { rooms: readonly ResolvedRoom[]; /** Running area totals, one per room; the last one is `total`. */ cumulative: readonly number[]; total: number; /** Every opening a walker fits through. `Plan` has already decided which. */ doors: readonly ResolvedOpening[]; } const samplers = new Map(); function samplerFor(level: LevelPlan): Sampler { const hit = samplers.get(level.id); if (hit) return hit; const cumulative: number[] = []; let total = 0; for (const room of level.rooms) { total += room.area; cumulative.push(total); } const made: Sampler = { rooms: level.rooms, cumulative, total, doors: level.openings.filter((opening) => opening.passable), }; samplers.set(level.id, made); return made; } /** * One candidate: somewhere a robot could stand — inside a room, and clear of * every wall by its own radius. * * A room's `bounds` are its bounding box and a room need not be rectangular, * so the `roomAt` check is not made redundant by having chosen a room first: * it is what rejects the missing corner of an L-shaped floor plate. It also * *re-resolves* which room the point is in, and later rooms win, which is the * answer you want — a point that lands inside a meeting room while sampling * the open floor's bounding box is a point in the meeting room, and standing * there is fine. * * `blocked` with the same point at both ends is a degenerate capsule, which is * exactly a point-to-wall distance test. Reusing it rather than writing one * keeps the "how much room does a robot need" arithmetic in the one place * `Plan` already documents it. */ function trySample(sampler: Sampler, levelId: string, rand: () => number, into: Point2): boolean { if (!(sampler.total > 0)) return false; const roll = rand() * sampler.total; let index = sampler.cumulative.length - 1; for (let i = 0; i < sampler.cumulative.length; i++) { if (roll <= (sampler.cumulative[i] ?? 0)) { index = i; break; } } const room = sampler.rooms[index]; if (!room) return false; into.x = room.bounds.minX + rand() * room.bounds.width; into.z = room.bounds.minZ + rand() * room.bounds.depth; if (!plan.roomAt(levelId, into)) return false; return !plan.blocked(levelId, into, into, radius); } /** Somewhere on this level a robot could stand, or nothing. Used to place one. */ function samplePoint(level: LevelPlan, rand: () => number, into: Point2): boolean { const sampler = samplerFor(level); for (let i = 0; i < PICK_ATTEMPTS; i++) { if (trySample(sampler, level.id, rand, into)) return true; } return false; } // ---- The address book --------------------------------------------------- /** * Somewhere worth going, and what to do on arrival. * * Every field except `visitedAt` is decided once, when the level's book is * built, and never changes — which is what makes an errand cost one `blocked` * call to commit to instead of four. `at` has already been checked to be * indoors and clear of every wall, and `approach` has been checked the same way * *plus* the leg between the two, so by the time a robot is choosing, the only * open question is whether it can see the thing from where it is standing. */ interface Address { kind: ErrandKind; /** Where the robot ends up standing. Never on a seat; see `DESK_STANDOFF`. */ at: Point2; /** The yaw to hold once there, or nothing to stop on the arrival heading. */ facing: number | null; /** * Where the last leg starts, so the robot walks in already lined up. Absent * when there is no facing to line up with, or when the run-in does not fit — * a desk in an alcove with its back 0.5 m from a wall, say, which is still a * perfectly good place to stand and simply gets approached from wherever. */ approach: Point2 | null; /** Which room `at` is in. Falls out of the check that admitted it; see `SAME_ROOM`. */ roomId: string; /** Layer clock when a robot last set out for here. See `REVISIT_COOLDOWN`. */ visitedAt: number; } /** One level's addresses, split by kind because the kind is drawn first. */ interface AddressBook { desk: Address[]; fixture: Address[]; room: Address[]; } /** * Seconds of simulated time since the layer was made, advanced by the same * clamped `dt` the robots move on — so a tab that was asleep for a minute does * not come back to a crowd whose cooldowns have all expired at once, for the * same reason it does not come back to robots three rooms away. */ let clock = 0; const books = new Map(); /** The id of the room a robot could stand at this point in, or nothing. */ function standable(levelId: string, point: Point2): string | null { const room = plan.roomAt(levelId, point); if (!room) return null; return plan.blocked(levelId, point, point, radius) ? null : room.id; } /** * One address, if a robot can stand at it. * * The rejections here are the whole reason this is done once per level rather * than per pick: a desk pushed against a wall, a whiteboard in a stairwell, a * fixture whose front is inside a partition, a centroid outside its own * L-shaped room. Every one of those is a fact about the pack that never * changes, and paying for it at 60 Hz would be the expensive way to learn it. */ function makeAddress( level: LevelPlan, kind: ErrandKind, at: Point2, facing: number | null, ): Address | null { const roomId = standable(level.id, at); if (roomId === null) return null; let approach: Point2 | null = null; if (facing !== null) { for (const run of APPROACH_RUNS) { // Back along the facing: the robot walks from here to `at` looking the // way `at` says, so the run-in and the hold are the same direction. const back: Point2 = { x: at.x + Math.sin(facing) * run, z: at.z + Math.cos(facing) * run, }; if (standable(level.id, back) === null) continue; // Measured on both reference packs: this leg has never once been the // thing that failed, because it is short and colinear with two points // already known to be clear. It is checked anyway — a pack is allowed to // put a partition between a desk and the space behind it, and finding // that out at 60 Hz with a robot walking through it is not the way. if (plan.blocked(level.id, back, at, radius)) continue; approach = back; break; } } // Far enough in the past that everything starts fully interesting, without // any special case for "never visited" in the scoring. return { kind, at, facing, approach, roomId, visitedAt: -REVISIT_COOLDOWN }; } /** * Every place on a level worth walking to, built once and kept. * * Cost is a few `roomAt` and `blocked` calls per candidate — up to three of * each for a facing address — over every seat, every authored prop and every * room on the level. On the reference ground floor that is 76 seats, 147 * qualifying props and 17 rooms, and it is paid on the frame the first robot * on that level picks its first destination and never again. Doing it eagerly * at construction would move the same work to a worse moment, since a level * with no robots on it never needs a book at all. * * Seats come from `level.seats` rather than `plan.allSeats()` on purpose: a * robot belongs to one level for its whole life, and the building's seat list * would offer it addresses on a floor it can never reach. */ function bookFor(level: LevelPlan): AddressBook { const hit = books.get(level.id); if (hit) return hit; const made: AddressBook = { desk: [], fixture: [], room: [] }; for (const seat of level.seats) { const spot = makeAddress( level, "desk", { x: seat.position.x + Math.sin(seat.facing) * DESK_STANDOFF, z: seat.position.z + Math.cos(seat.facing) * DESK_STANDOFF, }, seat.facing, ); if (spot) made.desk.push(spot); } for (const prop of level.props) { // A prop bound to a seat, or generated by a desk bank, is the furniture of // a workstation — the seat itself is already a better address for it, and // adding the desk and the chair as well would put three addresses on one // spot and weight the whole floor toward whichever room has the most desks. if (prop.seat !== undefined || prop.source !== undefined) continue; // `position.y` is the base of the prop with the level's elevation already // in it, so the level's own floor has to come back out before it can be // compared with a height on the robot. if (prop.position.y - level.floorY > FIXTURE_MAX_BASE) continue; const spot = makeAddress( level, "fixture", { x: prop.position.x + Math.sin(prop.rotation) * FIXTURE_STANDOFF, z: prop.position.z + Math.cos(prop.rotation) * FIXTURE_STANDOFF, }, prop.rotation, ); if (spot) made.fixture.push(spot); } for (const room of level.rooms) { // No facing: there is nothing at a room's centre to look at, and inventing // one — face the longest wall, face the door — would be a guess dressed up // as intent. A robot arriving at a centroid stops looking the way it came // in, which is into the room, which is enough. // // `centroid` is the area centroid and a room need not be convex, so this // can land outside its own outline; `makeAddress` drops those rather than // falling back to the bounding box centre, which is not more likely to be // inside. Both reference packs have none. const spot = makeAddress(level, "room", { x: room.centroid.x, z: room.centroid.z }, null); if (spot) made.room.push(spot); } books.set(level.id, made); return made; } // Scratch for the shortlist, reused by every robot on every pick. Same // discipline as `from`, `to` and `chosen`: nothing here outlives the call that // fills it, and `pickErrand` is the only thing allowed to read or write it. const shortlist: (Address | null)[] = new Array(SHORTLIST).fill(null); const shortlistScore: number[] = new Array(SHORTLIST).fill(0); /** * Which kind of errand this one is, weighted by `ERRAND_MIX` over the kinds * this level actually has any of. * * The empty-kind skip is not defensive coding for its own sake. Nothing in the * office contract obliges a level to have seats, or props, or more than one * room — the second reference pack's mezzanine resolves three desks and a * single room, and a floor of meeting rooms with no authored furniture is an * ordinary thing to write. A weight table that did not renormalise would spend * a fifth of its draws on an empty list and fail whole picks for no reason, * which presents as a robot that thinks for a second and a half. */ function drawKind(rand: () => number, book: AddressBook): ErrandKind | null { let total = 0; let last: ErrandKind | null = null; for (const [kind, weight] of ERRAND_MIX) { if (book[kind].length === 0) continue; total += weight; last = kind; } if (last === null) return null; let roll = rand() * total; for (const [kind, weight] of ERRAND_MIX) { if (book[kind].length === 0) continue; roll -= weight; if (roll <= 0) return kind; } // Floating-point slop only: the loop above subtracts exactly `total`. return last; } /** * How much this robot wants this address, as a number in (0, 1]. * * Three factors, multiplied, and each one exists to stop a specific way four * robots stop looking like four people: * * - **Cooldown**, so the crowd does not converge on the same few best * addresses and pace a rut between them for the rest of the session. * - **Elbow room**, so a robot does not walk across the building to stand * where another one already is. Other robots' *destinations* count as much * as their positions, which is the half that stops two robots setting off * for the same desk and discovering it on arrival. * - **Somewhere else**, so a robot in the kitchen tends to leave the * kitchen. This is the term that spreads the crowd through the building * rather than round one room, and it is the cheapest of the three. * * Multiplied rather than summed, because these are qualities a destination can * lack independently and a sum lets one good factor carry two bad ones — the * desk you were just at, with another robot already standing at it, would * still score well for being in the next room. The floors under the first two * keep the product away from zero, so a level with only bad options still * produces an ordering rather than a tie. */ function scoreAddress(robot: Robot, address: Address, hereRoom: string | null): number { const cool = Math.max(COOL_FLOOR, Math.min(1, (clock - address.visitedAt) / REVISIT_COOLDOWN)); let nearest = Infinity; for (const other of robots) { if (other === robot || other.level.id !== robot.level.id) continue; const here = other.view.position; nearest = Math.min(nearest, Math.hypot(here.x - address.at.x, here.z - address.at.z)); const bound = other.target; if (bound) { nearest = Math.min(nearest, Math.hypot(bound.x - address.at.x, bound.z - address.at.z)); } } const elbow = nearest === Infinity ? 1 : Math.max(SPREAD_FLOOR, Math.min(1, nearest / SPREAD_FULL)); return cool * elbow * (address.roomId === hereRoom ? SAME_ROOM : 1); } /** * Pick somewhere with a reason to be there, or fail and let the caller fall * back to the sampler. * * Shortlist, then commit — and the split is what keeps this cheap. Scoring is * arithmetic over four robots and costs nothing, so `SHORTLIST` candidates are * drawn and ranked without touching the collider at all; only then is line of * sight tested, best first, and the first one that can be seen wins. The * measured cost is one `plan.blocked` call for most picks, because the best * candidate is usually visible, and at most `SHORTLIST` of them. * * That is strictly cheaper than the sampler it replaced, which spent up to two * `blocked` calls on each of `PICK_ATTEMPTS` candidates and still ended up * somewhere with no name — and it is most of why the whole tick got faster * rather than slower. */ function pickErrand(robot: Robot): boolean { const book = bookFor(robot.level); const here = robot.view.position; const hereRoom = plan.roomAt(robot.level.id, here)?.id ?? null; let filled = 0; for (let i = 0; i < SHORTLIST; i++) { const kind = drawKind(robot.rand, book); if (kind === null) return false; const pool = book[kind]; const candidate = pool[Math.floor(robot.rand() * pool.length)]; if (!candidate) continue; // Same rule as the sampler's: too close and the robot shuffles rather than // walks, and the walk is the part anybody sees. if (Math.hypot(candidate.at.x - here.x, candidate.at.z - here.z) < MIN_TRIP) continue; // Insertion sort, best first. Six entries at most, so this is a handful of // compares and — unlike sorting an array of pairs — no allocation. const value = scoreAddress(robot, candidate, hereRoom); let slot = filled; while (slot > 0 && (shortlistScore[slot - 1] ?? 0) < value) { shortlist[slot] = shortlist[slot - 1] ?? null; shortlistScore[slot] = shortlistScore[slot - 1] ?? 0; slot--; } shortlist[slot] = candidate; shortlistScore[slot] = value; filled++; } for (let i = 0; i < filled; i++) { const address = shortlist[i]; if (!address) continue; // The run-in is taken whenever there is one, from wherever the robot is // standing — including from the far side, where taking it means walking // past the destination and coming back at it. That looked like the wrong // trade and the measurement said otherwise. Taking it only from the near // side, on the sign of a dot product, halved how often it was used at all // — 128 of 399 picks over half an hour rather than 267 — and left the // median arrival 99° off the angle it was supposed to hold. Taking it // always costs 1.3% more walking and brings that median to 9°. const goal = address.approach ?? address.at; from.x = here.x; from.z = here.z; if (plan.blocked(robot.level.id, from, goal, radius)) continue; // Copied rather than aliased. The address is shared by every robot and // lives for the whole session; `waypoint` and `target` are one robot's and // are cleared and replaced constantly, and one line that reached for // `robot.target.x = …` would quietly move the desk for everybody. robot.waypoint = address.approach === null ? null : { x: address.approach.x, z: address.approach.z }; robot.target = { x: address.at.x, z: address.at.z }; robot.arriveFacing = address.facing; const [low, high] = DWELL[address.kind]; robot.arriveDwell = low + robot.rand() * (high - low); // Heading somewhere with a name, so the last door stops defining this // robot — the same reasoning as the sampler's, and the reason a long // circuit can come back through the door it left by. robot.lastDoor = null; address.visitedAt = clock; return true; } return false; } /** * A doorway to head for when nowhere in the room is worth walking to. * * This is the fix for the one thing pure line-of-sight sampling cannot do, and * it is not a small thing: a robot inside a small room can see almost nothing * outside it, because every candidate has to be visible through a 0.9 m gap * with 0.28 m of clearance either side. Measured on the reference office, three * of four robots wandered into a kitchen, a stair core and a 2 × 2 m phone * booth within the first minute and then stood there for the remaining * nineteen. Not vibrating, not erroring — parked, forever, which is a worse * failure than a visible one because it looks deliberate. * * So a robot that cannot see anywhere to go walks to a door instead: a * **waypoint** at the middle of the opening and a target `THROUGH_DOOR` metres * past it, so it ends up in the next space with sight lines into it rather * than stopped in the gap still looking at the room it wanted to leave. * `Plan` has already decided which openings a walker fits through — the * `passable` flag is the wall split's own answer, computed from the same sill * and head heights that punched the hole — so this invents no geometry and * cannot disagree with the collider. * * The waypoint is the difference between working and not. A 0.9 m door with a * 0.28 m robot leaves 0.22 m of usable width once both jambs are cleared, so a * single straight line from an off-axis corner of a room to a point beyond the * door misses by centimetres and the whole door is rejected — which is what * left the last robot in a phone booth after every other fix. Split into two * legs, each checked on its own, both are easy: any point in the room can see * the middle of its own door, and the middle of a door can always see straight * out of it. Which is the general shape of the thing: **one** waypoint, chosen * from data `Plan` already publishes. Two would be a path, and a path needs a * graph, and a graph is the navmesh this file exists to not build. * * Two passes over the doors, and the second one is why a booth works. The * first skips the door this robot last came through, so a robot that has just * walked into the open floor does not turn straight round. The second allows * it, because a room with exactly one door — a booth, a store, a server room — * has no other way out, and refusing to reuse it is refusing to leave. */ function pickDoor(robot: Robot): boolean { const doors = samplerFor(robot.level).doors; if (doors.length === 0) return false; const here = robot.view.position; const beyond: Point2 = { x: 0, z: 0 }; // Started at a random index rather than at zero, so a robot with two doors // in sight does not always take the same one and pace a rut between two // rooms for the rest of the session. const start = Math.floor(robot.rand() * doors.length); for (const allowLast of [false, true]) { for (let i = 0; i < doors.length; i++) { const door = doors[(start + i) % doors.length]; if (!door) continue; if (!allowLast && door.id === robot.lastDoor) continue; const dx = door.center.x - here.x; const dz = door.center.z - here.z; if (Math.hypot(dx, dz) < MIN_DOOR) continue; from.x = here.x; from.z = here.z; if (plan.blocked(robot.level.id, from, door.center, radius)) continue; // A wall at yaw φ runs along (cos φ, −sin φ), so its normal is // (sin φ, cos φ). Step out along whichever end of that normal is // further from the robot — that is the far side, which is the side // worth going to. const nx = Math.sin(door.yaw); const nz = Math.cos(door.yaw); const sign = dx * nx + dz * nz >= 0 ? 1 : -1; beyond.x = door.center.x + sign * THROUGH_DOOR * nx; beyond.z = door.center.z + sign * THROUGH_DOOR * nz; if (!plan.roomAt(robot.level.id, beyond)) continue; if (plan.blocked(robot.level.id, door.center, beyond, radius)) continue; robot.waypoint = { x: door.center.x, z: door.center.z }; robot.target = { x: beyond.x, z: beyond.z }; robot.arriveFacing = null; robot.arriveDwell = DWELL_DOOR[0] + robot.rand() * (DWELL_DOOR[1] - DWELL_DOOR[0]); robot.lastDoor = door.id; return true; } } return false; } /** * A random reachable point on the floor. The fallback, and no longer the plan. * * This used to be the whole of destination selection, and everything that read * as patrolling was here: a point drawn from a room's bounding box is not a * place, it is a coordinate — so a robot walked to the middle of nowhere, * stopped at whatever angle it happened to arrive at, waited a fixed-ish beat * and set off again. `pickErrand` runs first now, and this catches the two * things it cannot do: a robot in a room with no address it can see, and a * pack that authors no seats, no props and no usable centroids at all. * * It is worth keeping precisely because it asks so little of the pack. An * office is a `Plan`, and a `Plan` is allowed to be four walls and a door. * * The line-of-sight test is against the segment from here to there, and a * segment includes its endpoints — so this is also the check that the * destination itself has room to stand in, and there is no separate one. */ function pickWander(robot: Robot): boolean { const candidate: Point2 = { x: 0, z: 0 }; const level = robot.level; const sampler = samplerFor(level); const here = robot.view.position; for (let i = 0; i < PICK_ATTEMPTS; i++) { if (!trySample(sampler, level.id, robot.rand, candidate)) continue; if (Math.hypot(candidate.x - here.x, candidate.z - here.z) < MIN_TRIP) continue; from.x = here.x; from.z = here.z; if (plan.blocked(level.id, from, candidate, radius)) continue; robot.target = { x: candidate.x, z: candidate.z }; robot.waypoint = null; // Nothing there to look at and no reason to linger, so a shrug of a pause // and off again. robot.arriveFacing = null; robot.arriveDwell = PAUSE_MIN + robot.rand() * (PAUSE_MAX - PAUSE_MIN); // Somewhere in the open: this robot is no longer defined by the last door // it used, and forgetting it is what lets a long circuit of the building // come back through the same doorway without a special case. robot.lastDoor = null; return true; } return false; } /** * Choose somewhere to walk to, or fail. * * Three tiers, in descending order of how much the destination means: * somewhere with a name and a facing, then anywhere at all on this floor, then * out through the nearest door. A robot reaches the second only because it can * see no address from where it is standing, and the third only because it can * see nothing at all — which is why the order is this way round, and it is a * happy accident of the shortlist that the tier that means the most is also * the one that costs the least. * * Failure is still a normal outcome, not an error — a robot boxed into a * corner with no door in sight will wait and try again from wherever it is — * and nothing is logged, because a robot with nowhere to go looks exactly like * a robot taking a moment. * * Every tier sets `target`, `waypoint`, `arriveFacing` and `arriveDwell` * rather than returning a destination, because two of the three have to set a * waypoint as well and a function that returned one field and mutated three * would be the worst of both. */ function pickTarget(robot: Robot): boolean { if (pickErrand(robot)) return true; if (pickWander(robot)) return true; return pickDoor(robot); } /** Whether a robot could walk `PROBE_AHEAD` metres on this heading with `clearance` to spare. */ function clearAhead(robot: Robot, heading: number, clearance: number): boolean { const here = robot.view.position; from.x = here.x; from.z = here.z; to.x = here.x - Math.sin(heading) * PROBE_AHEAD; to.z = here.z - Math.cos(heading) * PROBE_AHEAD; return !plan.blocked(robot.level.id, from, to, clearance); } /** Whether another robot on the same level is close enough and far enough ahead to yield to. */ function shouldYield(robot: Robot): boolean { const here = robot.view.position; const fx = -Math.sin(robot.yaw); const fz = -Math.cos(robot.yaw); for (const other of robots) { if (other === robot || other.level.id !== robot.level.id) continue; const dx = other.view.position.x - here.x; const dz = other.view.position.z - here.z; const distance = Math.hypot(dx, dz); if (distance > YIELD_RANGE || distance < 1e-4) continue; if ((dx * fx + dz * fz) / distance > YIELD_CONE) return true; } return false; } /** * Stop, and stand there for `seconds`. * * `settle` is the yaw to turn to while standing, and it is a parameter rather * than something read off the robot because the two callers want opposite * things from it. Arriving somewhere passes the errand's facing — that is the * point of the errand. Giving up — wedged, deadlocked, watchdogged — passes * `null`, because a robot that failed to get somewhere has no business * adopting the pose of having got there. */ function beginPause(robot: Robot, seconds: number, settle: number | null): void { robot.target = null; robot.waypoint = null; robot.wait = seconds; robot.settle = settle; robot.sinceCheck = 0; robot.checkAge = 0; } // ---- Population --------------------------------------------------------- const warned = new Set(); options.robots.forEach((spec, index) => { const level = plan.level(spec.levelId); if (!level) { if (!warned.has(spec.levelId)) { warned.add(spec.levelId); console.warn(`[tera/interiors] no level "${spec.levelId}" for a robot; skipping it`); } return; } // Seeded per robot rather than from one shared stream, so adding a fifth // robot does not move the other four. Same reasoning as `furnish.ts` keying // its randomness on the batch rather than on a counter. const rand = seededRandom(seed + index * 0x9e37); const start: Point2 = { x: 0, z: 0 }; if (!samplePoint(level, rand, start)) { // Nowhere on this level a robot fits. That is a fact about the pack — a // level of corridors narrower than 0.56 m, or one with no rooms — and it // is worth one line, because the symptom otherwise is a robot that is // simply absent with no explanation anywhere. console.warn( `[tera/interiors] found nowhere to stand on level "${level.id}" after ` + `${PICK_ATTEMPTS} tries; that robot is not in the scene`, ); return; } const rig = cloneOptimus(prototype); rig.root.name = spec.id ?? `robot-${index + 1}`; rig.root.position.set(start.x, level.floorY, start.z); rig.root.rotation.y = rand() * Math.PI * 2; group.add(rig.root); const view: RobotView = { id: rig.root.name, levelId: level.id, position: new THREE.Vector3(start.x, level.floorY, start.z), }; views.push(view); robots.push({ view, level, rig, yaw: rig.root.rotation.y, target: null, // Staggered, so four robots do not all set off on the same frame. wait: rand() * PAUSE_MAX, // Nothing to settle to and nowhere to have arrived from: a robot's first // errand overwrites both of these before either is read. settle: null, arriveFacing: null, arriveDwell: PAUSE_MIN, gait: 0, distance: rand() * STRIDE, waypoint: null, lastDoor: null, sinceCheck: 0, checkAge: 0, rand, }); }); /** * The heading nearest `want` that a robot can actually walk, and the clearance * it was found at. * * `PROBE_RELIEF` is the escape hatch, and it is the reason a wedged robot * cannot stay wedged. `plan.blocked` measures from the *start* of the segment * as well as along it, so a robot standing closer to a wall than its own * radius has every direction blocked — including straight away from the wall. * The step check below is supposed to make that unreachable, and it is an * invariant rather than a proof: an earlier version of it broke, and three of * four robots spent nineteen simulated minutes welded to the spot. So if * nothing is clear at full radius the robot is allowed to be thinner until * something is, and creeps back out. No relief can push it through a wall, * because `segmentDistance` returns zero for segments that actually cross and * zero is under every clearance there is. * * Falls back to `want` itself when everything is blocked, so the caller still * turns toward where it wanted to go and simply does not move. * * **The returned object is `chosen`, every time.** This used to be a fresh * `{ heading, clearance }` per call, which is once a frame for every robot * that is moving — the same allocation-per-frame this file goes out of its * way to avoid in `from`, `to` and `RobotLayer.robots`, and it is odd that * one survived where those did not. It is scratch now, and it is safe * *because* of how it is used: `step` reads both fields on the line after the * call and never keeps the reference. Anything that wants to hold on to a * choice — comparing this frame's against last frame's, say — has to copy the * two numbers out, or it will find that both of them changed underneath it on * the next robot's turn. */ function chooseHeading(robot: Robot, want: number): Heading { for (const relief of PROBE_RELIEF) { const clearance = radius * relief; for (const offset of PROBE_TURNS) { if (clearAhead(robot, want + offset, clearance)) { chosen.heading = want + offset; chosen.clearance = clearance; return chosen; } } } chosen.heading = want; chosen.clearance = radius; return chosen; } // ---- Step --------------------------------------------------------------- function step(robot: Robot, dt: number): void { let moved = 0; let effort = 0; if (robot.target === null) { robot.wait -= dt; if (robot.wait <= 0) { if (pickTarget(robot)) { robot.sinceCheck = 0; robot.checkAge = 0; } else { robot.wait = RETRY_PAUSE; } } // Still nothing to walk to, so this is a robot standing somewhere on // purpose: turn it to the angle its errand asked for. Guarded on `target` // rather than sequenced before the pick because `arriveFacing` has already // been copied into `settle` by then and a pick that succeeded has replaced // it with the *next* destination's — turning toward that one from here // would have the robot aim itself across the building before setting off. // // This is the one place the yaw moves without a destination, and the // reason `SETTLE_RATE` is slower than `TURN_RATE`. if (robot.target === null && robot.settle !== null) { let swing = robot.settle - robot.yaw; swing = Math.atan2(Math.sin(swing), Math.cos(swing)); const limit = SETTLE_RATE * dt; if (Math.abs(swing) <= limit) { // Arrived at the angle. `+= swing` rather than `= settle` keeps the // yaw continuous — the facing came out of the pack and may be any // multiple of a turn away from where this robot has wound up to. robot.yaw += swing; robot.settle = null; } else { robot.yaw += limit * Math.sign(swing); } } } // Steer at the waypoint while there is one, and at the destination after // that. There is at most one waypoint and it is always a doorway. const goal = robot.waypoint ?? robot.target; if (goal && robot.target) { const here = robot.view.position; const dx = goal.x - here.x; const dz = goal.z - here.z; const remaining = Math.hypot(dx, dz); if (remaining < (robot.waypoint ? REACHED : ARRIVE)) { if (robot.waypoint) robot.waypoint = null; // Arrived. Both halves of what the errand asked for are spent here and // nowhere else: how long to stand, and which way to look while doing it. else beginPause(robot, robot.arriveDwell, robot.arriveFacing); } else { // A figure faces −Z at yaw 0, so the heading that points along (dx, dz) // is the one whose (−sin, −cos) matches it. This is the same convention // `Yaw` carries everywhere else and it is why there is no conversion. const want = Math.atan2(-dx, -dz); let error = want - robot.yaw; error = Math.atan2(Math.sin(error), Math.cos(error)); // Slow while turning and slow into the destination. The first is what // makes a robot pivot toward a doorway instead of arcing into its // frame; the second is what stops it overshooting and orbiting the // point it was aiming at. Both fall out of the walk cycle for free, // because the cycle is driven by distance — a slow robot takes short // steps rather than the same steps more slowly. const facing = Math.max(0, Math.cos(error)); const approach = Math.min(1, remaining / (ARRIVE * 2.5)); let pace = speed * facing * approach; if (shouldYield(robot)) pace = 0; // Fan out from the direction the destination wants until something is // clear. The straight line was clear when the destination was chosen, // but a robot turns on an arc rather than pivoting, so it can end up // aimed at a corner the original line missed. // // Probing around `want` and not around the robot's own heading is not a // detail. Probing around the heading, and then steering toward whatever // came back, gives *two* rate-limited turns in one frame — one toward // the destination and one toward the probe — and at equal rates they // cancel exactly. The observed symptom was a robot locked 0.95 rad off // course, pacing on the spot in a phone booth for the whole session, // with every individual line of it looking correct. One desired heading // and one turn. const choice = chooseHeading(robot, want); // The turn happens whether or not anything was clear, so a robot that // has walked into a dead end keeps rotating and finds its way out by // looking around rather than by waiting for the watchdog. let swing = choice.heading - robot.yaw; swing = Math.atan2(Math.sin(swing), Math.cos(swing)); robot.yaw += Math.min(Math.abs(swing), TURN_RATE * dt) * Math.sign(swing); // And it only walks if the direction it actually ended up facing is // clear. The turn rate caps how far the yaw got, so mid-turn the robot // faces somewhere nothing has tested; walking that is exactly how one // ends up inside the clearance band, which presents as a robot standing // in a kitchen for the rest of the session rather than as an error. if (pace > 0 && clearAhead(robot, robot.yaw, choice.clearance)) { const advance = pace * dt; robot.view.position.x -= Math.sin(robot.yaw) * advance; robot.view.position.z -= Math.cos(robot.yaw) * advance; moved = advance; effort = pace / speed; } robot.distance += moved; robot.sinceCheck += moved; robot.checkAge += dt; if (robot.checkAge >= STUCK_WINDOW) { if (robot.sinceCheck < STUCK_DISTANCE) { // Wedged, deadlocked with another robot, or aiming at somewhere it // can no longer reach. Throwing the destination away and standing // still for a moment resolves all three, and is the reason this // cannot vibrate against a wall forever. beginPause(robot, RETRY_PAUSE, null); } else { robot.sinceCheck = 0; robot.checkAge = 0; } } } } // Ease rather than snap, so a robot that stops settles its limbs over about // a quarter of a second instead of jumping to attention mid-stride. robot.gait += (effort - robot.gait) * Math.min(1, dt / GAIT_EASE); if (robot.gait < 1e-3) { robot.gait = 0; restOptimus(robot.rig.joints); } else { pose(robot.rig.joints, robot.distance, robot.gait); } robot.rig.root.position.x = robot.view.position.x; robot.rig.root.position.z = robot.view.position.z; robot.rig.root.rotation.y = robot.yaw; } return { group, tick(dt) { if (!(dt > 0)) return; const clamped = Math.min(dt, MAX_STEP); // The clamped step, deliberately: the clock exists to age destinations // against how much walking has happened, and in a backgrounded tab none // has. See `clock`. clock += clamped; for (const robot of robots) step(robot, clamped); }, robots() { return views; }, dispose() { for (const robot of robots) group.remove(robot.rig.root); robots.length = 0; views.length = 0; // Every clone shares the prototype's buffers, so this frees all of them // exactly once. Materials belong to the caller's registry and are left // alone, the same way every asset in this library leaves them alone. disposeOptimus(prototype); }, }; }