The office keeps its lights on, and something walks around under them
**Lights.** A sited office follows the real sun, and the real sun spends
half its time below the horizon — which was producing a technically
correct and completely useless picture: an unlit floor plate at midnight
in a building whose whole premise is that you can see who is at which
desk. `luminaires.ts` brings the diffusers up as the sun goes down and
reports one scalar for how much interior light there is; `withHouseLights`
adds it to the rig. CONTRACT §4's rule that a fitting emits no light is
kept in full — nothing here is a light source, and the rig still has one
owner.
**And they notice you.** A fitting within four metres of somebody walking
underneath brightens and fades back as they leave, which is what an
occupancy-sensed floor actually does at night. They are one `InstancedMesh`
sharing one material, so `emissiveIntensity` cannot vary between them —
`instanceColor` can, but three multiplies it into the diffuse term only, so
six lines of `onBeforeCompile` carry it into the emissive term as well. The
alternative was one mesh per fitting: forty draw calls of ceiling in a
building that spends about twenty on everything.
**Optimus.** A posable Gen-3 humanoid — eleven articulating joints, pale
shells over a dark frame, a black visor — with a walk cycle driven by
*distance travelled* rather than wall-clock, so the feet do not slide when
a robot slows down. Two per floor, derived from the pack's levels, so the
two-storey tower gets four and the hangar gets two without either pack
knowing robots exist. They wander between reachable points using
`Plan.blocked` — the collider the wall split already produces — and they
are deliberately **not** gated on `depth`: the build-time-exclusion rule is
about occupancy, and a robot is nobody.
**Starlinks stop being pixels.** The sixty-four nearest the centre of view
grow real geometry — a flat bus with ONE large solar array, which is the
actual signature and the thing everybody draws symmetrically and wrong —
fading in so there is no pop where a point becomes a mesh. Two draw calls.
The sun for their attitude comes from `solar.ts` and not from the rig,
because `atmosphere.ts` floors the light direction to keep the shadow
camera usable, and a sun ten degrees *down* is exactly the dusk geometry
that makes a pass visible.
**Aircraft** are airliners now — swept wings, nacelles, a fin — instead of
an arrowhead, still one shared geometry facing +Z as `flights.ts` requires.
**Clouds** drift over the board, driven by observed cover, lit by the rig
rather than by themselves.
Four modules were built by subagents and reviewed by another; every one
came back `needs-work` and the reviews were right. Fixed before wiring:
- The walk cycle's arms were a quarter cycle out of step with its legs —
the legs are cosine-shaped and the arms were on `sin`, so at the
instant the left leg reached full forward the left shoulder was at dead
neutral. Uncanny, and hard to name until it is pointed at.
- Every Optimus shell used a `roundedBox` radius of 0.12–0.22, which that
primitive turns into a near-circular cross-section — the figure was
built out of lozenges, not panels. The rest of the library uses
0.02–0.09.
- The cloud material was `transparent` + `DoubleSide` without
`forceSinglePass`, so three rendered it twice per frame *and* bumped
`material.version` on each pass — rebuilding the program cache key
forever, on the one layer that is fill-rate bound.
- `starlinkMesh.dispose()` freed the geometries but not the
`InstancedMesh`es, orphaning their instance buffers on every city
switch.
- The airliner's tailplane roots sat outside the tail cone and hung in
free air over most of their chord.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import { kit, type AnyAsset, type AssetRegistry } from "../kit.ts";
|
||||
import { deskPartition, deskPedestal, deskWorkstation } from "./desks.ts";
|
||||
import { plantPotted, plantTall } from "./greenery.ts";
|
||||
import { lightPendant, lightTroffer } from "./lighting.ts";
|
||||
import { robotOptimus } from "./optimus.ts";
|
||||
import { screenMonitor, screenWallDisplay } from "./screens.ts";
|
||||
import { seatLounge, seatTaskChair } from "./seating.ts";
|
||||
import { storageLocker, storageShelf } from "./storage.ts";
|
||||
@@ -49,6 +50,7 @@ export const OFFICE_ASSETS: readonly AnyAsset[] = [
|
||||
plantTall,
|
||||
lightPendant,
|
||||
lightTroffer,
|
||||
robotOptimus,
|
||||
rug,
|
||||
whiteboard,
|
||||
];
|
||||
@@ -66,6 +68,7 @@ export {
|
||||
deskWorkstation,
|
||||
lightPendant,
|
||||
lightTroffer,
|
||||
robotOptimus,
|
||||
plantPotted,
|
||||
plantTall,
|
||||
rug,
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
/**
|
||||
* A Tesla Optimus, procedurally, as a rig you can pose — plus the merged static
|
||||
* version for a pack that just wants one standing in a corner.
|
||||
*
|
||||
* This is the first asset in the library that is **not** a single merged mesh,
|
||||
* and the reason is the only reason that would justify it: something has to walk
|
||||
* it around the office (`interiors/robots.ts`), and a walk cycle needs limbs
|
||||
* that move independently. `parts.ts` says it plainly — anything that has to
|
||||
* move on its own belongs in its own object rather than in a bin — so the figure
|
||||
* is eleven small bins, one per articulating joint, each merged internally and
|
||||
* hung off the joint it belongs to. Eighteen meshes per robot rather than two.
|
||||
* That price is stated again, in draw calls, at the bottom of this comment and
|
||||
* in `robots.ts`, because it is the whole cost of the feature.
|
||||
*
|
||||
* ### The figure
|
||||
*
|
||||
* Gen 3 from reference knowledge, and the proportions are what carry it, because
|
||||
* ten metres is the distance this is normally seen from and at ten metres a
|
||||
* silhouette is all there is. What has to be right, in order of how much it
|
||||
* matters:
|
||||
*
|
||||
* 1. **1.73 m and slim.** Optimus is human-height and noticeably narrower than a
|
||||
* human — a 0.35 m shoulder span on a 1.73 m frame. Build it at human width
|
||||
* and it reads as a person in a costume.
|
||||
* 2. **The waist.** The single most identifying line: a wide pelvis, a wide
|
||||
* chest, and a genuinely thin dark column between them. It is 0.15 m across
|
||||
* where the pelvis is 0.28 and the chest is 0.34. Widen it and the whole
|
||||
* thing turns into a mannequin.
|
||||
* 3. **The visor.** A smooth black panel filling the front of a small pale head,
|
||||
* with no features on it at all. Two eyes, a mouth line, a "friendly" curve —
|
||||
* any of them and it stops being Optimus.
|
||||
* 4. **Pale shells over a dark frame.** Every limb is a light shell that stops
|
||||
* short of the joint, with dark structure showing in the gap. That gap is
|
||||
* what makes it read as a machine rather than as a white plastic doll, and it
|
||||
* costs nothing but a few millimetres of geometry.
|
||||
* 5. The knee actuator, the shoulder caps, the five-fingered hands. Detail, not
|
||||
* silhouette. Present because they are cheap, not because they are load-
|
||||
* bearing.
|
||||
*
|
||||
* ### Two materials, and why not three
|
||||
*
|
||||
* `paper` for the shells and `screenBezel` for the dark frame and the visor.
|
||||
* Both are borrowed — `materials.ts` has a closed role list with no robot in it,
|
||||
* and inventing a role is not on offer — so the borrow is chosen to survive a
|
||||
* self-hoster recolouring the palette. `paper` is the library's palest
|
||||
* untextured neutral, which is exactly what an Optimus shell is; `screenBezel`
|
||||
* is its near-black, and the visor genuinely *is* a bezel, so a self-hoster who
|
||||
* darkens their screen surrounds darkens the robot's face, which is the right
|
||||
* coupling rather than a coincidental one.
|
||||
*
|
||||
* `metalTrim` was tried as a third material for the joint barrels and dropped.
|
||||
* The office rig carries no environment map, so a `metalness: 0.85` role has
|
||||
* nothing to reflect and renders as a dull dark grey — indistinguishable from
|
||||
* `screenBezel` at ten metres — while costing another mesh in nine of the eleven
|
||||
* groups. Two materials, eighteen meshes.
|
||||
*
|
||||
* ### The indexed/non-indexed rule bites here harder than anywhere else
|
||||
*
|
||||
* `common.ts` warns that every part under one material must be all-indexed or
|
||||
* all-non-indexed or `mergeGeometries` silently drops the material. With
|
||||
* eighteen bins there are eighteen chances to get it wrong and the symptom is an
|
||||
* invisible shin, so the split is made structural rather than remembered:
|
||||
*
|
||||
* - **`shell` is drawn with `roundedBox()` and nothing else.** `ExtrudeGeometry`,
|
||||
* never indexed. Every pale part of the robot is a rounded box, which is also
|
||||
* what Optimus actually looks like — flat-sided shells with softened edges,
|
||||
* not tubes.
|
||||
* - **`frame` is drawn with `box()` and `cylinder()` and nothing else.** Both
|
||||
* indexed. This is why the visor is a flat box and not a rounded one: it is
|
||||
* drawn in the dark material, so it may not be an extrusion.
|
||||
*
|
||||
* Add a part and put it in the material whose primitive class it already
|
||||
* belongs to. If you cannot, you want the *other* material, and it will usually
|
||||
* turn out to look better there anyway.
|
||||
*
|
||||
* ### The joint frame
|
||||
*
|
||||
* The figure faces **−Z at yaw 0**, same as every other asset (`common.ts`), and
|
||||
* the origin is on the floor between the feet. Consequences worth writing down,
|
||||
* because getting a sign wrong here produces a robot that walks backwards
|
||||
* through its own knees:
|
||||
*
|
||||
* - **+X is the robot's right, −X its left.** Stand behind it looking the way it
|
||||
* faces — which is looking down −Z, the three.js default view direction — and
|
||||
* its right hand is on your right, which is +X. So `hipR` is at `x > 0`.
|
||||
* - **Every joint group's local axes are the root's axes.** No rotation is baked
|
||||
* into a joint; a rig with every rotation at zero is a figure standing to
|
||||
* attention. `OPTIMUS_REST` is applied on top of that, by `buildOptimus`, and
|
||||
* is what "standing still" means to the walk cycle.
|
||||
* - **`rotation.x` is the sagittal hinge and positive swings the limb forward.**
|
||||
* A limb hangs along −Y; `Rx(θ)` sends (0,−1,0) to (0,−cos θ,−sin θ), and −Z
|
||||
* is the front. So `hipL.rotation.x = +0.4` is a leg reaching forward and
|
||||
* `shoulderR.rotation.x = +0.4` is a hand swung forward.
|
||||
* - **A knee bends the other way, so `knee.rotation.x` must stay ≤ 0.** The shin
|
||||
* folds backwards, toward +Z. Positive values hyperextend it, and because
|
||||
* nothing clamps them, a sign slip in an animation shows up as a robot with
|
||||
* its knees on backwards rather than as an error. This is the asymmetry to
|
||||
* remember: hip, shoulder and elbow all bend positive; the knee is the one
|
||||
* that bends negative.
|
||||
* - **An elbow bends positive.** The forearm folds forward, toward the chest,
|
||||
* which is −Z. It shares its sign with the shoulder and not with the knee.
|
||||
* - **`rotation.y` is the transverse twist and positive turns left**
|
||||
* (counter-clockwise from above — the house `Yaw` sense, unconverted). Used on
|
||||
* `pelvis` and `torso` for the counter-rotation of a walk.
|
||||
* - **`rotation.z` is the frontal lean and positive leans left.** `Rz(θ)` sends
|
||||
* up (0,1,0) toward −X. Used on `torso` for sway and on `shoulder` for the
|
||||
* outward splay that keeps the hands off the hips.
|
||||
* - **`pelvis.position.y` is the body's height above the floor** and starts at
|
||||
* `OPTIMUS.hipY`. Everything else hangs off it, feet included, so lowering the
|
||||
* pelvis lowers the whole robot — which is what a walk's vertical oscillation
|
||||
* wants and is why the bob is applied there and nowhere else.
|
||||
*
|
||||
* ### What it costs
|
||||
*
|
||||
* Eighteen meshes and about 7,600 triangles per figure. `buildOptimus` is
|
||||
* meant to be called **once**; `cloneOptimus` gives you another figure sharing
|
||||
* every geometry and both materials, which is how four robots cost four times
|
||||
* the draw calls but one times the memory. Seventy-two draw calls for a crowd of
|
||||
* four is real money against an office that draws in about thirty, and it is the
|
||||
* number to look at first if a floor starts dropping frames.
|
||||
*
|
||||
* The `tera:robot.optimus` asset is the other end of that trade: it flattens the
|
||||
* same rig into one mesh per material, so a pack that wants a robot standing
|
||||
* still gets two draw calls and no articulation. `furnishings.ts` instances per
|
||||
* mesh, so twenty static robots in a pack still cost those two.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||
import { defineAsset, type AssetContext } from "../kit.ts";
|
||||
import type { SurfaceMaterial } from "../materials.ts";
|
||||
import { MeshBin, type PartBin } from "../parts.ts";
|
||||
|
||||
/**
|
||||
* Every dimension the figure and the walk cycle both have to agree on, in
|
||||
* metres above the sole, in the rest pose.
|
||||
*
|
||||
* Exported because `robots.ts` needs `hipY` to bob the pelvis around and would
|
||||
* otherwise carry a second copy of a number that must not drift. Everything
|
||||
* else — shell widths, bevel radii, how far a finger sticks out — is local to
|
||||
* the emitters below and deliberately not part of the contract.
|
||||
*/
|
||||
export const OPTIMUS = {
|
||||
/** Sole to crown. Gen 3 is quoted at about 1.73 m. */
|
||||
height: 1.73,
|
||||
ankleY: 0.085,
|
||||
kneeY: 0.5,
|
||||
hipY: 0.92,
|
||||
/** Top of the pelvis shell; the waist column starts here and the torso pivots here. */
|
||||
waistY: 1.05,
|
||||
/** Bottom of the chest shell. The 0.10 m between this and `waistY` is the slim bit. */
|
||||
chestY: 1.15,
|
||||
elbowY: 1.12,
|
||||
wristY: 0.865,
|
||||
shoulderY: 1.4,
|
||||
/** Top of the chest shell, where the neck column starts. */
|
||||
neckY: 1.43,
|
||||
/** The head's pivot: the top of the neck, so a nod hinges where a neck does. */
|
||||
headY: 1.505,
|
||||
/** Half the distance between the two hip pivots. A narrow stance; Optimus has one. */
|
||||
hipHalf: 0.085,
|
||||
/** Half the distance between the two shoulder pivots. */
|
||||
shoulderHalf: 0.175,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* What "standing still" is, in radians, applied by `buildOptimus` and used as
|
||||
* the base every animated angle is added to.
|
||||
*
|
||||
* These are not zero because a figure with every joint at zero stands with its
|
||||
* arms welded to its hips and its legs locked straight, which looks like a
|
||||
* mannequin rather than like a machine that is idling. A few degrees of elbow
|
||||
* and a few of outward arm splay is the whole difference.
|
||||
*
|
||||
* `shoulderZ` is a magnitude: it is applied as `+` on the right shoulder and `−`
|
||||
* on the left, because positive `rotation.z` swings a hanging arm toward +X and
|
||||
* +X is the robot's right — so the same sign splays one arm out and tucks the
|
||||
* other one in.
|
||||
*
|
||||
* `hipX` and `kneeX` cancel, and that is not a coincidence: nothing here moves
|
||||
* the ankle, so the foot's pitch is the *sum* of the two, and any sum but zero
|
||||
* stands the robot on its heels or its toes. The first draft had a 0.01 hip and
|
||||
* a −0.05 knee and buried six millimetres of toe in the carpet. Change one of
|
||||
* these and change the other. The residue is that a bent leg is fractionally
|
||||
* shorter than a straight one, so the soles float about a millimetre — which is
|
||||
* a millimetre, and cheaper than a third joint to correct it.
|
||||
*/
|
||||
export const OPTIMUS_REST = {
|
||||
hipX: 0.04,
|
||||
/** Negative, and exactly −`hipX`. A knee only bends one way; see above. */
|
||||
kneeX: -0.04,
|
||||
shoulderX: 0.03,
|
||||
shoulderZ: 0.075,
|
||||
elbowX: 0.16,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The joints a walk cycle drives, resolved once so an animation never has to
|
||||
* search the scene graph per frame.
|
||||
*
|
||||
* `pelvis` carries the legs and the body's height; `torso` is a child of
|
||||
* `pelvis` and carries everything above the waist, which is what lets the
|
||||
* shoulders counter-rotate against the hips without the legs coming with them.
|
||||
*/
|
||||
export interface OptimusJoints {
|
||||
/** Body height and hip twist. Pivots at `OPTIMUS.hipY`; the legs hang off it. */
|
||||
pelvis: THREE.Group;
|
||||
/** Sway, lean and shoulder counter-twist. Pivots at the waist. */
|
||||
torso: THREE.Group;
|
||||
head: THREE.Group;
|
||||
hipL: THREE.Group;
|
||||
hipR: THREE.Group;
|
||||
kneeL: THREE.Group;
|
||||
kneeR: THREE.Group;
|
||||
shoulderL: THREE.Group;
|
||||
shoulderR: THREE.Group;
|
||||
elbowL: THREE.Group;
|
||||
elbowR: THREE.Group;
|
||||
}
|
||||
|
||||
export interface OptimusRig {
|
||||
/** Origin on the floor between the feet, facing −Z at `rotation.y === 0`. */
|
||||
root: THREE.Group;
|
||||
joints: OptimusJoints;
|
||||
}
|
||||
|
||||
/** The two materials the whole figure is drawn in. See the header for the borrow. */
|
||||
interface Skin {
|
||||
shell: SurfaceMaterial;
|
||||
frame: SurfaceMaterial;
|
||||
}
|
||||
|
||||
/** A point in whichever joint frame the emitter is drawing into. */
|
||||
interface Anchor {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
const JOINT_NAMES = [
|
||||
"pelvis",
|
||||
"torso",
|
||||
"head",
|
||||
"hipL",
|
||||
"hipR",
|
||||
"kneeL",
|
||||
"kneeR",
|
||||
"shoulderL",
|
||||
"shoulderR",
|
||||
"elbowL",
|
||||
"elbowR",
|
||||
] as const;
|
||||
|
||||
// ---- Emitters -------------------------------------------------------------
|
||||
//
|
||||
// Each of these draws one body part into a bin, in that bin's own frame, with
|
||||
// `a` naming the joint centre the part hangs from. They are written this way so
|
||||
// that the rig and the flattened asset are the *same* geometry rather than two
|
||||
// bodies that will drift apart the first time somebody widens a shin.
|
||||
|
||||
/**
|
||||
* A dark joint barrel lying across the body, centred on `a`.
|
||||
*
|
||||
* A unit cylinder stands along +Y with its base at the placement point, so a
|
||||
* roll of +π/2 sends its length along −X — which means the placement has to sit
|
||||
* half a length to the *right* of where the barrel should end up. Getting that
|
||||
* offset wrong puts every joint on the robot half a barrel off-centre, which is
|
||||
* subtle enough to survive a first look and obvious enough to ruin the second.
|
||||
*/
|
||||
function barrel(
|
||||
bin: MeshBin,
|
||||
P: PartBin,
|
||||
material: SurfaceMaterial,
|
||||
a: Anchor,
|
||||
diameter: number,
|
||||
length: number,
|
||||
): void {
|
||||
bin.add(P.cylinder(12), material, {
|
||||
x: a.x + length / 2,
|
||||
y: a.y,
|
||||
z: a.z,
|
||||
size: [diameter, length, diameter],
|
||||
roll: Math.PI / 2,
|
||||
});
|
||||
}
|
||||
|
||||
/** The pelvis block, and the hip axle the legs turn on. Drawn in the pelvis frame. */
|
||||
function emitPelvis(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
// Straddles the hip line: down to 0.855 m and up to the waist at 1.05 m. The
|
||||
// width is what the slim waist above it is measured against.
|
||||
bin.add(P.roundedBox(0.07), s.shell, { y: -0.065, size: [0.28, 0.195, 0.19] });
|
||||
// The axle runs right through and out both sides, so there is dark structure
|
||||
// visible in the gap between the pelvis shell and the top of each thigh.
|
||||
barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.115, 2 * OPTIMUS.hipHalf + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waist, chest, shoulder caps and neck. Drawn in the torso frame, whose origin
|
||||
* is the waist at `OPTIMUS.waistY`.
|
||||
*
|
||||
* The shoulder caps live here rather than on the arms on purpose, and it is not
|
||||
* only a draw-call saving: on the real machine the cap is bodywork bolted to the
|
||||
* torso and the arm swings inside it. Putting the cap on the arm makes the whole
|
||||
* shoulder rotate when an arm swings, which reads as a shrug.
|
||||
*/
|
||||
function emitTorso(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
const waist = OPTIMUS.chestY - OPTIMUS.waistY;
|
||||
const chest = OPTIMUS.neckY - OPTIMUS.chestY;
|
||||
|
||||
// The waist. 0.15 m across between a 0.28 m pelvis and a 0.34 m chest, and it
|
||||
// is drawn in the dark material so the gap reads as structure rather than as
|
||||
// a robot that skipped lunch.
|
||||
bin.add(P.box(), s.frame, { y: -0.02, size: [0.15, waist + 0.04, 0.135] });
|
||||
|
||||
// Chest: a shell, then a plate a few millimetres proud of it. The plate is
|
||||
// what catches the light and gives the chest an edge at ten metres; without it
|
||||
// the torso is one flat pale slab.
|
||||
bin.add(P.roundedBox(0.06), s.shell, {
|
||||
y: waist,
|
||||
size: [0.34, chest, 0.205],
|
||||
});
|
||||
bin.add(P.roundedBox(0.055), s.shell, {
|
||||
y: waist + 0.045,
|
||||
z: -0.098,
|
||||
size: [0.245, chest - 0.09, 0.022],
|
||||
});
|
||||
|
||||
// The neck column, dark, running from the top of the chest into the head.
|
||||
bin.add(P.cylinder(10), s.frame, {
|
||||
y: OPTIMUS.neckY - OPTIMUS.waistY,
|
||||
size: [0.072, OPTIMUS.headY - OPTIMUS.neckY + 0.012, 0.072],
|
||||
});
|
||||
|
||||
const shoulderY = OPTIMUS.shoulderY - OPTIMUS.waistY;
|
||||
for (const side of [-1, 1]) {
|
||||
// The cap. Rounded hard, because a square shoulder is the other thing that
|
||||
// makes a humanoid read as a costume.
|
||||
bin.add(P.roundedBox(0.09), s.shell, {
|
||||
x: side * OPTIMUS.shoulderHalf,
|
||||
y: shoulderY - 0.078,
|
||||
size: [0.13, 0.156, 0.15],
|
||||
});
|
||||
// The ring the arm turns in, poking out beyond the cap.
|
||||
barrel(bin, P, s.frame, { x: side * OPTIMUS.shoulderHalf, y: shoulderY, z: 0 }, 0.092, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The head: a small pale shell and a black visor. Drawn in the head frame, whose
|
||||
* origin is the top of the neck.
|
||||
*
|
||||
* The visor is a plain `box` because the dark material is the indexed one — see
|
||||
* the primitive-class rule in the header, which is why the one part of this
|
||||
* figure most deserving of a soft edge does not get one.
|
||||
*
|
||||
* It sits about 3 mm *into* the flat middle of the face and about 1.5 mm *proud*
|
||||
* of the curve at the edges, because the shell's corners round away in Z while
|
||||
* the visor stays flat. That is not a compromise, it is the effect: a panel let
|
||||
* into a face and wrapping round the sides of it. Sunk flush all the way across
|
||||
* — which was the first version — it reads as a black rectangle painted on. The
|
||||
* two small yawed side pieces finish the wrap.
|
||||
*/
|
||||
function emitHead(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
const base = OPTIMUS.height - OPTIMUS.headY - 0.215;
|
||||
bin.add(P.roundedBox(0.08), s.shell, { y: base, size: [0.165, 0.215, 0.185] });
|
||||
|
||||
const visorY = base + 0.082;
|
||||
bin.add(P.box(), s.frame, { y: visorY, z: -0.079, size: [0.132, 0.075, 0.02] });
|
||||
for (const side of [-1, 1]) {
|
||||
bin.add(P.box(), s.frame, {
|
||||
x: side * 0.072,
|
||||
y: visorY,
|
||||
z: -0.062,
|
||||
size: [0.05, 0.072, 0.018],
|
||||
yaw: side * 0.62,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A thigh. Drawn in the hip frame; the shell stops short at both ends. */
|
||||
function emitThigh(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
const drop = OPTIMUS.hipY - OPTIMUS.kneeY;
|
||||
// Two stacked shells rather than one, for the taper. A parallel-sided thigh is
|
||||
// the difference between "slim humanoid" and "stilts".
|
||||
bin.add(P.roundedBox(0.07), s.shell, { y: -drop * 0.52, size: [0.135, drop * 0.46, 0.165] });
|
||||
bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.045, size: [0.112, drop * 0.5, 0.14] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shin, ankle and foot, plus the knee actuator. Drawn in the knee frame.
|
||||
*
|
||||
* The actuator is the one piece of detail on the legs that is worth its
|
||||
* geometry: a dark barrel across the front of the knee is the single most
|
||||
* recognisable thing about an Optimus leg, and it is one cylinder.
|
||||
*/
|
||||
function emitShin(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
const drop = OPTIMUS.kneeY - OPTIMUS.ankleY;
|
||||
barrel(bin, P, s.frame, { x: 0, y: 0, z: -0.012 }, 0.118, 0.125);
|
||||
|
||||
bin.add(P.roundedBox(0.065), s.shell, { y: -drop + 0.02, size: [0.1, drop - 0.05, 0.118] });
|
||||
// The ankle, dark, in the gap the shin shell leaves above the foot.
|
||||
bin.add(P.box(), s.frame, { y: -drop + 0.005, size: [0.072, 0.03, 0.085] });
|
||||
|
||||
// The foot. The sole is a separate dark slab so the robot has something to
|
||||
// stand on that is not the same colour as its shins — a monochrome foot
|
||||
// dissolves into a pale floor.
|
||||
const sole = -OPTIMUS.kneeY;
|
||||
bin.add(P.box(), s.frame, { y: sole, z: -0.035, size: [0.098, 0.014, 0.25] });
|
||||
bin.add(P.roundedBox(0.06), s.shell, {
|
||||
y: sole + 0.014,
|
||||
z: -0.035,
|
||||
size: [0.106, 0.052, 0.243],
|
||||
});
|
||||
}
|
||||
|
||||
/** An upper arm. Drawn in the shoulder frame; the cap is on the torso. */
|
||||
function emitUpperArm(bin: MeshBin, P: PartBin, s: Skin): void {
|
||||
const drop = OPTIMUS.shoulderY - OPTIMUS.elbowY;
|
||||
bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.048, size: [0.088, drop - 0.098, 0.098] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Forearm, wrist and a five-fingered hand. Drawn in the elbow frame.
|
||||
*
|
||||
* The fingers are pale rather than dark, with only the knuckle bar in the frame
|
||||
* material. At ten metres a hand reads from its outline, so what matters is that
|
||||
* there are five of something and that they are separate — not what colour the
|
||||
* gaps between them are, which is a decision worth about forty triangles of
|
||||
* detail nobody will ever resolve.
|
||||
*
|
||||
* This is the one emitter that has to know which side it is on, because a thumb
|
||||
* is the only part of the figure that is not left-right symmetric. It goes
|
||||
* medial — toward the body — which is where a relaxed arm puts it, and which
|
||||
* means `side` flips its sign. Drawing both thumbs at a fixed `+x`, as the first
|
||||
* version did, gives a robot with two right hands and a bounding box 27 mm wider
|
||||
* on one side than the other.
|
||||
*/
|
||||
function emitForearm(bin: MeshBin, P: PartBin, s: Skin, side: number): void {
|
||||
const drop = OPTIMUS.elbowY - OPTIMUS.wristY;
|
||||
barrel(bin, P, s.frame, { x: 0, y: 0, z: 0 }, 0.094, 0.088);
|
||||
bin.add(P.roundedBox(0.07), s.shell, { y: -drop + 0.02, size: [0.08, drop - 0.055, 0.088] });
|
||||
bin.add(P.box(), s.frame, { y: -drop - 0.012, size: [0.062, 0.024, 0.07] });
|
||||
|
||||
// Palm, then four fingers and a thumb set off to the side and turned in.
|
||||
const palmTop = -drop - 0.012;
|
||||
bin.add(P.roundedBox(0.075), s.shell, { y: palmTop - 0.078, size: [0.068, 0.078, 0.032] });
|
||||
for (let i = 0; i < 4; i++) {
|
||||
bin.add(P.roundedBox(0.08), s.shell, {
|
||||
x: (i - 1.5) * 0.017,
|
||||
y: palmTop - 0.148,
|
||||
size: [0.014, 0.07, 0.024],
|
||||
});
|
||||
}
|
||||
bin.add(P.roundedBox(0.08), s.shell, {
|
||||
x: -side * 0.03,
|
||||
y: palmTop - 0.088,
|
||||
z: -0.02,
|
||||
size: [0.017, 0.056, 0.026],
|
||||
roll: side * 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The rig --------------------------------------------------------------
|
||||
|
||||
/** A named, empty joint at a position in its parent's frame. */
|
||||
function joint(parent: THREE.Object3D, name: string, x: number, y: number): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = name;
|
||||
group.position.set(x, y, 0);
|
||||
parent.add(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** Merge a joint's parts and hang them off it, if it has any. */
|
||||
function attach(target: THREE.Group, bin: MeshBin): void {
|
||||
if (bin.size === 0) return;
|
||||
target.add(bin.build(`${target.name}.mesh`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one posable Optimus.
|
||||
*
|
||||
* Call this **once** and `cloneOptimus` for every figure after the first: a
|
||||
* clone shares every geometry and both materials, so a crowd costs draw calls
|
||||
* and nothing else. Whoever built the original disposes it with
|
||||
* `disposeOptimus`, which frees the geometry the clones are all pointing at —
|
||||
* so dispose last, and dispose exactly once.
|
||||
*
|
||||
* The materials come from `ctx.materials` and belong to the registry. Nothing
|
||||
* here disposes them.
|
||||
*/
|
||||
export function buildOptimus(ctx: AssetContext): OptimusRig {
|
||||
const P = ctx.parts;
|
||||
const skin: Skin = {
|
||||
shell: ctx.materials.get("paper"),
|
||||
frame: ctx.materials.get("screenBezel"),
|
||||
};
|
||||
|
||||
const root = new THREE.Group();
|
||||
root.name = "optimus";
|
||||
|
||||
// Pelvis, and the legs hanging off it. The legs are children of the pelvis so
|
||||
// that dropping the pelvis drops the whole robot — see the header note on
|
||||
// `pelvis.position.y`.
|
||||
const pelvis = joint(root, "pelvis", 0, OPTIMUS.hipY);
|
||||
const pelvisBin = new MeshBin();
|
||||
emitPelvis(pelvisBin, P, skin);
|
||||
attach(pelvis, pelvisBin);
|
||||
|
||||
const torso = joint(pelvis, "torso", 0, OPTIMUS.waistY - OPTIMUS.hipY);
|
||||
const torsoBin = new MeshBin();
|
||||
emitTorso(torsoBin, P, skin);
|
||||
attach(torso, torsoBin);
|
||||
|
||||
const head = joint(torso, "head", 0, OPTIMUS.headY - OPTIMUS.waistY);
|
||||
const headBin = new MeshBin();
|
||||
emitHead(headBin, P, skin);
|
||||
attach(head, headBin);
|
||||
|
||||
// `side` is −1 for the robot's left and +1 for its right, which is the sign of
|
||||
// X: see the joint-frame note. The two limbs are mirror images in position
|
||||
// only — the shells themselves are symmetric, so there is no mirrored
|
||||
// geometry and no wound-backwards triangles to worry about.
|
||||
for (const side of [-1, 1]) {
|
||||
const suffix = side < 0 ? "L" : "R";
|
||||
|
||||
const hip = joint(pelvis, `hip${suffix}`, side * OPTIMUS.hipHalf, 0);
|
||||
const hipBin = new MeshBin();
|
||||
emitThigh(hipBin, P, skin);
|
||||
attach(hip, hipBin);
|
||||
|
||||
const knee = joint(hip, `knee${suffix}`, 0, OPTIMUS.kneeY - OPTIMUS.hipY);
|
||||
const kneeBin = new MeshBin();
|
||||
emitShin(kneeBin, P, skin);
|
||||
attach(knee, kneeBin);
|
||||
|
||||
const shoulder = joint(
|
||||
torso,
|
||||
`shoulder${suffix}`,
|
||||
side * OPTIMUS.shoulderHalf,
|
||||
OPTIMUS.shoulderY - OPTIMUS.waistY,
|
||||
);
|
||||
const shoulderBin = new MeshBin();
|
||||
emitUpperArm(shoulderBin, P, skin);
|
||||
attach(shoulder, shoulderBin);
|
||||
|
||||
const elbow = joint(shoulder, `elbow${suffix}`, 0, OPTIMUS.elbowY - OPTIMUS.shoulderY);
|
||||
const elbowBin = new MeshBin();
|
||||
emitForearm(elbowBin, P, skin, side);
|
||||
attach(elbow, elbowBin);
|
||||
}
|
||||
|
||||
const rig: OptimusRig = { root, joints: optimusJoints(root) };
|
||||
restOptimus(rig.joints);
|
||||
return rig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the joints of a rig root by name.
|
||||
*
|
||||
* Exported because `Object3D.clone(true)` copies names but hands back plain
|
||||
* `Object3D`s with no idea which of them is a knee, so a clone has to be
|
||||
* re-resolved. Throws rather than returning null: a root with no `kneeL` in it
|
||||
* is not a rig, and the caller has nothing useful to do about that at runtime.
|
||||
*/
|
||||
export function optimusJoints(root: THREE.Object3D): OptimusJoints {
|
||||
const found = {} as Record<(typeof JOINT_NAMES)[number], THREE.Group>;
|
||||
for (const name of JOINT_NAMES) {
|
||||
const object = root.getObjectByName(name);
|
||||
if (!object) throw new Error(`optimus: rig has no joint named "${name}"`);
|
||||
found[name] = object as THREE.Group;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put every joint back to the idle stance. Called by `buildOptimus`, and by an
|
||||
* animation that wants a figure to stop moving without writing the eleven
|
||||
* assignments out itself.
|
||||
*/
|
||||
export function restOptimus(j: OptimusJoints): void {
|
||||
j.pelvis.position.y = OPTIMUS.hipY;
|
||||
j.pelvis.rotation.set(0, 0, 0);
|
||||
j.torso.rotation.set(0, 0, 0);
|
||||
j.head.rotation.set(0, 0, 0);
|
||||
j.hipL.rotation.set(OPTIMUS_REST.hipX, 0, 0);
|
||||
j.hipR.rotation.set(OPTIMUS_REST.hipX, 0, 0);
|
||||
j.kneeL.rotation.set(OPTIMUS_REST.kneeX, 0, 0);
|
||||
j.kneeR.rotation.set(OPTIMUS_REST.kneeX, 0, 0);
|
||||
// The splay is mirrored: positive `rotation.z` swings a hanging arm toward
|
||||
// +X, so the right arm needs `+` and the left arm `−` to both move outward.
|
||||
j.shoulderL.rotation.set(OPTIMUS_REST.shoulderX, 0, -OPTIMUS_REST.shoulderZ);
|
||||
j.shoulderR.rotation.set(OPTIMUS_REST.shoulderX, 0, OPTIMUS_REST.shoulderZ);
|
||||
j.elbowL.rotation.set(OPTIMUS_REST.elbowX, 0, 0);
|
||||
j.elbowR.rotation.set(OPTIMUS_REST.elbowX, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Another figure sharing the first one's geometry and materials.
|
||||
*
|
||||
* `Object3D.clone(true)` copies the hierarchy, the names and the transforms and
|
||||
* *references* geometry and material, which is exactly the sharing wanted here —
|
||||
* so do not dispose a clone. Dispose the original, once, with `disposeOptimus`.
|
||||
*/
|
||||
export function cloneOptimus(rig: OptimusRig): OptimusRig {
|
||||
const root = rig.root.clone(true);
|
||||
return { root, joints: optimusJoints(root) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Free a rig's geometry.
|
||||
*
|
||||
* Materials are the registry's and are left alone, exactly as every asset in
|
||||
* this directory leaves them alone. Call this on the rig `buildOptimus`
|
||||
* returned, never on a clone, and only once everything cloned from it is out of
|
||||
* the scene.
|
||||
*/
|
||||
export function disposeOptimus(rig: OptimusRig): void {
|
||||
rig.root.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (mesh.isMesh) mesh.geometry.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The static asset -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Collapse a posed rig into one mesh per material.
|
||||
*
|
||||
* The rig's own geometries are baked at their world transforms and disposed, so
|
||||
* what comes back owns everything it points at and can go through `MeshBin`'s
|
||||
* usual life cycle. This is safe only because of the primitive-class rule in the
|
||||
* header: `paper` is all extrusions and `screenBezel` is all indexed
|
||||
* primitives, so neither merge can hit the mixed-index refusal that would drop a
|
||||
* material and leave a robot with no shells on it.
|
||||
*/
|
||||
function flatten(rig: OptimusRig, name: string): THREE.Group {
|
||||
rig.root.updateMatrixWorld(true);
|
||||
|
||||
const byMaterial = new Map<THREE.Material, THREE.BufferGeometry[]>();
|
||||
rig.root.traverse((child) => {
|
||||
const mesh = child as THREE.Mesh;
|
||||
if (!mesh.isMesh || Array.isArray(mesh.material)) return;
|
||||
const baked = mesh.geometry.clone().applyMatrix4(mesh.matrixWorld);
|
||||
const list = byMaterial.get(mesh.material);
|
||||
if (list) list.push(baked);
|
||||
else byMaterial.set(mesh.material, [baked]);
|
||||
});
|
||||
disposeOptimus(rig);
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.name = name;
|
||||
for (const [material, list] of byMaterial) {
|
||||
const merged = list.length === 1 ? list[0] : mergeGeometries(list, false);
|
||||
if (list.length > 1) for (const geometry of list) geometry.dispose();
|
||||
if (!merged) continue;
|
||||
const mesh = new THREE.Mesh(merged, material);
|
||||
mesh.name = `${name}:${material.name || "material"}`;
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
group.add(mesh);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
type OptimusParams = {
|
||||
/** Sole to crown, metres. The whole figure scales; 1.73 is the real one. */
|
||||
height: number;
|
||||
/**
|
||||
* The stance to bake in. `"rest"` stands to attention, `"stride"` freezes it
|
||||
* mid-step so a pack can put one in a corridor without it looking parked.
|
||||
*/
|
||||
pose: "rest" | "stride";
|
||||
};
|
||||
|
||||
/**
|
||||
* One Optimus, standing still, merged.
|
||||
*
|
||||
* Two draw calls, no articulation, and `furnishings.ts` instances it per mesh —
|
||||
* so a pack with twenty of these still pays those two. Anything that has to walk
|
||||
* wants `buildOptimus` instead, and pays eighteen.
|
||||
*
|
||||
* There is no `colorKey` and no tintable part. A robot that comes in a team
|
||||
* colour is a mascot; this is a machine, and the two things it can be are the
|
||||
* colour of its shells and the colour of its frame, both of which belong to the
|
||||
* palette rather than to one instance.
|
||||
*/
|
||||
export const robotOptimus = defineAsset<OptimusParams>({
|
||||
id: "tera:robot.optimus",
|
||||
label: "Optimus humanoid robot",
|
||||
defaults: { height: OPTIMUS.height, pose: "rest" },
|
||||
|
||||
footprint(p) {
|
||||
const scale = p.height / OPTIMUS.height;
|
||||
// Measured off the built figure rather than guessed: 0.534 m across the
|
||||
// splayed hands and 0.279 m from toe to heel, rounded up. The arms are the
|
||||
// widest part of a standing humanoid and the feet are the deepest — not the
|
||||
// shoulders and not the chest, which is what you would reach for.
|
||||
return { width: 0.55 * scale, depth: 0.3 * scale, height: p.height, clearance: 0.35 };
|
||||
},
|
||||
|
||||
build(p, ctx) {
|
||||
const rig = buildOptimus(ctx);
|
||||
if (p.pose === "stride") {
|
||||
// A single frame of the walk cycle, written out rather than shared with
|
||||
// `robots.ts`: this is a fixed pose for a static prop, that is a function
|
||||
// of distance travelled, and coupling them would mean a pack's decorative
|
||||
// robot changing shape whenever somebody retunes a gait.
|
||||
rig.joints.hipL.rotation.x = 0.36;
|
||||
rig.joints.hipR.rotation.x = -0.3;
|
||||
rig.joints.kneeL.rotation.x = -0.12;
|
||||
rig.joints.kneeR.rotation.x = -0.42;
|
||||
rig.joints.shoulderL.rotation.x = -0.26;
|
||||
rig.joints.shoulderR.rotation.x = 0.28;
|
||||
rig.joints.elbowL.rotation.x = 0.2;
|
||||
rig.joints.elbowR.rotation.x = 0.34;
|
||||
rig.joints.torso.rotation.y = -0.07;
|
||||
rig.joints.pelvis.rotation.y = 0.04;
|
||||
// Dropped by the amount the straddle costs, and no further: the pose is
|
||||
// hand-written, so nothing checks that the feet reach the floor except
|
||||
// measuring the result. At −0.022 the leading toe was 2 mm under it.
|
||||
rig.joints.pelvis.position.y = OPTIMUS.hipY - 0.019;
|
||||
}
|
||||
|
||||
const group = flatten(rig, "robot.optimus");
|
||||
if (p.height !== OPTIMUS.height) group.scale.setScalar(p.height / OPTIMUS.height);
|
||||
return group;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user