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:

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.

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.