All posts

Three ceilings a character runs into, and none of them say so

A Live2D character is a guest in somebody else's page. The pixel budget, the frame cap that made things worse, and the context limit that breaks the next character rather than the one that leaked.

September 21, 20264 min readSaytu team

Pixels, frames, contexts

On this page

A Live2D character on a storefront is a WebGL renderer running inside somebody else's page, beside their product images, their theme's carousel and whatever analytics they installed last year. Getting the model that far is its own set of problems; these three start once it is rendering. It is a guest. Three of the limits it runs into belong to the browser rather than to us, and not one of them arrives as an error that names itself.

A 280px bubble asking for 840

A renderer multiplies its logical size by the device pixel ratio to decide how many physical pixels it actually fills. Phones at DPR 3 are ordinary. A character bubble 280 logical pixels wide therefore asks for 840 by 840 physical pixels, which is 705,600 fragments the GPU has to shade every frame, for a decorative preview sitting in the corner of a shop.

The renderer is initialised with the ratio clamped at two instead:

resolution: Math.min(window.devicePixelRatio || 1, 2)

At two the same bubble is 560 by 560, or 313,600 fragments. Less than half the work, and on a bubble that size nobody can see the difference between the two.

What makes this one easy to miss is that nothing fails. The phone renders all 705,600 of those fragments. It just spends battery and thermal headroom doing it, on a page the shopper came to for a pair of shoes, and the only symptom is that their phone gets warm.

The frame cap that made it worse

The obvious companion to clamping resolution is clamping frame rate. Pixi offers exactly that, a `maxFPS` on the ticker, and we set it.

It made the character visibly worse on the machines that had the most to give. The throttle works by skipping animation-frame callbacks, and animation frames arrive on the display's own schedule. On a 120Hz display, asking for 60 does not produce an evenly spaced 60. It produces a pattern of kept and dropped frames quantised to 120Hz boundaries, and a character whose head turn is built from those frames reads as judder rather than as a lower frame rate.

So there is no frame cap. The character runs at whatever the display refreshes at, and the saving that the cap was supposed to buy comes from the resolution clamp above, which costs nothing anybody can see. A cheaper frame is better than a skipped one.

The ceiling that charges the next character

The third limit is the one that does not present itself where it happens.

A browser will keep only so many live WebGL contexts, around sixteen in the engines we care about, and when a page asks for one too many it silently evicts the oldest. Pixi's app.destroy() releases textures, the stage and the ticker, but it does not release the context. It cannot; the context belongs to the canvas.

Inside the studio a merchant switches between characters, and between Live2D and Spine, without reloading the page. Every switch tore down a renderer properly and left its context alive. Nothing looked wrong for the first fifteen. On the sixteenth the browser evicted the oldest context, which by then belonged to nothing, and the next renderer initialised against a dead one. Shader capability queries came back as zero, and the message on screen said the model had failed to load.

So the teardown holds the context from the start and hands it back explicitly:

glContext?.getExtension("WEBGL_lose_context")?.loseContext();

The debugging cost here was not the fix. It was that the character that broke was never the character that leaked. Every clue pointed at whichever model happened to be loading when the count ran out, and that model was fine.

What the three have in common

None of the three limits produce an error at the moment they are exceeded. The pixel one shows up as a warm phone. The frame one shows up as judder on the best hardware, which is the opposite of where you look for a performance problem. The context one shows up as a load failure on an innocent file.

A renderer that lives in someone else's page has to be written for ceilings it will never be told it is approaching. The clamp, the absent cap and the released context are all the same move: decide the budget in advance, because nothing is going to come and tell you.

Keep reading

All posts