Canvas ctx.filter is slow in Android WebView
Echo Bat fell to 8 fps in an Android WebView. ctx.filter cost 110–190 ms a layer, and the getImageData readback I swapped in cost more.
Contents
Echo Bat is a cave flyer: a bat in the dark that only sees rock when its sonar wave passes over it. I added painted art to the cave, two layers of it, and made the rock visible only inside a wave. It looked right. Then I flew it on the emulator and it ran at 8 frames a second.
This post is about the two costs that made up most of that: ctx.filter, and the
getImageData readback I added while getting rid of it. Both are ordinary Canvas 2D calls, and
both turned out to be expensive in the Android WebView. If canvas ctx.filter is slow in your
Android WebView too, the measurements below should tell you by how much.
The setup: a Capacitor 8.5.2 app, so the game is a <canvas> with a 2D context inside the
Android System WebView. Every number below comes from a Pixel 9 Pro emulator with an Android
16 image. An emulator is not a phone. Its graphics run through the host, and I have not
repeated any of this on real hardware. Read the numbers as before-and-after on one machine,
not as what your players will get.
Measuring the frame rate
The emulator window said "slow". I wanted a number, so I forwarded the WebView's DevTools
socket with adb forward and counted requestAnimationFrame callbacks for three seconds
during an autopilot run. The probe is the same one described in
how I made the emulator itself faster.
fps: 8 median frame: 116.7 ms p90: 133.4 ms
adb shell dumpsys gfxinfo agreed in its own way: 23 frames rendered in three seconds. Its
percentiles looked healthy, a 17 ms median. The problem was how few frames there were, so I
count frames and do not read a per-frame percentile on its own.
There were two suspects. The emulator had about 950 MB swapped out inside the guest, which had made it crawl on another day. And I had just added a lot of drawing. I measured the drawing first.
What ctx.filter cost
The background layer is painted art, dimmed to almost black so the cave reads as dark. I did the dimming with a canvas filter, on every frame:
// Simplified: the background layer, every frame
ctx.filter = 'brightness(0.02)';
for (const p of visiblePieces) ctx.drawImage(p.img, p.x, 0, p.w, screenHeight);
ctx.filter = 'none';
The rock layer used a second filter, brightness(0) invert(1), to make a white silhouette of
itself, which the sight mask then used to hide whatever was behind lit rock.
To see what that cost by itself, I ran a small benchmark inside the game's WebView over the same DevTools connection: draw six full-height art pieces across a 1278×2856 canvas, ten times, and read one pixel back at the end so the timing includes the actual drawing. That canvas is the screen's full resolution and larger than the game's own, so the absolute numbers are higher than in play. The ratios are the point:
pieces, no filter 12.9 ms
pieces, brightness filter 112.5 ms
pieces, silhouette filter 193.3 ms
plain canvas onto screen 1.1 ms
The same six drawImage calls cost nine to fifteen times more through a filter. That is
where the 110–190 ms per layer comes from, and the game was paying it on two layers every
frame.
The dimming never changes, so there was no reason to do it per frame. Each piece is now darkened once, the first time it is drawn, and the dark copy is kept:
// Simplified: dim once, keep the copy
const dimmed = new Map();
function dimmedPiece(img, level) {
const key = `${img.src}|${level}`;
if (dimmed.has(key)) return dimmed.get(key);
const c = document.createElement('canvas');
c.width = img.width;
c.height = img.height;
const g = c.getContext('2d');
g.drawImage(img, 0, 0);
g.globalCompositeOperation = 'source-atop'; // only over the piece's own pixels
g.globalAlpha = 1 - level;
g.fillStyle = '#000';
g.fillRect(0, 0, c.width, c.height);
dimmed.set(key, c);
return c;
}
source-atop matters there: it paints black only where the piece already has pixels, so
transparent edges stay transparent. The cache holds one dark copy per piece.
The silhouette filter went too, and that is where I made the next problem.
The readback I added to fix it
Without the silhouette, I still needed a way to cut the rock to "visible as much as the sonar lights it". The sonar's light was painted as a small grey mask, a quarter of the screen's width and height. A canvas has no built-in way to use a grey picture's brightness as transparency, so I read the mask back each frame and copied brightness into alpha:
// Simplified: the readback version
const px = maskCtx.getImageData(0, 0, maskW, maskH); // every frame
for (let i = 0; i < px.data.length; i += 4) px.data[i + 3] = px.data[i];
cutCtx.putImageData(px, 0, 0);
// …then cut the rock with the cut canvas, 'destination-in'
The mask was small, and I created its context with { willReadFrequently: true }, so I
expected this to be cheap. With the filters gone, play went from 8 to 29 fps, and a CPU
profile over three seconds put 275 ms in getImageData. On the home screen, which draws the
same cave zoomed in behind the bat, it was much worse:
total ms 3346
1554 (program)
1415 getImageData
96 drawImage
Over 40% of the sampled time, in one call that reads a small image. The profiler puts the
time in getImageData itself. A readback is commonly described as making the canvas finish
all its queued drawing before it can answer, which would explain the size of the number, but I
did not instrument that, so I am reporting the cost, not the mechanism.
The first replacement was worse
My first replacement for the readback was to multiply the rock by the grey mask and then cut
it back to its own shape with destination-in, using the art pieces as the cut. Play went
down, from 39 to 33 fps. A micro-benchmark of each step at the size the game draws, 720×1607,
showed which step it was:
destination-in, art pieces 12.06 ms
destination-in, small mask 5.78 ms
multiply, small mask 4.23 ms
source-atop, small mask 3.97 ms
plain copy onto another canvas 0.33 ms
The version that stayed
The fix was to stop thinking of the mask as light and paint it as darkness. The mask starts
as opaque black wherever nothing is seen. Each sonar wave and the bat's own glow then erase
darkness with destination-out. The result is laid over the rock canvas with source-atop,
so it only darkens pixels the rock already has:
// Simplified: a darkness mask, no readback
m.globalCompositeOperation = 'copy';
m.fillStyle = `rgba(0,0,0,${1 - dark})`; // unseen: nearly opaque black
m.fillRect(0, 0, maskW, maskH);
m.globalCompositeOperation = 'destination-out';
paintWaves(m); // each wave opens the dark
m.globalCompositeOperation = 'source-over';
rock.globalCompositeOperation = 'source-atop';
rock.drawImage(mask, 0, 0, rockW, rockH); // scaled up, over the rock only
rock.globalCompositeOperation = 'source-over';
No filter, no readback, no multiply, no destination-in with art. Play went from 33 to
51 fps.
Two things looked different afterwards, and both were improvements I had not planned: unlit rock now hides the background behind it, as solid rock should, and where two waves overlap their light adds up instead of the brighter one winning.
The rest of the way to 55
Two smaller fixes made up the rest, and one thing I expected to help did not.
Composing at the art's density. The cave art is painted at 2 pixels per cave unit, and the screen canvas had more than that, so every layer was being scaled up and pushed at a resolution with no extra detail in it. The cave is now composed on its own canvas at the art's density and scaled to the screen once per frame. In play that canvas is 720×1607 against the screen's 853×1904. That took play from 29 to 42 fps.
Sizing the mask from the right canvas. On the home screen the darkness mask was still
sized from the full screen, even though home zooms in and the composed cave there is only
240×536. The timer described below put 19.5 ms a frame on the one source-atop draw of that
oversized mask. Sized from the cave's canvas it measured 0.76 ms. Together with skipping a
second, dimmer pool of light each wave used to paint, which the darkness mask no longer
needed, that took home from 29 to 41 fps.
Restarting the emulator did not help. With 950 MB swapped out it was a fair suspect, and a cold boot does fix a slow emulator when the time is the system's. Here it was mine: after the restart, with swap at zero, play measured 39 fps and home 25, no better than before.
| After | Play | Home |
|---|---|---|
| Start | 8 | not measured |
| Filters removed, readback added | 29 | not measured |
| Cave composed at the art's density | 42 | 29 |
| Emulator cold-booted | 39 | 25 |
Readback replaced by multiply + destination-in |
33 | 27 |
Darkness mask, destination-out + source-atop |
51 | 29 |
| Mask sized from the cave canvas | 49–55 | 41 |
The last row is three runs of the same build: 54, 48.7 and 55 fps, after a first run at 42.
One frame in ten still takes 33 ms. By the drawImage timer described next, the cave's own
drawing now costs about 8 ms a frame. At the start, a whole frame took 117 ms. I recorded a flight afterwards to check the look had not changed: dark between
waves, rock coming in with each wave and fading out behind it.
A timer around drawImage can point at the wrong call
To find the density and mask problems I wrapped CanvasRenderingContext2D.prototype.drawImage
with performance.now() and grouped the calls by source size, target size and composite
operation. It worked, but read it carefully. In one run it put 18 ms a frame on a plain
720×1607 copy, the same copy that measured 0.33 ms in isolation. After I removed the
destination-in step that ran before it, that copy fell to 5.3 ms without itself changing.
A per-call timer tells you where the canvas paid for its work, which is not always where the
work was asked for. I used it to pick candidates, then benchmarked each one alone before
changing anything.
Keeping the filter out
The filter was easy to add and nothing complained, so a test now checks that the stage layers
never set one. It replaces filter on a recording context with a setter that notes anything
other than 'none':
// Simplified
it('draws the stage without a canvas filter', () => {
const ctx = recordingContext();
let filtered = false;
Object.defineProperty(ctx, 'filter', {
set: (f) => { if (f !== 'none') filtered = true; },
get: () => 'none',
});
drawStage(ctx, run);
expect(filtered).toBe(false);
});
It is cheap, and it will fail the day somebody, probably me, reaches for brightness() again
because it is the shortest way to dim an image. 469 tests passed with the change.
What I did not test
A real phone. Everything here is one emulator, with its graphics going through the host, so
I can say how these calls compare to each other there and nothing about their absolute cost
on a mid-range Android device. I also did not compare WebView versions, and I did not try
OffscreenCanvas or WebGL, which would be a different post.
Takeaway
If a Canvas 2D game is slow in an Android WebView, look for three things before anything
clever: ctx.filter set inside the frame loop, getImageData called every frame, and
canvases larger than the art they hold. Anything a filter does that does not change can be
done once and kept. A mask that needs reading back can usually be painted as darkness and
laid on with source-atop. And benchmark each step alone inside the WebView itself, over the
DevTools socket, because that is the only place these numbers mean anything.