Tuning a forged page for real devices
A forged page has a small number of dials and only one of them is expensive. This guide is about which, by how much, and how to find out on the hardware your visitors actually hold. The mechanism behind all of it is in the how-it-works guide; what follows assumes you have read it and wants to talk about budgets.
What --rows really costs
--rows is the only parameter that changes how much work there
is. Columns are not a separate control: they follow the image's aspect ratio,
as round(rows × width / height). The instance count is the
product of the two, so rows enter the bill twice: doubling
--rows roughly quadruples the cubes. That is the reason a grid
that felt free at 128 can stall a phone at 256.
The default is --rows 256; the accepted range is 32 to 1024,
and the CLI throws with the range named rather than silently clamping. The
studio sorts the result into three tiers — ok up to 60,000
instances, amber above that, red past 200,000 — and the CLI
turns the last two into warnings in those words: amber "may struggle on
laptops", red is "desktop GPUs only". Find out before you commit to a
file:
node cli.mjs photo.jpg --rows 256 --emit json
# → { grid: { cols, rows }, instances, tier, payloadBytes, emitBytes, … }
# no file is written
Two things trim the real count below that number. Source pixels whose
alpha is below --alpha (default 8) produce no cube at all — the
geometry's instance count is the survivors, not rows × columns — so a logo on
a transparent PNG is far cheaper than its bounding box suggests. And with the
default --faces image, sharpness comes from the texture, not the
grid: a coarse grid still lands a pin-sharp picture, it just flies in as
fewer, larger cubes. If the landed frame looks right and only the chaos phase
feels thin, you are buying spectacle, not detail. With
--faces flat the grid is the resolution — the one case
where a high row count is load-bearing, and also the one that carries a
second per-cube attribute, so it costs more memory per instance too.
Two properties of the mesh set the floor. Each instance is a real box, not a quad, and the mesh is built with frustum culling switched off — nothing drops out of the draw for drifting off screen. The whole grid is one draw call whatever you pick, so the CPU side barely moves; what scales is vertex work and, mid-scroll, fill rate, because cubes parked near the camera are magnified across a large share of the viewport and overdraw each other. That is why the middle of the track, not the end, is where a page falls over.
When the numbers say the grid is too big, the answer is genuinely "use fewer cubes". There is no flag that makes 300,000 instances cheap.
The source image is the other half of the bill
The cube count governs the frame budget; the image governs everything that happens before the first frame. A local file is embedded as base64 directly in the page, so a 6000 px photograph is shipped in full to every visitor and decoded on their main thread. The engine then samples it down to the grid through a halving pyramid of canvas draws — each one a full re-rasterisation — before it builds the mesh. A needlessly large source costs bytes on the wire, decode time, a longer pyramid and a larger texture upload, and buys nothing the mosaic can show.
The CLI warns when the embedded image passes 3 MB and suggests
--optimize or a hosted URL. Take it seriously: that is payload
on the critical path of a page whose selling point is being one file.
What --optimize actually does
--optimize re-encodes the embedded image at the size the
mosaic can actually sample, using the same sizing function the Studio's
re-encode calls, so the two frontends cannot disagree about what the page
ships. The target depends on the faces:
- Full-image faces (the default) resolve to the screen's own pixels, so the only ceiling is a detail cap on the long edge — 2560 px in the current build.
- Flat faces average each tile to one colour, so the target is two source pixels per cube. Beyond that the mosaic provably discards the detail.
Three caveats, all real. It needs sharp installed; if it is
missing the CLI says so and embeds the image at full size rather than failing.
It refuses on a URL source, because a referenced image is not embedded and
there is nothing to shrink. And it re-encodes to PNG, which for a large JPEG
can come out bigger — so the CLI keeps the result only when it is smaller than
the original. On an already-modest source with default faces,
--optimize can legitimately change nothing. That is the check
working, not a failure.
node cli.mjs photo.jpg --rows 144 --optimize
# grid 432 x 144 · … instances (ok) · payload … · wrote …
The other lever is an http(s) URL instead of a path: the
image is then referenced live rather than embedded, so the output file is
small, but it must stay hosted and CORS-readable. More on choosing the source
itself is in the image guide.
A phone is not a small desktop
Three differences matter, and only one of them is about raw speed.
Pixel ratio. The renderer clamps to
Math.min(window.devicePixelRatio, 2). On a 3× phone that is
already a deliberate cut, and it is why a page can be smooth on a 1280-wide
laptop and not on a physically smaller screen: the phone is drawing more
pixels, not fewer. Fill rate, not cube count, is usually what gives first.
Thermals. A phone that is fine for ten seconds is not necessarily fine for two minutes; sustained GPU load throttles. The engine helps: the loop keeps ticking so the clock stays honest, but it only draws a frame that differs from the one already on screen, so a settled picture costs nothing to hold. Do not add a second animation on top and undo that.
Timing, not frame counting. The camera's easing is raised
to the real elapsed time rather than applied per frame, so a slow device
settles in the same wall-clock time as a fast one instead of lagging behind
its own scroll. --damp (default 0.12, 1 = instant) changes the
feel, not the cost.
Measure, do not guess
Start with the verification path already in the tool — it is the only one that proves the effect rendered, rather than that a page loaded:
node cli.mjs photo.jpg --rows 256 --probe 0,0.5,1
node cli.mjs photo.jpg --rows 256 --probe 0,0.5,1 --probe-size 390x844
--probe needs Playwright, and it needs the standalone page on
disk — it is rejected with --emit js and with
--stdout. It opens the page headlessly, drives it to each
fraction, writes a screenshot, and reports on each: mean luma and standard
deviation, plus the assembly value the engine itself published. Three
warnings come out of that, and they are the numbers to watch.
BLANK? means the frame's standard deviation was under 2 —
the effect probably is not rendering. OFF-TRACK means the published
assembly missed the requested fraction by more than 0.02, which on a scroll
probe usually means the page had not settled; raise --probe-wait
or pass --director to drive the engine's synchronous frame seam
instead, where every frame is exactly the fraction asked for. NO
CLOCK means no assembly was published at all — a WebGL failure or the
fallback still, so the screenshot is unverified.
The probe proves correctness. For the frame budget, open the page on a
real phone over remote debugging and watch frame time, not a
frame-rate average — averages hide exactly the mid-track spike that ruins the
effect. Scrub slowly through the middle of the track and look for long
frames. If GPU time dominates, cut --rows; if scripting and
decode dominate the first seconds, the source image is the problem, not the
grid.
When the effect is the wrong choice
Some pages should not have it, and saying so is cheaper than tuning.
- Pages people read. The effect owns the scroll — the track defaults to 400 vh — and asks the reader to spend it watching rather than reading. On documentation, a long article or a checkout, that is a tax with no return.
- Audiences you cannot measure. If a meaningful share of your traffic is on old low-power hardware, the honest move is a still hero. The WebGL fallback covers the case where there is no GL context at all, but it does not rescue a device that has one and is slow with it.
- Text over the chaos. Mid-assembly the field is
high-contrast noise. If your headline has to be legible at every scroll
position, verify it at fraction 0.5 before you commit — and remember that
under
prefers-reduced-motionthe camera does not travel at all, so that visitor sees your copy over a resolved picture instead. - Deep links. A visitor arriving at an anchor part-way down the track lands mid-chaos. If that is a common entry, shorten the track or move the effect off that page.
The flags themselves, with ranges and defaults, are in the documentation. Choosing a source that survives being scattered is the image guide, and the geometry that makes this cheap at all is how the effect works.