A bedsit you can walk in, measured off the frames of the room it never was - #385
Conversation
…r was
BEDSIT1.SET is a set of pre-rendered stills and the depth images the engine
uses to occlude actors in front of them. There is no room behind those
pictures — the game never needed one. This builds it: a solid, walked in the
browser at `/bedsit/`, worked out from the frames themselves.
It is an EXPERIMENT and it is kept where an experiment can be dropped. Every
source, tool and asset is under two paths and nothing else:
taoot/bedsit/ the page, its modules and its tools
taoot/public/bedsit/ the textures the page fetches
so removing it is `rm -rf` on those two and `git checkout taoot/vite.config.ts`,
which is the only file outside them this touches — ten lines adding two unlisted
build entries. Nothing in the game imports any of it; it reaches the other way
for four engine modules and the site's URL helper, and for nothing else.
**Nothing here is a guess where a measurement was available.** The room's shell,
the furniture's boxes and the places things stand were fitted to the SET's own
frames: scan a column for the brightness edge, put a ray through that pixel with
the frame's optics (focal 256, 45 degrees of half-FOV across), and cross it with
a plane the room already knows or with the frame's own depth level. Where that
could not be done the source says so and says what was assumed instead — the
rug's near edge is below the bottom of every frame that could see it, and the
settees' feet are in the one corner no lamp reaches.
**Three kinds of geometry.** The shell and most joinery are drawn from numbers
(`bedsit-room.ts`, `bedsit-furniture.ts`). Most of the loose furniture is an
imported mesh baked into a module by `bedsitglb.ts`, which fits a model to
sizes, splits it by connected island or by height, and can now bring one down to
a triangle budget by quadric-error collapse. The counterpane and the counter's
curtains are cloth simulations, baked where they came to rest.
**The surfaces are painted, not photographed.** `bedsit-paint.ts` draws the
plaster, its brick holes, the floorboards and the street; `bedsit-skin.ts` can
project the frames' own pixels onto a chart instead, which is how the paint was
checked. The tools under `bedsit/tools/` are the measuring instruments: `look`
reads a frame and turns pixels into places, `fit` solves for a plane, `pics`,
`obit`, `sky` and `rug` cut textures from the rip or from a photograph.
**The desk is drawn from its own measurements now** — carcase, plinth, top with
its bevel and the recess over the middle drawer, five drawer fronts and their
pulls — on a board of its own, darker and quieter than the chimneypiece's,
because a drawer front is small and seen close, where that board's figure
reads as one burl per panel. Its lamp is a turned surface of revolution,
fitted to the generated shade's own section rather than kept as its mesh.
**The desk has its props.** Three magazines, three postcards, a tarot card, four
standing photographs, three packets of Old Reds, an ashtray, a pocket watch, a
matchbox and a bottle of schnapps. Their places come from `bedcards.mov`, the
film the game plays when the desk is examined,
and `bedsitcards.ts` cuts them out of it through a homography so a card lying at
an angle comes back square. What it cannot do is tell you where they SIT: the
props on that desk are very nearly collinear — two hundred units of x against
nineteen hundred of y — and a homography fitted to them put the matchbox four
hundred units from where it is. That is written down where it was tried.
So the cut frames are a REFERENCE and not a texture, exactly as the wall
pictures were. Two hundred pixels across, dithered to 256 colours, with the left
margin of BRAVE NEW WORLD lost off the edge of its own close-up. What goes on
the desk is that reference enlarged and re-set and handed back, trimmed to its
own four edges by `bedsitmags.ts` — which finds the cover rather than being told
where it is, the supplied ground being flat enough that the corner pixel IS its
colour. Three of the four photographs arrived with a frame painted round them
and a drop shadow under them; none of that can be used in a room that lights its
pictures with its own lamps and builds their mouldings as geometry, so there is
an inset that cuts the art back to the photograph.
**Every pinned-corner texture in this room was mirrored, and had been all
along.** `glOf` maps a room point to a GL one by swapping y and z without
negating either, which is a REFLECTION — determinant −1 — and the whole room is
built and measured inside it. Nothing showed it until there was lettering: a
plaster wall and a floorboard do not read backwards. The fix is a flipped u at
the three places a UV is generated and not inside `glOf`, because every
measurement in the room is expressed in that frame and correcting the frame
would move all of them.
The packets are three meshes — closed, open, and the cigarettes in the open one
— built in Blender at room scale and baked with `--exact`, which maps blender
axes straight through and so keeps the three in register with each other. The
thickness is not chosen: the art is 0.734 across its length, which at 165 units
long gives 121 across, and 121 units is 78 mm, which is ten cigarettes at 7.4
side by side. Two rows of ten is a packet of twenty. Fourteen of those twenty
are sealed inside an opaque shell and were deleted, which is 1,040 triangles
down to 312. The hole they look out of is cut into the RAW box before it is
bevelled and cast, because a boolean against the finished mesh makes a fan of
slivers, and the box is hollowed afterwards so there is no back wall standing
behind the cigarettes to fight them for the same pixels.
One texture does all of it. `pack-oldreds.jpg` is the front art over the top
three quarters and four solid blocks under it — red, green, paper, tobacco — and
a flat face pinned to a single point inside a block takes that block's colour
flat. Which is how a packet's four wrap faces, its two ends, a cigarette's paper
and its tobacco come off one material in one draw call. The blocks were placed
twice: glTF counts v from the top and Blender from the bottom, so the first set
arrived at 1 − v and the sides came out tobacco-brown.
The desk's drawn ashtray is gone and the radio table's model stands there
instead — the same brass, the same measured sections — with the tarot card moved
out from under its brim.
**Six props were deleted, and it is the same reason every time.** Two
magazines, two empty picture frames, a PRINT beside the matchbox and a fountain
pen. Each of the first four was a pale rectangle with a darker rectangle inset
in it, which is not a prop — it is the one shape that reads as ABSENCE. The two
magazines lie in the middle of the lamp's pool and are clipped to white in the
only frame that sees them, so there was nothing to enlarge: they stood as flat
colour with a plate on each standing in for the photograph they certainly had,
and dropping the plate leaves a blank cream rectangle, which is not better. The
pen was never in the film at all; it was a plausible thing to find on a writing
desk, which is the same argument that put the PRINT there. A prop whose picture
cannot be recovered is worth less than the clear desk it was standing on.
**The four standing photographs are at four angles now, in frames a third the
width.** Each carries its own x and its own turn where they shared one x and
stood square to the wall — four frames someone put down one at a time, instead
of a row nobody arranged. That takes two rotations and they do not commute: the
TILT leans the frame back on its strut and acts in the vertical plane, the turn
swings it about its own upright and acts in plan, so the lean is applied first
in the frame's own coordinates and the result is then turned. The other order
tips the frame onto its side, which is written down where it is done. The
mouldings were thick and flat; they are thin now, with a bevelled inner lip, in
two colours read off the film rather than chosen — a dark stained wood, and a
gilt for the oval that has one.
**La Morte has the corners a card has, and its texture no longer does.** The
supplied art arrived with them already rounded, on a white ground, at 39 px
across and 30 down out of 512 — which is two faults at once. That art is pinned
corner to corner onto a card 130 by 215, so it is squashed on the way in and a
corner circular in the file comes out an ELLIPSE on the desk, half as round
across as along; and 39 px of 512 is 6 mm on a card 84 wide, where a real one is
three or four. So the art's corners were painted out — its own border carried to
all four edges of the file — and the rounding is geometry, where it is circular
because the card is, and where the radius is 4.5 mm at this desk's third-over
scale, which is seven units.
**The lady's cover is TORN, which the film shows and a cut rectangle cannot.**
There is no alpha in this room — the smoke is the only blended thing in it — so
raggedness cannot be painted into a texture and left to cut itself out. It has
to be the SILHOUETTE, so the outline is walked at a fine step and pushed in and
out along its own normal. The displacement is a hash of the point's index and
not `Math.random`: a cover that reshuffled itself on every load would be the
most distracting thing on the desk.
**No flat thing on that desk is given its height by hand any more.** A
magazine, a postcard and a playing card all stood on `DESK.top`, and where two
of them overlap in plan that is not a pile — it is one slab passing THROUGH
another, with the lower one's cover cut in half by the upper one's underside.
From the walker's eye it is invisible: the eye is a thousand units above the
desk and looking along it, so a buried card and a stacked card are the same
picture. Only a plan view catches it, and a plan view is where these positions
are moved, a dozen at a time. So the heights cannot be numbers in the data —
they were, and they went stale the first time anything moved. `stackFlat` takes
the ORDER things were laid down in, which is what does not change, and lifts
each one onto the tallest earlier thing it actually covers, by the
separating-axis test on the two turned rectangles. It found a postcard buried
inside the lady on its first run, under a comment claiming those cards cleared
her.
The thicknesses went with it. The lady was ten units because she sits in the
magazines array, but `fray` already says what she is — one torn sheet that came
away from its staples — and ten gave her a squared edge a finger thick all the
way round. She is 1. The postcards are 1 where they were 2, which is half a
millimetre at life size once the third-over scale is taken back out. The two
real magazines keep their ten, and PEEK rides on BRAVE NEW WORLD where it laps
it. The pen was the same fault seen from the other side: its height was typed as
`DESK.top + 10` because that was how thick the lady was, so the moment she
thinned it hung nine units over the paper as a black bar.
**There is a bottle of schnapps, and it is the first transparent thing in this
room.** Everything here has been opaque since it was written — the shader ended
at alpha 1 and the draw loop never turned blending on, which is why the
lampshade's glass is an emissive solid. A bottle with spirit in it cannot be
done that way: a solid bottle with a dark cylinder hidden inside it renders
exactly like a solid bottle. So the glass is drawn LAST, after every opaque
thing, with the depth test ON and the depth write OFF. Testing means anything in
front of it hides it; not writing means its own far wall does not reject its
near wall, so both walls of the glass and the liquor between them are there.
There is one glass object in the room and no sorting, and the day there are two,
the note in the draw loop is the warning.
The shape is a supplied model rather than a lathe, and the work on it was all
subtraction. A neck that appeared to be inwards was the glass passing up THROUGH
the cap and out of the top — found only by colouring the two islands, after
three fixes that translated it, deleted it and scaled the wrong band, the second
of which left the bottle open. Four holes in the shoulder that looked like
patches were missing glass, and welding at 0.004 took eleven islands to two and
460 open edges to 132. Then the neck was cut back to almost nothing and the cap
made as high as it is wide. The label is a flat panel and not a wrap, hung off
the FLAT of the front at 45 rather than the base fillet at 47.8 — the fillet is
the wider of the two, and hanging it there floated the label six units clear of
the glass.
**The last two drawn props on the desk are models, and getting them in meant
fixing the importer twice.** `bedsitglb.ts` read `meshes[0].primitives[0]` and
stopped, which is one mesh of however many a file holds — right for a generator
that welds its output into a single buffer, and silently wrong for anything
assembled. The matchbox came in as TWENTY meshes: the box, its tray, and one
match instanced eighteen times, each instance carrying its place in its node's
matrix and nothing in the mesh to tell them apart. Taking the first gave the box
alone and no error, because a box is a perfectly good mesh. It walks the whole
scene now, every primitive under the transform of the node it hangs from.
`--part <material> <name>` then splits by the file's own materials, which is the
only thing that could have separated those matches from the box they lie in — no
island test can gather eighteen instances of one mesh and no height band can cut
between them — and it is the split the room needs anyway, a module being drawn
in exactly one material. `--up z` is the third: the watch arrived lying in the
XY plane with z for thickness, because the root matrix a Sketchfab export
carries does not always turn a Z-up FBX the way glTF promises. Nothing errors —
the fitter stretches whatever it is given into whatever box it is told — and the
result is a pocket watch stood on its edge. The giveaway is in the spans
`--list` prints: a watch is a disc, so two of its three are nearly equal and the
third is a fraction of them, and if the small one is not being called height the
file is not Y-up.
The watch is a hunter with its lid standing open, 2,940 triangles wearing one
512 sheet that holds the enamel dial, its Roman numerals, the engine turning on
the back and the blued screws. `DESK_PROPS.watch.r` no longer draws anything and
is kept because it is the case width the model was FITTED to — change it and the
bake has to be re-run. Its x and y are the case's middle, which is not the
mesh's: the bow stands off one side, so the bounding box the bake centres on is
a fifth of a case-width off the case itself. The crystal settles it, being a
disc on the case's own axis, and it comes from the same bake and moves with it.
The matchbox is the box, its tray and eighteen matches, 1,858 triangles.
**The way in is a title card, a preloader and one press.** The room and the
track both start loading when the page opens, because neither needs permission:
a fetch and a canvas are not a noise. What needs a gesture is the SOUND, and the
press is spent on it, which is why the button says Start rather than Load — by
the time it can be pressed there is nothing left to wait for.
The track is read as a STREAM into a blob rather than handed to the element as a
URL. The room is revealed on a bar line twenty-five seconds in, and a track
still arriving over the wire can stall between two of those bars, at which point
the picture is being cut to a rhythm the sound is no longer keeping. It also
gives the bar the one honest number on that screen: bytes against
`Content-Length`. What the bar shows either side of that is the room's own
stages weighted by what they cost — the painting is the long pole at the
measured seven tenths — and the split between room and music, which cannot be
measured because it depends on the connection, sets only the bar's PACE. Both
jobs are waited for whatever the weights say.
**The intro is cut to the track's own bars.** `patterns-on-the-wall.mp3` runs at
75 to the minute, measured off the file by the autocorrelation of its onset
strength, which puts a bar of four at 3.2 seconds and the first downbeat at
2.73. Every cue is a count of those bars, so the picture and the sound cannot
part company by rounding. One bar of line, one bar of black, four times, and the
room on bar eight — and bar eight is where the track opens up after the quietest
stretch of its first half minute. The last line is read in the gap the track
leaves and the room arrives on the sound coming back. None of that was arranged:
it is where the dip already was, and the bar count was chosen to land on it.
It is counted off `currentTime` and not off a timer, because twenty-five seconds
is long enough for the page's idea of a second and the track's to part company.
Two bounds keep that honest in both directions. It may not run AHEAD of the wall
clock — a headless browser renders into a null sink as fast as it can, and the
first measured run went by in seven seconds instead of twenty-five. And it may
not fall more than a second BEHIND it, because `min` on its own means a track
that stops stops the intro, and the intro is the only way into the room. The cut
itself arms a timer for the exact remainder rather than waiting to be discovered
overdue by the next frame: this page's frame is 17 ms on a GPU and 830 ms in a
software rasterizer, measured, and a fade may land a frame late where a cut may
not.
**And the room opens on a composed view rather than a standpoint.** The three
standpoints are the SET's own — where its frames were rendered from, and what
this room is checked against — and none of them is a good first look. This one
is straight down the room from as far back as there is floor, level, square to
the window wall, on the desk's own middle: the desk in the lamp's pool with a
dormer either side of it and London behind both. Further back is the wall, and a
camera inside it renders black, which is how 11,400 was ruled out.
That view arrived pointing at a wall until the last fault in this lot was found.
Pointer lock has to be taken at the press, because that is the only gesture
fresh enough for a browser to grant it — which leaves the mouse captured over a
black screen for twenty-five seconds, and a person watching an intro moves the
mouse. Every one of those movements was turning the camera behind the black, and
a held key was walking. Neither is listened to until the cut now. The room is
still DRAWN throughout: it is what the splash fades off.
**The wireless is an EKCO AC97**, split by island into the case, its speaker
cloth, its knobs and its switches so each can be its own material, with the
dial's station panel drawn into the slot the face was rasterised to find. Its
cabinet and the cloth are two more drawn boards from `bedsitwood.ts`, which is
no longer only woods: turning the grain and the pores off and the figure up
gives the moulded swirl of bakelite, and a ground with a fine check over it
gives the tan grille. Both were measured off a photograph of the set rather
than chosen.
The side table's lower shelf hung in mid-air, cut to the legs' bounding boxes
where a bounding box is a tapered leg at its widest; it is now a box cut to
each leg's face interpolated to the height it actually meets it. The book on it
is twelve triangles of drawn box rather than an imported island, and it, the
ashtray and the set are placed from a plan view — the only view the game gives
of that top is a grazing one, where a row of pixels is thirty-five units of
depth and depth and size cannot be told apart, and looking straight down there
is nothing to tell apart.
**A lamp's own glass now dims with its lamp.** `unlit` stops being a flag and
becomes an encoding — 0 an ordinary surface, 1 emissive and tied to nothing,
2 + i emissive and belonging to lamp i — so the pendant's wings, the standard
lamp's shade and the desk lamp's crown fade with their sliders instead of
burning on over a room they are no longer lighting. What they fade to is not
black but the same surface lit like any other. The street beyond the glass and
the smoke over it follow the windows' slider the same way: the terrace is a
photograph drawn unlit, so nothing dimmed it, and the plumes are a grey chosen
to sit a step darker than the sky, which inverts if only one of them moves.
**And the windows are turned down in the STREET'S space, not the shader's.**
Every lit pixel in this room ends at `pow(lit, 1/1.85)`. The street does not: it
returns before that line, because it is a photograph and its own pixels are the
answer. So the windows' slider was acting in two spaces at once — linear on the
city, tonemapped on the light coming from it — and the two came apart the
moment it moved.
Measured, with every other source off and one patch of each sampled across the
slider: the city plane falls as g^1.00 and the sash laid on the ceiling as
g^0.54, which is 1/1.85 and no coincidence. At the top of the slider the shaft
is 0.433 of the city, which is what a patch of lit ceiling should be. At 0.15 it
is 1.035 and at 0.10 it is 1.247 — the daylight on the ceiling brighter than the
sky it comes through. That is not a look, it is an impossibility, and it is why
a very low setting still laid the sash clearly across a dark room.
The two window lamps are raised to the tonemap's own exponent before they are
used, which the shader then takes straight back out. The light they lay in the
room falls as g^1.00, exactly as the city does, and the ratio holds at 0.433
wherever the slider is put — three decimal places, at seven settings. The top of
the slider is unchanged to the byte, because raising 1 to anything is 1. The
street and the smoke were not touched: they were never the ones in the wrong
space, and they still track each other.
The exponent is a named constant now, since two places have to agree on it and a
literal in each was a number waiting to drift. It is exact while the windows are
what light a surface and approximate once a lamp is on it too, the tonemap being
applied to the sum — which is the right way round, because the mismatch only
ever showed in a dark room.
The panel is the single place the OPENING gains are written, for the same
reason. They were 1 in the markup and 1 in the source, which is one number in
two files: change the light the room opens at and the sliders say something the
room is not doing until the first drag corrects it. The source reads them off
the panel now. The room opens with the pendant out, the standard and the desk
lamp at 0.55 and the windows and the fill at 0.10, which is an evening.
`LAMP_TINT` is still the room as it is MEANT to be lit and 1.00 is still that;
what the panel opens at is a setting, not a correction of it.
**The page fetches no SET file any more.** The four materials that were cut
from the rip at load — the stain, the wood, the cloth and the curtain — are cut
offline by `bedsitmats.ts` using the page's own `frameSwatch`, and written as
PNGs beside the rest. What is left of `skin()` is the plaster photograph, the
paint and the material files. The tool refuses to write a swatch that came back
flat grey, which is what a frame that matched nothing produces.
**The walls are painted once, not once a visit.** `bedsit-paint.ts` writes the
room's materials down as functions of a point on a wall and rasterizes them,
which is the right way to author plaster and the wrong way to serve it: six of
those charts are 10.5 megatexels of per-texel JavaScript, they run at about 1.4
megapixels a second on one thread, and the page did every one of them on every
load before it drew a frame. `bedsitbake.ts` runs the painter offline and
writes what it produced. The load goes from 17.1 seconds to 8.0 — 9.2 of them,
53 per cent — for 1.64 MB.
It was found by measurement and the measurement was worth more than the fix,
because both obvious suspects were innocent. Adding the desk's props — 2,560
triangles and twelve textures — had moved the load NOT AT ALL: stubbing all
twelve new files to a single pixel changed it by less than the noise between
two runs. Neither was it the shadow bake, which a note in this repo had blamed
for years: every module, all 93,169 triangles and all three lamps' cubes are
finished by DOMContentLoaded at 640 ms, five per cent of the load. Individual
GL calls cannot be timed in a headless browser at all — SwiftShader returns
from `drawElements` immediately and does the work at flush, so instrumenting
the entry points reported a thousand draws in a millisecond. What tells the
truth is the page's own progress notice, which is already a phase timeline.
**The rug is deliberately not baked.** It is the cheapest chart to paint — 419
ms against the fireplace wall's 1,780 — and the most expensive to compress,
being a woven pattern, which is the one thing a lossy codec handles worst here.
The number that settles the quality argument is not the error but the
ROUGHNESS, how far a texel departs from its own neighbourhood, which is what a
wall of plaster is made of and what this room has already rejected a texture
for losing. At WebP 0.85 the ceiling comes back 16 per cent smoother than it
was painted; at 0.95 it is within 3, and so is everything else — except the
rug, which loses 6 at every setting and carries three times the RMS error of
anything else. Two thirds of a second buys it exactly.
Three things make it a saving rather than a dependency. The name carries a hash
of the pixels, because Vite copies `public/` verbatim and a file that changes
otherwise keeps its URL — invisible on a dev server that revalidates, and
exactly the fault that gets found late on a deployed build and blamed on
something else. A chart that fails to arrive is PAINTED, by the painter that is
still in the page and still knows how. And the files are written in `Painted`'s
own row order, `v0` first rather than the top of a picture, so they upload
straight from an `Image` with no flip and no canvas — the picture path needs a
`getImageData`, which here would have been 46 MB of readback and would have
eaten a good part of what this saves.
Verified as the same room rather than a faster one: four camera positions that
between them see every baked chart, compared against the painted render pixel
for pixel, come back 0.14 to 0.50 of 255 apart on average with at most a
twentieth of one per cent of pixels differing by more than 8. The same test
proves the fallback, because forcing it is how the painted side is produced.
**The walls wear a photograph rather than invented noise.** It is the same scan
on the ceiling at less than full weight, which is what `plaster`'s tooth being a
strength and not a switch is for. Three things had to change together before it
could be seen at all: the whole surface had to take the scan and not only its
tooth band, the tile had to go to four metres so a photograph texel is not
smaller than a chart texel, and the wall charts had to double to 2048 by 1024.
Measured, the first attempt was SMOOTHER than the noise it replaced — 1.397
against 1.531 — which is why it read as no change.
The baking tool has to be handed that photograph too, and forgetting it is the
one way that tool can silently produce a worse room than the page it replaces.
`useToothPhoto` is called from the PAGE, because the page is where an `<img>`
can be decoded, and `wall()` guards on it so a painter that never received one
does not fail — it paints smooth and says nothing. Baked in that state the walls
lost the scan with no error anywhere and nothing to notice but the room looking
flatter than it had. The tool decodes the same file itself now, and the re-bake
is visible in the manifest: every plaster chart has a new hash and the floor's,
which never wanted a tooth, has not.
**The door is joinery instead of a picture.** Four modules off one Blender
build: the leaf's ground, its six raised bands, the architrave and the knob.
The bands are the rule and not the reading — five equal fifths across, rails as
tall as the bands are wide — because the frames measure the two grooves at 835
and 612 apart, which no joiner cut on purpose; the measured split is kept in a
comment and `EVEN = False` rebuilds it. The only tonal step on the whole door is
the one that draws its bands.
**Four pieces were re-made because they were generated rather than built.** The
hall stand's bends drifted ten units across a curve and its four arms were four
different curves; the standard lamp is not a solid of revolution at all — its
rings are out of round by up to 34 units on a 290-unit foot and their centres
wander 60 off the axis. Both were measured off the old mesh and drawn again from
the measurements: the stand as walks of fixed radius in each member's own plane,
the arm as one oval through four anchors, the lamp as a profile spun on a lathe.
A bend named by its radius cannot come out lopsided and a lathe cannot make an
out-of-round ring, which is the whole of why they were rebuilt rather than
smoothed. The ashtray went the same way, and the pillow is a new model cut to a
fifth of the triangles it arrived with.
**The counter under the cupboard is a cupboard now.** It had three shelf RAILS
and no shelves. A rail is the strip across the front edge of a shelf, so three
of them hanging in front of an empty box is a joiner's drawing of a cupboard
with the cupboard left out — and there was no bottom board either, so the
room's own floorboards showed through, which you can see, because the two
curtains part by a hundred units in the middle and stop short of the floor.
The five imported meshes it was made of were five separate pieces of evidence
that nobody had ever cut this thing out of wood. The end cheeks were 57 and 33
thick where `COUNTER.end` says 40. The rails were 21, 23 and 30 thick and 152,
147 and 203 tall, no two alike. The rails and the plinth ran 33 units past the
right cheek and 16 past the left, so the front of the cupboard overhung its own
ends. Blender counts 18 zero-area faces across the cheeks and the worktop, and
82 boundary edges on the plinth and the rails, which are not solids at all but
open shells showing their insides at a grazing angle. None of it is visible
head-on in a dark corner behind a curtain, which is exactly why it survived.
It is eleven named boxes now — 132 triangles against 268 imported, five modules
gone — and Blender puts it at no zero-area faces, no open edges and no face
that is not axis-aligned. A carcase made of named boxes cannot come out with
one cheek thicker than the other. The shelves are SPACED rather than placed:
three equal bays of 373 units between the bottom board and the worktop, because
no frame can see inside this cupboard and the rails that were the only other
evidence came from the model with the mismatched cheeks. 241 mm a bay, which
stands a tin.
**And the curtains were simulated a second time, which is what let the rest of
it be right.** They had a visible kink at the top, and measuring it says what it
was rather than how it looked: the heading was pinned at a mean x of 10,752 and
the cloth hung at 10,709, so it stepped 43 units forward in the first 150 of
drop — about sixteen degrees — and then fell plumb, drifting six. A cloth
cannot settle 45 units in FRONT of its own pin line; gravity swings it back. So
it was never a drape. It was the authored pleated sheet held in the shape it was
authored in, by the very numbers the first simulation had to learn: bending 4
and air damping 3 are stiff enough not to knot, and stiff enough not to fall.
The pin was in the wrong place because there was no curtain rail — the cloth
hung off the CARCASE's top rail, 45 units behind the worktop's front edge, and
had to step forward to get out from under the counter. There is a rail under
that edge now. The step is 0.4 units on one curtain and 0.2 on the other, the
lean over the whole drop is 11, and the two are symmetric where they were 6 and
18. Same solver, same settings, same 40 by 30 simulated and 40 by 8 shipped,
same 640 triangles each.
That re-run is also why `COUNTER` is one measured box with everything derived
from it instead of two sets of numbers. The carcase had been kept deliberately
WRONG — 3,076 long against the 3,500 the frames give, and a worktop 51 units
high — because the cloth had been settled against an imported model of that
size and cloth cannot be re-fitted by editing a number. Re-simulating it is what
let both be right at once, and is the reason to re-run a simulation rather than
nudge what it produced.
**Round members shade as round.** `bend` carries one ring along a whole path by
parallel transport; `spun` is a lathe whose normals are averaged. Before them,
the bed's head rail was fourteen independent cylinders per quarter, each picking
its cross-section off a reference axis chosen by whether the segment was steeper
than 26 degrees — so the facets jumped a twelfth of a turn out of line partway
round the bend, and every seam was a hard edge. The curve was right and the bar
round it was a string of sausages.
The bed's head and foot are now ONE bar from floor to floor. There was a post, a
rail and a ferrule dressing the joint between them, and every version of that
ferrule was a bulge: at the rail's own radius it left two surfaces in one place,
and wide enough to close over the rail's open end it was a swelling on a bar
that has none. A brass bedstead has no joint there to dress. Under the mattress
there is a slatted base, because 710 was read as the mattress's underside and is
not — it is the top edge of the side rail, which is the last height the mattress
can be seen past.
The soot the pendant has printed on the ceiling takes its position from
`PENDANT` itself, the record the room hangs the fitting from, so it cannot be
left behind if the lamp moves. The cobwebs are gone.
**The three lamps in the room cast shadows.** A cube of packed distance around
each bulb, baked ONCE at load and never again — nothing that casts here moves
and neither do the lamps, so the sliders change how bright a lamp is and not
where it is. Per frame this adds no geometry at all: the same 32 draw calls, the
same vertices. What it adds is per PIXEL, which is the whole performance story
below.
WebGL1 has no cube depth texture, so distance is packed into an ordinary RGBA
cube — and RADIAL distance rather than the depth buffer's, because a cube's six
faces each have their own frustum and their own idea of depth, where the
distance from the bulb is the same number whichever face holds it.
The bias is a normal offset scaled by the TANGENT of the incidence, because this
room draws with no face culling and flips every normal towards the eye — nothing
here is reliably wound, and the lampshade, the counterpane and the hall stand's
tubes are genuinely open surfaces. The usual cure for shadow acne is to cull one
side of the caster and that cure is not available. What the tangent buys: one
texel covers a texel ACROSS a surface and texel × tan(incidence) of DEPTH along
it, and the map holds one distance for the lot, so a grazing surface is compared
against a number taken from somewhere else on itself. A flat bias left a dark
ring round the desk lamp — not a caster, but the desk top going grazing about a
metre out.
**A lamp does not cast its own shadow, and casts everyone else's.** Each bulb is
excluded from its own map by the column of space its fitting occupies, and from
no other map — so the standard lamp's shade blocks nothing from the standard
lamp and blocks the pendant normally. Columns and not spheres, because a lamp is
not ball-shaped: the desk lamp's brim is 410 from its bulb and its foot 612,
with the desk it stands on at 580 between them, and a sphere wide enough for the
foot stops the desk top casting and lets light through the desk onto the floor.
The standard lamp needs two, a wide one for the shade and a narrow one for the
pole, because one wide enough for the 518 shade would reach the armchair 362
away and stop the chair casting from the lamp that lights it.
Three faults found this way, all of them invisible until something cast a
shadow. The desk lamp's bulb is a sphere of radius 52 whose centre IS the point
the shader lights from, so the first thing the bake saw in every direction was
the bulb and the room came back black. The standard lamp's light point sits
inside solid brass, the pole being 17 to 35 in radius at that height. And the
PENDANT'S LIGHT HUNG 775 BELOW ITS OWN FITTING: `bulb: 3,900` was true of the
drawn pendant, which stood on its finial's point, and the imported one hangs
from the plaster at 4,280 to 4,940. A point light does not care whether it is
inside its own shade, so nothing complained for as long as there were no
shadows. It is worked out from the fitting's own mesh now, and the whole of
`PENDANT` is `{x, y}` — nine more fields describing a lamp that is not in the
room went with it, since being written down beside the truth is exactly how that
number was believed.
**The page has a speed it can be turned down to.** Everything expensive here is
per pixel — five lamps, two analytic window sashes, the shadow lookups — and
none of it is per triangle: 88,000 triangles in 32 calls is nothing, and a slow
machine draws them as fast as a quick one. So the lights panel gained two
controls, which are on a phone as well as a desk. SHADOWS is soft, hard or off;
hard is one tap instead of five, three times less work, and it differs from soft
on under one per cent of pixels, which is the shadow edges and nothing else.
DETAIL is how much of the screen's resolution to render at, and the cost falls
with the SQUARE of it — a quarter is a sixteenth of the pixels and a tenth is a
hundredth. Measured against each other, hard shadows at three quarters is faster
than no shadows at full, so the shadows need not be the thing that is given up.
MSAA is there too, and it is the one setting that has to survive a reload:
whether a context multisamples is decided when the context is MADE, so the
choice is remembered and read back before there is a context to act on it.
The bottom of the detail ladder is a MEASURING INSTRUMENT rather than a way to
look at the room. At 1280 by 720 the frame goes 1314 ms, 867, 530, 272, 190
across the five settings — and the last of those draws one per cent of the
pixels of the first while taking a seventh of the time, not a hundredth. What
that gap names is a fixed cost of about 180 ms a frame which is not per pixel at
all. If a machine's frame rate does not move at a tenth, nothing drawn per pixel
is what is holding it back, and there is no sense turning anything else down.
**A lamp that contributes nothing is no longer asked what is in front of it.**
lampAt ends in max(dot(n, towards the lamp), 0), so a surface facing away from a
lamp gets exactly zero from it; multiplying that zero by a shadow lookup, or by
the seven smoothsteps and a square root of a window sash, gives zero either way.
Both were being spent on every fragment regardless, and in a room about half of
every wall faces away from any given lamp at any moment. The test is for greater
than zero and that is the point of it: an EXACT zero rather than a threshold, so
nothing is skipped that would have changed a pixel — where a threshold would put
a seam across the picture wherever it happened to fall, which is the mistake the
shadow map's far plane made.
Verified pixel for pixel, and worth saying how, because the first answer was
wrong: a view with no window in it came back identical, and a view with one came
back 933 pixels apart — all of them inside that window, where the chimney smoke
had drifted between the two frames. An animated plume will happily masquerade as
a rendering difference.
**And a panel of what the room is made of, under F.** Every piece of furniture
gets a box, and so do the door and the pendant, which were lifted out of
`buildRoom` to get one: the shell is what was MEASURED off the frames — plaster,
boards, openings — and a door and a light fitting standing in front of it are
models. Each piece is built from its own Builder, which is the whole trick, since
a Builder buckets its triangles by material and one shared between fifteen pieces
puts the desk's mahogany and the bed's in the same draw call with no seam to cut.
It costs a few dozen draw calls instead of a handful, and draw calls are the one
thing this page has to spare.
It is a TOOL before it is a saving, and the measurement says so plainly. Emptying
the room takes 265,221 vertices down to 30,594 and saves 44 per cent of the
frame — but the empty room still costs more than half of the full one, because
deleting a wardrobe does not delete its pixels, it reveals the wall behind it,
and that wall goes through the same shader. What is saved is only the OVERDRAW.
Something is always in front of the camera, and that is the floor no amount of
emptying gets under. Which is why DETAIL keeps paying where furniture and smoke
do not: it is the only control that reduces the number of pixels rather than
what stands behind them.
The smoke has a switch too, and it is not a performance control however much it
looks like one — it is the only thing here drawn blended, so it is the obvious
suspect, and measured twice, facing the windows and with a wall between, it came
out at the noise floor both times. Forty triangles of London sky, behind glass.
The readout starts hidden and is no longer BUILT while it is hidden: it was
rewriting its innerHTML sixty times a second, which is a DOM parse and a layout
every frame for a panel nobody is reading. It now carries a frame counter, off
the RAW interval rather than the clamped one — `dt` is capped at 50 ms so a slow
frame cannot teleport the walker, and a counter built on it would report 20 fps
as its floor however bad things got. X shows the readout, F the furniture, L the
lights, P cycles the surfaces.
**And there is music**, one track looped under the whole room with its volume
on the lights panel. Three things decide how it is wired and none of them is
taste. It does not load until it is asked to: `preload = "none"` keeps 4.3 MB
out of the way of a load that already has a 4.5-second tail of pictures and
materials, and a track fetched alongside them would be competing for the same
connection to deliver something nobody can hear yet. It starts on the SPLASH,
because that click is the only user gesture this page is guaranteed, and a
browser will not let a page make a noise it was not asked for — `play()` REJECTS
rather than throwing, so it is caught, and silence is an acceptable outcome of
asking. And the volume is remembered, like MSAA, because a page that plays music
at you every time you open it is a page you stop opening. Nought is off, and off
stays off.
Faults are known and left standing rather than hidden. The room renders two
to three times brighter than the frames — 50 against 22 over a whole view — and
several colours have been chosen to compensate, so correcting it means walking
those back too. And the fireplace surround's width has never been checked
against the frames the way everything else has. And the wireless is stretched
to a box measured off the frames that is a third taller for its width than the
model's own proportions, so its speaker reads as an oval where it should be
round. And the matchbox's label is a Sivakasi safety-matches wrapper priced in
rupees, which is what the model came wearing and is not what a London bedsit had
on its desk in 1942; it sits in a clean rectangle of the atlas and can be
repainted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Titanic alone, and one thing: the bedsit, which is new and unlisted. Nothing in the engine moved and nothing the game does is touched — `/bedsit/` reaches out for four engine modules and the site's URL helper and for nothing else, and nothing reaches back. It is a solid of the room behind `BEDSIT1.SET`, walked in the browser, worked out from the frames themselves and from the twenty-four depth images that set ships for hiding actors behind its furniture. The page fetches no rip: what it used to cut from `BEDSIT1.SET` at load is cut offline instead, so a deployed copy is the same room whether or not the host has the game data in place. The deploy adds one directory to the tree, `taoot/bedsit/`, and one entry to the build. The four other games and the site are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
It might seem to be low effort but it cost me quite a bit of time actually (ok, not that image in particular even though I had a hard time having the other two kids standing in front of their father). Most likely a mix between me having only basic 3d modeling knowledge and AI (Opus 5) just not being good enough (yet). I learnt a lot so I'd do it again! Back to code now... 😅 |
|
To clarify, I added the image of the kid because of her, "what is going on?" expression. Captures my initial confusion– what is this addition and I can't click the matches on the desk and is this mod support etc– as well as shows that I found what it was. It is interesting, though I think there's been a few people in the the TAOOT Facebook making similar 3D spaces/"remakes". Maybe some potential for collaboration? I assume most of whatever I can say about the experience as it is now is known– skybox mirrored, walk through furniture, etc. I'll note one detail I noticed just now, given the picture: the frame appears to be partly transparent. |

BEDSIT1.SETis a set of pre-rendered stills and the depth images the engine uses to occlude actors in front of them. There is no room behind those pictures — the game never needed one. This builds it: a solid, walked in the browser at/bedsit/, worked out from the frames themselves.It is an experiment and it is kept where an experiment can be dropped. Every source, tool and asset is under two paths and nothing else:
so removing it is
rm -rfon those two andgit checkout taoot/vite.config.ts, which is the only file outside them this touches — ten lines adding two unlisted build entries. Nothing in the game imports any of it; it reaches the other way for four engine modules and the site's URL helper, and for nothing else.Nothing here is a guess where a measurement was available. The shell, the furniture's boxes and the places things stand were fitted to the SET's own frames: scan a column for the brightness edge, put a ray through that pixel with the frame's optics, and cross it with a plane the room already knows or with the frame's own depth level. Where that could not be done the source says so and says what was assumed instead.
The page fetches no rip. What it used to cut from
BEDSIT1.SETat load is cut offline bybedsitmats.tsand served as PNGs, so a deployed copy is the same room whether or not the host has the game data in place — which matters here, because the runner has none.The commit message is the long version: three kinds of geometry, how each surface is painted, the shadow cubes, the speed controls and the measurements behind them, and the faults left standing rather than hidden.
Second commit
Version Titanic 0.9.68— the two-line bumptaoot-v0.9.68will be checked against. Titanic alone; the four other games and the site are untouched.Verified
npx tsc --noEmitclean.npm run build -w taootbuilds, and the built tree served the way the host serves it comes up at/bedsit/: title, logo, streamed music playing, baked charts and atlases all resolving, no failed requests and no page errors.🤖 Generated with Claude Code