By now, given the ridiculous amount of blogs I follow, I’ve read a lot about canvas. I know what it can do, and how to do it, but theory != practice, and on top of that, most of the articles I read were about “drawing” (paths, shapes, etc), rather than “blitting”, which is all that I’m going to be doing. And while blitting is simple enough, there’s still several things to try, and several things I don’t know, so I need to play around a bit and see how it works…
Basic baseline experiment: I load a transparent PNG image with my sprites, in an , and draw from it to a canvas. Nothing fancy.
NOTE: Below, when I mention “FPS”, that’s just (1 / time taken). It’s how many FPSs I could get, theoretically, based on the limitation imposed by drawing, which I’m assuming is going to be the main bottleneck, by far.
- Drawing 1000 tiles to a 1200×900 canvas takes 3.4ms (293 FPS).
- Having the target canvas be on-screen or not doesn’t change anything, if I resize the browser, minimize it, or move it off-screen, performance is exactly the same
- There’s no flickering, and no image shows up until I finish drawing the last of all the frames in my test, so it looks like I won’t need a backbuffer. And I do need some kind of “stack resetting” mechanism, like setTimeout, at the end of every frame. I was planning to have one anyway, but it looks like it’s not exactly optional.
- If I don’t clear the canvas between “frames”: 3.2ms (302 FPS) instead of 3.4. That means not only that the clearing takes some time, but also that canvas doesn’t “reuse” existing pixels, meaning it doesn’t save time by only drawing things that changed, just like I expected.
- If I use clearRect instead of fillRect to clear the canvas, it’s slightly slower: 3.5ms
- Clearing the canvas using “the width trick” (canvas.width = canvas.width): considerably slower!: 4.4ms
- Instead of drawing from an image that has a sprite sheet, I tried making a small canvas that has only one tile, and drew from the little canvas to the main one instead. This way, I’m using the overload that only takes x/y parameters, and it doesn’t have to do any clipping, getting only a subset of pixels, nothing, just straight drawing: 3.1ms (315 FPS). Faster! Yes!
- This last technique obviously impacts memory usage. Baseline Memory footprint: On page load: 6.6 Mb. After creating the little canvas and drawing: 11.1 Mb.
I then created 1000 little canvases with individual tiles (all the same tile, but that shouldn’t matter), and drew from them (one tile from each little canvas). Memory footprint after that: 23.5 Mb. It does take a lot of memory, but it’s not too bad.
Also, drawing like this, from a wild number of different canvases, the total test took 4.4ms (222 FPS). So this is much slower.
All in all, having one canvas per tile still looks like a winner, since there is no way in hell i’ll have 1000 different base tiles in my sprites.
- However, the fact that having too many little canvases is actually worse for performance throws away another idea I had, which is caching “composed” tiles. That is, if there are a lot of cells in a map that have the same combination of several layers (say, a tree over grass, with a wall), I can pre-combine these and keep them cached, and then I need one blit per cell, not 3
- I also tested the same thing, but in a more realistic scenario. I created 20 different canvases, and drew one “line” of cells in the screen from each of them: 3.1ms (312 FPS). Still as fast as my original test with one individual canvas. Perfect
- Drawing outside the canvas: If the destination rect of the blit falls outside the canvas area, drawing is much faster (makes sense really). Drawing the 1000 tiles but where they’re not visible: 0.96 ms (1038 FPS). If I don’t even clear the canvas: 0.77ms (1288 FPS).
Of course this is not useful at all as an optimization technique, however, it is still good to know, because it may mean I can be more liberal when picking which cells to draw (which is always a challenge in isometric). I can err on the side of safety and make sure the screen is completely painted by drawing a few too many cells, since the once that are outside the screen are extra fast.
- Finally, transparency… Back in the old days, we’d use “ROPs” to do the transparency (full transparency, not semi-transparency, or “opacity”, which we used to call “alpha blending” and was stupidly slow). You’d “AND” a mask, and then “OR” the image, and you’d have your transparency. Since PNGs support semi-transparency, I wanted to try if I could do something like this and gain some performance. Potentially, this could be a *huge* hit.
Unfortunately, I didn’t find any information whatsoever about anything resembling ROPs, and apparently there’s nothing like it.
Also, drawing a fully opaque image (even loading it from a JPG, so that the browser *knows* it’s not transparent at all) takes exactly the same time as drawing an image that has opacity 0.5, and the opaque images with fully-transparent pixels that i’ve been working with so far.
So… The good news is that nowadays semi-transparency is FAST.
And the bad news (at least in my old-fart opinion) is that if semi-transparency is FAST, and it’s just as fast as opaque blitting, then opaque blitting is SLOW!
This is a shame really, because ROP masking was orders of magnitude faster than “alpha-blending”…
Maybe that doesn’t apply anymore, and now the video card takes care of everything anyway and it wouldn’t be faster, and anyway 300 FPS with 1000 sprites is pretty fast anyway… But still… The little kid in me that used to try all kinds of weird shit to squeeze out a few more FPS is seriously disappoint.
So, I guess that’s all there is to test here. This is WAY faster than I expected, and things are actually looking very feasible so far.
It also looks like the time accessing the map is considerable compared to the time drawing, which feels weird, but I was probably testing with oversized maps.
1000 tiles cover almost 1200×900, so there’s no way i’ll need to read 2500 tiles per drawing, and I won’t have 10 layers, ever.
One big remaining question is how big is a realistic map… A full screen seems to have an ‘isometric diagonal’ of about 40 tiles, so 1000×1000 is quite a bit of walking, but not crazy huge.
And the map I tested was pretty big in memory. It may be necessary to do some kind of smart “section loading” if maps get too big.