~/satyajit

splat-walk: a Gaussian splat filmed by a simulated person on a simulated phone

mdjsonmcp

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.

willeastcott/splat-walk@8a140e7 · snapshot 2026-11-10
tracked files
107
branch
main
tests
none found
source
122.5 kB
commit date
2026-11-05
source by language
JavaScript116.5 kB(9)CSS3.9 kB(1)HTML2.2 kB(1)

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:

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:

cadence(v)=1.3+0.41 vsteps/s\text{cadence}(v) = 1.3 + 0.41\,v \quad \text{steps/s}

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 ϕ\phi advances by that rate; every whole number is a heel strike. The camera's height rides on it:

bob=−0.019 e a(ϕ) (cos⁡2πϕ+0.16cos⁡(4πϕ+0.6))\text{bob} = -0.019\,e\,a(\phi)\,\big(\cos 2\pi\phi + 0.16\cos(4\pi\phi + 0.6)\big)

where ee is a gait envelope, (v/1.2)0.8(v/1.2)^{0.8}, so the bob fades in as the walker speeds up and out as they stop, and a(ϕ)=1+0.065cos⁡πϕa(\phi) = 1 + 0.065\cos\pi\phi 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, 0.017 esin⁡πϕ0.017\,e\sin\pi\phi, 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 u2e2(1−u)u^2 e^{2(1-u)} with uu 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 look

The 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 peakatovershootsettles to 2%shear at 1080p
5°0.6419.1°/s267 ms7.6%825 ms6.2 px
20°0.6458.0°/s308 ms7.3%1,100 ms18.8 px
45°0.6490.2°/s379 ms7.2%1,562 ms29.2 px
60°0.64100.5°/s421 ms7.2%1,842 ms32.5 px
60°0.7890.1°/s404 ms1.9%1,150 ms29.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 tt it evaluates the walk three times: at tt, at t−Ht - H and at t+Ht + H, with

H=12(shutter+readout)=12(180+0.016)=14.25 ms.H = \tfrac{1}{2}(\text{shutter} + \text{readout}) = \tfrac{1}{2}\big(\tfrac{1}{80} + 0.016\big) = 14.25\ \text{ms}.

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 f∈[−1,1]f \in [-1, 1]:

// 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, likewise

Read it row by row. The top row of the picture (ny=1n_y = 1) centres its exposure at f=−12 readout/Hf = -\tfrac{1}{2}\,\text{readout}/H, the bottom row at +12 readout/H+\tfrac{1}{2}\,\text{readout}/H: 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 f=−1f = -1 and the latest tap of the bottom row at f=+1f = +1, 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 540/tan⁡25∘=1158540 / \tan 25^\circ = 1158 px for a 50-degree vertical field of view on 1,080 rows, the prediction for a vertical edge is 1158⋅ω⋅0.0161158 \cdot \omega \cdot 0.016 = 32.5 px of shear top to bottom and 1158⋅ω/801158 \cdot \omega / 80 = 30.2 px of smear (reasoned). The re-run measured (measured, 640x360 render scaled to 1080p):

exposurereadoutshearedge width
instantnone0.0 px6 px (the pole itself, 4.9 px)
1/80 snone1.5 px (noise)27 px
instant16 ms30.9 px6 px
1/80 s16 ms32.1 px27 px
Four panels of thin vertical lines. Instant: crisp and vertical. 1/80 s exposure: each line widened into a soft grey band, still vertical. 16 ms readout: crisp lines slanted so the bottom sits to the right of the top. Both: soft grey bands slanted the same way.
My numpy re-implementation of the film pass's sampling loop, camera yawing at 100.5°/s past poles 5° apart. The readout slants every edge; the exposure widens it. A procedural stand-in for the splat render, not a frame from the app (our render).

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:

textureMBbytes per splat
sh0.webp16.063.213
quats.webp14.522.905
scales.webp13.892.778
means_l.webp12.772.554
shN_centroids.webp10.692.138
shN_labels.webp9.221.844
means_u.webp2.150.430
meta0.080.016
total79.3815.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.

playcanvas/splat-transform@cf7bd62 · snapshot 2026-11-01
tracked files
328
license
MIT
branch
main
tests
86 files
source
3.0 MB
commit date
2026-10-29
source by language
TypeScript2.0 MB(206)JavaScript1.0 MB(93)C3.3 kB(1)

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.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "splat-walk: a Gaussian splat filmed by a simulated person on a simulated phone", ai.satyajit.si, November 2026.

bibtex
@misc{ghana2026splatwalk,
  author = {Satyajit Ghana},
  title  = {splat-walk: a Gaussian splat filmed by a simulated person on a simulated phone},
  url    = {https://ai.satyajit.si/articles/splat-walk},
  year   = {2026}
}
share