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

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.satyajit.si/articles/splat-walk
> date: 2026-11-09
> tags: 3d, gaussian-splatting, webgpu, rendering, simulation

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](https://github.com/willeastcott/splat-walk).

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.

<RepoCard repo="willeastcott/splat-walk" />

## 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:

$$
\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:

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

where $e$ is a gait envelope, $(v/1.2)^{0.8}$, so the bob fades in as the walker
speeds up and out as they stop, and $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\,e\sin\pi\phi$, because you sway once per stride, not once
per step.

<GaitBob />

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 $u^2 e^{2(1-u)}$ with $u$ 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:

```js
// 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.

<HeadSpring />

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 $t$ it
evaluates the walk three times: at $t$, at $t - H$ and at $t + H$, with

$$
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 \in [-1, 1]$:

```glsl
// 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 ($n_y = 1$) centres its exposure
at $f = -\tfrac{1}{2}\,\text{readout}/H$, the bottom row at $+\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 = -1$ and the latest tap of the bottom row at $f = +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.

<ShutterSheet />

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^\circ = 1158$ px for a 50-degree
vertical field of view on 1,080 rows, the prediction for a vertical edge is
$1158 \cdot \omega \cdot 0.016$ = **32.5 px** of shear top to bottom and
$1158 \cdot \omega / 80$ = **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 |

<Figure
  src="https://media.thesatyajit.com/ai/articles/splat-walk/rolling-proof.png?v=26e3b7c296eb1fb5"
  alt="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."
  caption="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:

| 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](/articles/sog-splat-format) 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](/articles/sog-splat-format)
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](/articles/3dgs-capture-pipelines).

<RepoCard repo="playcanvas/splat-transform" />

## 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.
