Jump to content

Pixi performance: tile maps, Canvas and RenderTexture


Recommended Posts

this post is about Pixi performance, in particular with regards to (scrolling) tile maps...


I've implemented a scrolling tile map system and have implemented three different renderers for it:


1) renders the tilemap to a 2d Canvas context and uses that as the texture for a sprite that is added to the pixi stage. the drawing code is naive and draws each tile that is visible on the screen each frame (using drawImage)


2) uses a RenderSprite instead of a 2d Canvas, with a Sprite for each on-screen tile (all sharing the same BaseTexture). when drawing it sets the frame coordinates for each tile/sprite.


3) is an optimized version of the first technique, where two 2d Canvases are kept instead of just one and rather than draw every tile every frame, the area that is "common" between the current and previous frames is copied from the "back buffer" canvas to the "main" canvas (the one which is used as the sprite texture) and then only the row(s) and column(s) of tiles that are new for the frame are drawn. (generally this is just one, since most scrolling is done at rates slow enough that we don't jump over an entire tile width/height in a single frame). this was a standard technique "back in the day" of VGA cards and trying to squeeze enough performance to do scrolling games on the PCs we had back then.


(I haven't yet implemented the optimization used in #3 with RenderSprites instead of 2d Canvases)


I created a test "level" that is 32x32 tiles (1024x1024 pixels) with 15 layers full of tiles. (I basically just kept adding layers until I saw the frame-rate drop significantly below 60fps). I fully expected the second technique (using a RenderTexture) to be faster than the first, but I was mistaken... The perf numbers on Chrome 32 (on linux on an Intel sandybridge-based laptop) are as follows:


1) the frame-rate hovers in the 40s with some variability

2) the frame-rate sits at 30-32 fps with very little variability

3) the frame-rate hovers right around 60 fps with some variability


(numbers under firefox are very similar)


it's no surprise that the third, optimized technique is considerably faster than the non-optimized ones, but I'm a bit disappointed that using RenderTexture didn't pay off. (it is interesting to me that using a RenderTexture produced such a solid, un-fluctuating frame-rate. this would make it quite compelling if I could bring the frame-rate up a bit).


I'm mostly interested in seeing if anyone has explanations on why RenderTexture might be slower than using a 2d Canvas. and, obviously, I'm also interested in any faster ways using Pixi...




Link to comment
Share on other sites

naturally, soon after posting I discovered some problems. chief among them being that a bug in my code was preventing the autodetect render from running and forcing canvas mode. those results are not entirely surprising given that it was not running under webgl, since all RenderTexture was doing in the all-canvas case was adding overhead.


under webgl the code that worked for #2 doesn't quite work. it draws... very oddly - the tiles look as if they have the wrong color values, including alpha. anyway, I'll plug away at that and get it up and running so we can have some better numbers. initially (but with the tiles looking all wrong) the speed for #2 looks to be comparable to #3, perhaps even faster, which is good news.


I'll post back when I get everything working and some more numbers.

Link to comment
Share on other sites

update: I got technique #2 working under webgl (though there is still a minor glitch in the drawing when the view x/y crosses a tile boundary)


running under webgl I get the following numbers


1) the frame-rate hovers in the mid-20's

2) the frame-rate sits right around 60

3) the frame-rate hovers in the mid-to-upper-40's


this is much more along the lines of what I expected :) using the optimizations of #3 with RenderTextures should yield even better results, if it is possible (it requires having two 'drawing buffers'; I'm not sure how to accomplish that using RenderTextures... can you render one RenderTexture to another?)


take-aways so far:


- under webgl, RenderTexture is definitely faster

- under canvas, drawing the canvas yourself will be faster than using a RenderTexture, particularly if you optimize the drawing


no surprises :)


yet to-be-determined: 


- the perf difference between #2 using webgl and #3 using canvas

Link to comment
Share on other sites

I haven't made any progress towards writing the optimized code with RenderTextures, as I've uncovered a problem with RenderTextures in Pixi 1.4, using webgl only. essentially, using the technique I described above for #2, which works perfectly under 1.3 (and 1.4 in canvas mode), "glitches" every tile a tile boundary is scrolled across (i.e. when the sprite position is set to 0,0). in between, when the sprite is drawn at an offset the drawing is fine, but on tile boundaries it displays what looks like the wrong coordinates (as if the u,v's weren't changed for the frame perhaps). hard to explain, but bad.


my first assumption was that I was doing something wrong, so I debugged it for a couple of days. however, because it works perfectly in canvas mode *and* on 1.3, I'm inclined to think it's a webgl renderer bug.


my test code is as follows (it has dependencies on an external json loader and keyboard handler, but those would be easily swapped out. it loads a Tiled json file and lets you scroll around in it. doesn't attempt to enforce boundaries & only shows one layer, as this is just a test case I pared down to try to track this bug down. the relevant code is in the renderTileMap() function)


"use strict";var WIDTH = 512;var HEIGHT = 384;var z2 = zSquared();// require all the z2 modulesz2.require( ["loader", "input"] );// create a canvasvar canvas = z2.createCanvas( WIDTH, HEIGHT, true );// create the PIXI renderervar renderer = PIXI.autoDetectRenderer( canvas.width, canvas.height, canvas );// create the PIXI stagevar stage = new PIXI.Stage( 0x800000 );// load Tiled level jsonz2.loader.queueAsset( 'level', 'test.json', 'tiled' );z2.loader.load( start );function initInput(){z2.kbd.start();z2.kbd.addKey( z2.kbd.UP );z2.kbd.addKey( z2.kbd.DOWN );z2.kbd.addKey( z2.kbd.LEFT );z2.kbd.addKey( z2.kbd.RIGHT );z2.kbd.addKey( z2.kbd.SPACEBAR );}function updateInput( pos ){var pos_inc = 1;// check keysif( z2.kbd.isDown( z2.kbd.UP ) )pos.y -= pos_inc;if( z2.kbd.isDown( z2.kbd.DOWN ) )pos.y += pos_inc;if( z2.kbd.isDown( z2.kbd.LEFT ) )pos.x -= pos_inc;if( z2.kbd.isDown( z2.kbd.RIGHT ) )pos.x += pos_inc;}// create map object from Tiled jsonfunction initTileMap( json, stage ){var map = {};// load the map from the jsonvar i;map.viewWidth = WIDTH;map.viewHeight = HEIGHT;map.tileWidth = json.tilewidth;map.tileHeight = json.tileheight;map.widthInTiles = json.width;map.heightInTiles = json.height;map.viewWidthInTiles = map.viewWidth / map.tileWidth;map.viewHeightInTiles = map.viewHeight / map.tileHeight;map.width = json.width * map.tileWidth;map.height = json.height * map.tileHeight;// load each tilesetmap.tilesets = [];for( i = 0; i < json.tilesets.length; i++ ){var ts = json.tilesets[i];var w = ts.imagewidth / ts.tilewidth;var h = ts.imageheight / ts.tileheight;var tileset ={widthInTiles: w,heightInTiles: h,tiles: z2.loader.getAsset( ts.name ),start: ts.firstgid,end: ts.firstgid + w * h};// store the start tilemap.tilesets.push( tileset );}// load each tile layermap.layers = [];for( i = 0; i < json.layers.length; i++ ){var lyr = json.layers[i];if( lyr.type == 'tilelayer' ){var l = {};// tiles datal.data = lyr.data;if( lyr.properties ){l.scrollFactorX = lyr.properties.scrollFactorX ? +lyr.properties.scrollFactorX : 1;l.scrollFactorY = lyr.properties.scrollFactorY ? +lyr.properties.scrollFactorY : 1;}else{l.scrollFactorX = 1;l.scrollFactorY = 1;}map.layers.push( l );}}map.canvasWidth = map.viewWidth + map.tileWidth;map.canvasHeight = map.viewHeight + map.tileHeight;map.frame = new PIXI.Rectangle( 0, 0, map.canvasWidth, map.canvasHeight );map.tileTexture = new PIXI.BaseTexture( map.tilesets[0].tiles );map.doc = new PIXI.DisplayObjectContainer();map.tileSprites = [];for( i = 0; i <= map.viewHeightInTiles; i++ ){map.tileSprites.push( [] );for( var j = 0; j <= map.viewWidthInTiles; j++ ){var texture = new PIXI.Texture( map.tileTexture );var spr = new PIXI.Sprite( texture );spr.position.x = j * map.tileWidth;spr.position.y = i * map.tileHeight;map.tileSprites[i].push( spr );map.doc.addChild( spr );}}map.renderTexture = new PIXI.RenderTexture( map.canvasWidth, map.canvasHeight );map.renderTexture2 = new PIXI.RenderTexture( map.canvasWidth, map.canvasHeight );map.renderTexture.setFrame( map.frame );map.renderTexture2.setFrame( map.frame );map.sprite = new PIXI.Sprite( map.renderTexture );stage.addChild( map.sprite );map._prevTx = -1;map._prevTy = 0;return map;}function renderTileMap( map, lyr, viewx, viewy ){var x = ~~(viewx * lyr.scrollFactorX);var y = ~~(viewy * lyr.scrollFactorY);var tx = ~~(x / map.tileWidth);var ty = ~~(y / map.tileHeight);var tileset, tile, tile_x, tile_y;var orig_tx = tx;var orig_ty = ty;var i, j;tileset = map.tilesets[0];// set the frame for each tile spritefor( i = 0; i <= map.viewHeightInTiles; i++, ty++ ){if( ty < 0 || ty > map.heightInTiles ){map.tileSprites[i][0].visible = false;continue;}for( j = 0, tx = orig_tx; j <= map.viewWidthInTiles; j++, tx++ ){if( tx < 0 || tx > map.widthInTiles ){map.tileSprites[i][j].visible = false;continue;}tile = lyr.data[ty * map.widthInTiles + tx];// '0' tiles in Tiled are *empty*if( tile ){map.tileSprites[i][j].visible = true;tile--; // Tiled indices are one-off because 0 is 'no tile'tile_y = ~~(tile / tileset.widthInTiles);tile_x = tile - (tile_y * tileset.widthInTiles);var frame = map.tileSprites[i][j].texture.frame;frame.x = tile_x * map.tileWidth;frame.y = tile_y * map.tileHeight;frame.width = map.tileWidth;frame.height = map.tileHeight;map.tileSprites[i][j].texture.setFrame( frame );}else{map.tileSprites[i][j].visible = false;}}}map.sprite.position.x = -(x - (orig_tx * map.tileWidth));map.sprite.position.y = -(y - (orig_ty * map.tileHeight));// render the tile sprites' doc to the render texture// (clearing the render texture first)map.renderTexture.render( map.doc, null, true );}// main loopfunction main( map ){var lyr;lyr = map.layers[0];var pos = {x: 0, y:0};var count = 0;var f = function( dt ){// check inputupdateInput( pos );// render tile maprenderTileMap( map, lyr, pos.x, pos.y );// render stagerenderer.render( stage );// next framerequestAnimationFrame( f );};return f;}// called after assets are loadedfunction start(){var map;// create a Tiled map scenevar json = z2.loader.getAsset( 'level' );map = initTileMap( json, stage );// start input handlinginitInput();// start the main looprequestAnimationFrame( main(map) );}
Link to comment
Share on other sites

I emailed a repro case to you - I don't have an email for Mat, so perhaps you can pass it on to him

let me know if you need anything else


Awesome! Should work like a boss now as the textures were not being updated on the GPU before a render texture did its rendering.

The fix is in the latest dev branch


Thanks for providing a great example to work with!

Link to comment
Share on other sites

I've verified the fix and run my two test-cases on chrome 32 and firefox 26 on linux (my macbook was stolen recently so mac numbers will have to wait until insurance pays up. windows results will have to wait until hell freezes... I mean until I have a reason to boot to windows)


the two techniques tested are the optimized #3 technique above and an optimization & refinement of #2 (I realized that applying the optimizations of #3 to RenderTextures was non-sensical, as the RenderTexture method only ever changes the uv coordinates, so minimizing drawing isn't the same kind of issue). I'll call these:


technique #1: method using 2d Canvas Context drawing and optimizing to minimize drawing

technique #2: method using a Pixi RenderTexture with a grid of sprites for tiles, optimized to only set uv coordinates when necessary (on crossing tile boundaries)


as above, the test 'level' is 1024x1024 pixels drawn with 32x32 pixel tiles. however, in order to push the frame-rates down to the point where I could get some decent measurements of the optimized techniques, I upped the level from 15 layers to 50 (!) layers.


(I didn't bother getting numbers on the #1 naive technique above - initial results showed I never got a frame-rate higher than single-digits using this test level)


the frame-rates are read using the browsers' built-in frame-rate counters and are the averages of several runs


Chrome 32:
canvas mode:
tech. #1: 38-56 fps, averaging in the mid-40s
tech. #2: 7-8 fps
webgl mode:
tech. #1: 5-7 fps
tech. #2: 30-50 fps, averaging 35-40
Firefox 26:
canvas mode:
tech. #1: 35-60 fps, average upper-40s
tech. #2: 9-12 fps
webgl mode:
tech. #1: too low to matter (really really slow. firefox's fps display never left 0 even though I could move around so that clearly wasn't accurate)
tech. #2: 30-40 fps, averaging low/mid-30s (some runs averaged low 30s, some averaged mid 30s)
while these numbers are not at all comprehensive (just two browsers on one OS on one machine/GPU, no mobile at all, and only one small-sized level, albeit with an enormous number of layers), they do indicate that either of these techniques can perform quite well for tiled maps, but they should be chosen based on which renderer is used!
Link to comment
Share on other sites

In WebGL the fastest way to render many sprites is to make use of a texture atlas (aka sprite sheet) and push them all to the GPU in a single draw call. When you start introducing FBOs, it will incur performance impact since you are introducing more textures into the rendering process. Also, binding FBOs is pretty expensive.


This sounds like a lot of premature optimization. What's wrong with doing the following?


- Use a sprite sheet to pack all the tiles into a single power-of-two image, under 2048x2048.

- Add them all as children of a container.

- Iterate through and set the visibility of each sprite based on whether it will appear on-screen for that frame.

- Render the scene graph


It should perform plenty fast on canvas and WebGL. If the bottleneck comes from processing the scene graph, you can further optimize like so:


- Determine before rendering the "viewport" of your tile map based on scroll/zoom/etc.

- Iterate through only those visible tiles, and render them individually using Renderer.renderDisplayObject. This does without the scenegraph. This method hasn't been too well tested, though.

Link to comment
Share on other sites

thank you for the suggestions, it's always worth looking for a better and simpler method.


the reason I didn't investigate this technique was memory consumption; my feeling (based on experience and some 'guesstimate' numbers) is that creating a sprite for every tile in the game 'world' would take more memory than I wanted.


but, since this technique is trivial to implement, I did so. 


according to chrome's memory profiler:

memory usage for the RenderTexture-based technique was 40.1MB

memory usage for using per-tile sprites was 118MB


performance-wise, using the same test-case 'world' as above:


the per-tile sprite technique, using WebGL, the average frame-rate is ~22 fps

using Canvas, the average is ~16 fps


possibly not using the scene-graph could improve things here, I don't know.


a good case could be made that since this test 'world' is highly unrealistic these numbers are meaningless. for a given game, this may be true - but this test case does work as a good worst case scenario (or at least "really bad case" scenario).


(incidentally, I also ran a memory estimate for a real game level that I have which has a 512x24 tile 'world' with 5 layers of varying tile "densities" (percentage of tile 'places' in the world that are 'filled' and need a sprite - it would use around 28MB of memory for the tile sprites, versus 354KB used for the RenderTexture method [tile sprites and RenderTexture's mem usage]). 


using much larger tile sizes could alleviate some of the memory issue and perhaps bring the performance up, but this limits the number of tiles that can fit in the tile texture and has impacts the design of the collision handling and physics of the game as well, so it's not something I'm interested in at the current time.


thanks again

Link to comment
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.

Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.


  • Recently Browsing   0 members

    • No registered users viewing this page.
  • Create New...