2026-11-09 · 17 min · 3d · gaussian-splatting · webgpu · rendering · simulation
Why read this
Notabletop 60%Re-implements splat-walk's gait, head springs and rolling-shutter film pass in numpy: 32.5 px of shear predicted at the peak turn, 30.9 measured.
- Interactive explanations
- Original analysis
- Runs in a browser
3D & spatialNo licencePractitioner teardown
How this was scored
- Is it new?
- 1 of 3: An incremental tweak
- Can I trust it?
- 2 of 3: Measures key facts from files, code or configs
- Can I run it?
- 2 of 3: Open code or weights with real limits
- Will I understand it?
- 3 of 3: Mechanism carried by interactives built from real code or data
- Can I act on it?
- 2 of 3: A concrete recipe, numbers or comparison
- Will it last?
- 2 of 3: A reference for a year or more
- Does it affect many?
- 1 of 3: A specialist community
- Only here?
- 2 of 3: A teardown or measurement few others did
Score 67 of 100, ranked 226 of 701 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored
On 5 November Will Eastcott, who created PlayCanvas, posted a 68-second clip of someone walking down a narrow London passage with their phone out. His caption opens: "This isn't video footage. It's a Gaussian splat rendered in real-time, walked by a simulated person filming on a virtual phone: gait, head turns, motion blur, rolling shutter, footsteps." The replies split between people who could not tell and one who said the camera movement gave it away.
The scene is not the new part. It is "Artillery Passage" by David Fletcher, downloaded from SuperSplat under CC BY 4.0, and in the thread Eastcott quotes its description: "1575 photos taken in June 2024 with a Sony a7R III and processed in RealityScan and Lichtfeld Studio" (reported). What is new is the camera. A splat fly-through usually glides on a spline at a constant height, and your eye reads it as a drone or a game. splat-walk builds the person and the phone instead, and the source is public.
I read all of it at commit 8a140e7 (5 November 2026, 2,344 lines of
JavaScript in src/ plus index.html). The permission layer in my sandbox
refused to execute the repo's own JavaScript, even under Node with a stub for
the two PlayCanvas classes it imports, so I did the next best thing: I ported
the camera to numpy from the constants in the code and ran that. Everything
below marked measured comes from that port or from the committed files.
Nothing here was rendered by the app itself, and I did not run it in a
browser.
- branch
- main
- tests
- none found
- source
- 122.5 kB
- commit date
- 2026-11-05
by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded
local clone, 2026-11-10 at 8a140e7 — branch, commit, commitDate, fileCount, hasTests, languages, shallow
shallow clone: counts describe the pinned tree, not the history
What the repo is
Four pieces, each one file:
src/walk.js(488 lines): the walker. Position and orientation as a pure function of time, so any instant can be asked for, including instants inside an exposure.src/route.js(58 lines): 19 waypoints and 10 "looks" (things the walker turns to see), in the scan's own coordinates, measured from the splat.src/post.js(329 lines): the "camera body", a full-screen shader pass in GLSL and WGSL that turns the clean pinhole render into footage.src/audio.js(406 lines): footsteps placed on the gait's heel strikes, four city beds and a phone-microphone chain.src/burn.js(396 lines) is the tap-to-burn effect that runs in the engine's per-splat shader hooks.
There is no licence file. package.json is marked private and names no
licence, so the code is visible but not licensed for reuse (measured, from the
files). The scene carries its own LICENSE.txt (CC BY 4.0) and the engine is
MIT.
The walk
The route is a centripetal Catmull-Rom spline through the 19 waypoints. My port
of buildPath gives a path 61.15 m long, 3 cm longer than the straight
polyline through the same points (measured). The floor height along it comes
from a table measured every metre of the scan, then smoothed over about 1.5 m
"so the scan's bumps don't reach the camera", as the comment says.
Speed is a small state machine stepped at 240 Hz: stand for 2.6 s, ease up to 1.2 m/s over 1.7 s with a smoothstep, cruise with a slow ±3.5% drift, then a two-metre stop whose speed falls as the remaining distance to the power 0.62, then hold for 6 s. Without the drift and the looks' slowdowns my port stops at 56.77 s and ends at 62.77 s, after 95 steps (measured). The posted clip runs 68.6 s. Each look slows the walker by 5 to 22 percent while it lasts, which accounts for some of the gap; I did not port the noise generator, so I cannot say how much (reasoned).
Then the gait. The code ties cadence to speed:
At the route's 1.2 m/s that is 1.792 steps a second, a step of 0.67 m (measured). A gait phase advances by that rate; every whole number is a heel strike. The camera's height rides on it:
where is a gait envelope, , so the bob fades in as the walker speeds up and out as they stop, and makes one leg push 6.5% harder than the other. The code also multiplies in a 12% random term per step "so no two steps are quite alike". The second harmonic is what gives the double hump the README describes. Side sway runs at half the frequency, , because you sway once per stride, not once per step.
At cruising speed the bob is 4.03 cm peak to peak and the sway 3.4 cm (measured, my port, without the random term). The order of magnitude matches the textbook figure for a walking adult's head, around 4 to 5 cm vertical, and I would not claim more than that (reasoned).
The heel strike gets its own term, a smooth dip with the time since the footfall over 50 ms, so it peaks at exactly 50 ms (measured) and pulls the camera down 1.6 mm and the pitch down 0.16 degrees. Small. It is there because a phone in two hands takes the shock of each step through the arms, and the eye catches it even when it cannot name it. On top come a trunk counter-rotation of 0.75 degrees in yaw, a 0.5-degree nod per step, a 0.6-degree roll toward the stance foot, breathing at 0.24 Hz while standing, and three octaves of smooth value noise for hand drift.
The head: two springs in series
This is the part I found most carefully built. The walker's attention is a sequence of goals. Most of the time the goal is the path 3.4 to 5 m ahead, with small irregular glances around it: 14% of them are a look down "to check the footing", 8% a look further along the lane. Ten scripted looks interrupt that: the red shopfront at the start, the hanging signs, "the lit graffiti down the side alley", a restaurant menu, the bollards. Each look holds its target for a while and re-fixates once or twice, a few degrees off, while holding.
The head does not jump to a goal. It chases it as a damped spring, and the spring is retuned for every movement:
// src/walk.js, _simulateGaze: a new movement
const amp = Math.hypot(goalY - hY, goalP - hP);
const T = 0.28 + 0.008 * amp; // bigger turns take longer
omega = 2.6 / T;
zeta = damp; // 0.64-0.78, drawn per lookThe comment calls this the main sequence, the term from eye-movement research for the way a saccade's duration grows with its size. The goal itself is eased over 45 ms first "so no turn starts with a jolt", and head speed is clamped at 240 degrees a second. Then the phone, held in the hands, chases the head through a second spring at 2.8 Hz with damping 0.55. The rendered camera is the phone, not the head.
I ran the two springs in numpy at the code's 240 Hz step for four turn sizes and both ends of the damping range (measured):
| turn | ζ | phone peak | at | overshoot | settles to 2% | shear at 1080p |
|---|---|---|---|---|---|---|
| 5° | 0.64 | 19.1°/s | 267 ms | 7.6% | 825 ms | 6.2 px |
| 20° | 0.64 | 58.0°/s | 308 ms | 7.3% | 1,100 ms | 18.8 px |
| 45° | 0.64 | 90.2°/s | 379 ms | 7.2% | 1,562 ms | 29.2 px |
| 60° | 0.64 | 100.5°/s | 421 ms | 7.2% | 1,842 ms | 32.5 px |
| 60° | 0.78 | 90.1°/s | 404 ms | 1.9% | 1,150 ms | 29.2 px |
Two things fall out. The overshoot depends almost only on the damping (7.2 to 7.6
percent at 0.64, 1.9 at 0.78), as it should for a second-order system, while
the duration scales with the turn. And the peak speed grows much more slowly
than the turn: a 60-degree turn is twelve times a 5-degree one but peaks only
about five times faster, because its spring is slower. That is the
main-sequence shape, and it is why a big look reads as deliberate rather than
whipped. The largest scripted look, toward the graffiti, is clamped to 60
degrees of yaw (maxYaw: 60), so 100.5 degrees a second is about the fastest
the camera turns in the film (reasoned: idle glances are smaller, and the
walker's own heading changes slowly).
The film pass: every pixel over its own slice of time
post.js is where the footage happens. The splats are rendered once per video
frame, at 30 fps, with 10% overscan, into an HDR target. The film pass then
rebuilds each output pixel from that one image.
The trick is in what the app hands the shader. For a frame at time it evaluates the walk three times: at , at and at , with
It passes the two outer poses as matrices relative to the centre one (uM0,
uM2). In the shader every pixel takes 10 taps at a normalised time
:
// src/post.js, the exposure loop (GLSL)
float rowF = (0.5 - n.y * 0.5 - 0.5) * uTiming.x; // uTiming.x = readout / H
float s = (float(i) + jitter) / float(taps) - 0.5; // uTiming.y = shutter / H
float f = clamp(rowF + s * uTiming.y, -1.0, 1.0);
vec3 o = originAt(f); // origin, straight line toward M0 or M2
vec3 vG = dirAt(dG, f); // ray direction, likewiseRead it row by row. The top row of the picture () centres its exposure at , the bottom row at : the sensor reads out from top to bottom across 16 ms. Each row is then open for 1/80 s around its own centre. With these defaults the earliest tap of the top row lands exactly at and the latest tap of the bottom row at , so the two matrices cover the whole frame's light and nothing more.
Each tap moves the ray to where the phone was at that instant, finds how far it travels before hitting a proxy of the passage (a floor plane, the two walls, and a lid 13 m up, each rebuilt per frame from a table of wall positions measured from the scan), and projects that point back into the one rendered image. Average the taps and you have a pixel that saw a moving world for 1/80 s, starting a little later than the row above it. The shader also splits the ray three ways by colour for lateral chromatic aberration, applies a barrel distortion of 0.03, a halation around the bright sky, phone-style sharpening on luma, lens falloff and sensor noise on a 1080p grid that is stronger in the shadows.
To check that the loop does what I think, I re-implemented it in numpy: same row timing, same 10 jittered taps, same straight-line interpolation of the ray toward the outer pose, same 10% overscan, for a camera yawing at a steady 100.5 degrees a second past a row of thin vertical poles. The procedural "scene" stands in for the rendered splat image, and at infinite distance only rotation matters. With a focal length of px for a 50-degree vertical field of view on 1,080 rows, the prediction for a vertical edge is = 32.5 px of shear top to bottom and = 30.2 px of smear (reasoned). The re-run measured (measured, 640x360 render scaled to 1080p):
| exposure | readout | shear | edge width |
|---|---|---|---|
| instant | none | 0.0 px | 6 px (the pole itself, 4.9 px) |
| 1/80 s | none | 1.5 px (noise) | 27 px |
| instant | 16 ms | 30.9 px | 6 px |
| 1/80 s | 16 ms | 32.1 px | 27 px |

The shear comes out within 1.6 px of the formula, about half a pixel of the 640x360 render I measured it in, where one pixel is three at 1080p. The edge widths are counted in those whole pixels too, which is why they read 27 rather than 30. The point stands: at the walk's fastest turn the readout leans every vertical in the passage by about 32 px, 1.7% of the frame's width, and the exposure softens it by about as much. At 30 fps a 1/80 s exposure is a 135-degree shutter, a little under the 180 degrees film uses (reasoned), which is about right for a phone outdoors in daylight.
One more check on the shortcut. The shader does not evaluate the walk 10 times per pixel. It has three poses and draws straight lines between them, so a curving head path is replaced by two chords. I measured how far the real 60-degree phone trajectory strays from that piecewise-linear stand-in over any 28.5 ms window: at most 0.0079 degrees, 0.16 px at 1080p (measured). Three poses per frame are plenty.
Where the camera model is thin
The depth proxy is the weak spot, and the code says so plainly by keeping it to a floor, two walls and a lid. Rotation does not need depth: a turning camera smears everything by the same angle. Translation does. At 1.2 m/s the phone moves 1.5 cm forward during one exposure. A wall 1.5 m to the side and 6 m ahead moves about 0.7 px in that time, and the proxy gets that right. A bollard 1 m to the side and 1.5 m ahead moves about 7.7 px, but the proxy gives it the depth of the wall behind it and so under-blurs it (reasoned, pinhole geometry). The bob adds about 2.7 mm of vertical travel per exposure, around 1.5 px at 2 m (reasoned). So near objects that stick out of the walls are slightly too sharp. Given that rotation contributes 20 to 30 px at the turns, nobody will see it, which is presumably why the proxy stops there.
The other limits are in the brief, not the code. There is one camera path, scripted for this one passage: the route and the looks are numbers measured from this scan. The walk is deterministic, and the README says the soundtrack's random events are "the same on every play". And there is no autofocus, no exposure hunting and no zoom; one reply asked for "focal length adjustment as phone camera tend to do". Those are the next things a phone does that this one does not.
What it costs to ship
The scene in the repo is 4,999,591 splats with three SH bands, compressed by
splat-transform v3.0.0 into chunked SOG (measured, from lod-meta.json). The
README says six spatial chunks. The commit has five, 0_0 to 0_4, holding
1,093,659 / 1,093,661 / 1,093,661 / 1,054,601 / 664,009 splats (measured). I
summed every file across them:
| texture | MB | bytes per splat |
|---|---|---|
sh0.webp | 16.06 | 3.213 |
quats.webp | 14.52 | 2.905 |
scales.webp | 13.89 | 2.778 |
means_l.webp | 12.77 | 2.554 |
shN_centroids.webp | 10.69 | 2.138 |
shN_labels.webp | 9.22 | 1.844 |
means_u.webp | 2.15 | 0.430 |
| meta | 0.08 | 0.016 |
| total | 79.38 | 15.88 |
That is 79.38 MB in decimal units, the README's "76 MB" in binary ones (measured). A float PLY with three SH bands stores 248 bytes a splat, so this is about a 15.6x shrink (reasoned). If you read the SOG teardown here, the shape is familiar, with one difference: there, the 64k-entry SH palette was amortised over five million splats. Here each of the five chunks carries its own 65,536-entry palette, a 960x1024 image per chunk, so the palette costs 2.14 bytes a splat instead of a rounding error. The four extra palettes, about 2.1 MB each, are much of the gap to the README's single-file figure of 65 MB (reasoned). The README says what the chunking buys: view-dependent colour is re-evaluated per chunk rather than for all 5M splats whenever the camera moves, and the single file "costs ~20 ms more per frame" (reported). By the README's own figures that is about 11 MB of download for 20 ms a frame against a 33 ms budget, a sensible trade.
The app benchmarks the GPU on a stretch of the walk before playback, then trims the splat render resolution (never the output) with GPU timer queries to hold 30 fps (reported, from the README; I did not run it). On WebGPU the burn effect runs in the projector compute pass. In the thread, asked whether burned splats still get sorted, Eastcott answered that they do: "it culls on opacity before the effect’s colour hook runs, so faded splats still get sorted. They cost no fill, though", and filed an engine issue (reported).
Streamed SOG and the SuperSplat update
Two posts from the same week put splat-walk in context. On 8 November Markus
Schütz pointed out that PlayCanvas had "open-sourced their Gaussian Splat
streaming solution", linking splat-transform and the engine's documentation
page for the streamed SOG format (I read the post, not that page). The post he quoted was PlayCanvas's own demo, a scan of Vyšehrad in
Prague: "121 million Gaussians", "1.3GB of Streamed SOG data", "11 million
collision voxels", rendered on WebGPU (reported). That works out to about 10.7
bytes a splat (reasoned), cheaper than splat-walk's 15.88; palettes shared over
more splats would explain it, but the level-of-detail layout and SH bands could
differ too, and I did not check. splat-walk is the other end of the same tool:
one level of detail, every splat kept, so nothing ever pops between levels, and
about 78 MB fetched up front. For a passage that is the right call; for a
hilltop fortress it is not. The SOG format article
covers the encoding both use.
On 29 October PlayCanvas shipped an update to SuperSplat, the hosting site the Artillery Passage scan came from: following creators, notifications for comments, likes and followers, a redesigned home page and search, and grabbing scenes with both hands in VR and AR (reported). None of that changes rendering; it changes where scans like this one come from. For how such scans get made in the first place, see Gaussian-splat capture in practice.
- license
- MIT
- branch
- main
- tests
- 86 files
- source
- 3.0 MB
- commit date
- 2026-10-29
by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded
local clone, 2026-11-01 at cf7bd62 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount
shallow clone: counts describe the pinned tree, not the history
Takeaways
If you build splat viewers, the recipe here transfers whole and is cheap. Make the camera a pure function of time. Put the walk, the head and the hands on separate layers: a gait from a cadence law and a couple of harmonics, a head that chases attention goals through a spring retuned per movement, a phone that chases the head through a stiffer one. Then hand a film pass two extra poses per frame and let each pixel integrate its own row's slice of time. The expensive part, splatting, still runs once per frame; the shutter costs 10 texture taps per pixel.
The numbers to steal: 1.3 + 0.41 v steps per second, a bob of about 4 cm with a 16% second harmonic, head-movement times of 0.28 s plus 8 ms per degree, a phone spring at 2.8 Hz, 1/80 s exposure, 16 ms readout. At those settings a 60-degree look leans every vertical by about 32 px at 1080p, which is roughly what a real phone does and what a spline fly-through never does.
What I could not check is the claim in the caption, that it passes for video. That is a perceptual question, and the replies answer it both ways.