components/RenderTiles.js

/* global platypus */
import {Container, Graphics, Rectangle, RenderTexture, Sprite} from 'pixi.js';
import {arrayCache, greenSlice, greenSplice, union} from '../utils/array.js';
import AABB from '../AABB.js';
import PIXIAnimation from '../PIXIAnimation.js';
import RenderContainer from './RenderContainer.js';
import config from 'config';
import createComponentClass from '../factory.js';
import recycle from 'recycle';

export default (function () {
    var EDGE_BLEED = 1,
        EDGES_BLEED = EDGE_BLEED * 2,
        doNothing = function () {
            return null;
        },
        tempCache = AABB.setUp(),
        sort = function (a, b) {
            return a.z - b.z;
        },
        getPowerOfTwo = function (amount) {
            var x = 1;

            while (x < amount) {
                x *= 2;
            }

            return x;
        },
        transformCheck = function (v, tile) {
            if (0x80000000 & v) {
                tile.scale.x = -1;
            }
            if (0x40000000 & v) {
                tile.scale.y = -1;
            }
            if (0x20000000 & v) {
                const x = tile.scale.x;
                tile.scale.x = tile.scale.y;
                tile.scale.y = -x;
                tile.rotation = Math.PI / 2;
            }
        },
        Template = function (tileSpriteSheet, id, uninitializedTiles) {
            this.id = id;
            this.instances = arrayCache.setUp();
            this.index = 0;

            // jit sprite
            this.tileSpriteSheet = tileSpriteSheet;
            this.getNext = this.initializeAndGetNext;
            this.uninitializedTiles = uninitializedTiles;
            uninitializedTiles.push(this);
        },
        nullTemplate = {
            getNext: doNothing,
            destroy: doNothing
        },
        prototype = Template.prototype;

    prototype.initializeAndGetNext = function () {
        this.initialize();

        this.index += 1;
        return this.instances[0];
    };

    prototype.initialize = function () {
        const
            index = +(this.id.substring(4)),
            anim = 'tile' + (0x0fffffff & index),
            tile = new Sprite((this.tileSpriteSheet._animations[anim] || this.tileSpriteSheet._animations.default).texture);
            
        transformCheck(index, tile);
        tile.template = this; // backwards reference for clearing index later.
        this.instances.push(tile);
        greenSplice(this.uninitializedTiles, this.uninitializedTiles.indexOf(this));

        delete this.getNext;
    };

    prototype.getNext = function () {
        var instance = this.instances[this.index],
            template = null;

        if (!instance) {
            template = this.instances[0];
            instance = this.instances[this.index] = new Sprite(template.texture);

            // Copy properties
            instance.scale    = template.scale;
            instance.rotation = template.rotation;
            instance.anchor   = template.anchor || template._animation.anchor;
        }

        this.index += 1;

        return instance;
    };

    prototype.clear = function () {
        this.index = 0;
    };
    
    prototype.destroy = function () {
        var i = 0;
        
        for (i = 0; i < this.instances.length; i++) {
            this.instances[i].destroy();
        }
        
        arrayCache.recycle(this.instances);
        this.recycle();
    };

    recycle.add(Template, 'Template', Template, null, true, config.dev);

    return createComponentClass(/** @lends platypus.components.RenderTiles.prototype */{

        id: 'RenderTiles',

        properties: {
            /**
             * The amount of space in pixels around the edge of the camera that we include in the buffered image. If not set, largest buffer allowed by maximumBuffer is used.
             *
             * @property buffer
             * @type number
             * @default 0
             */
            buffer: 0,

            /**
             * Determines whether to cache the entire map across one or more texture caches. By default this is `false`; however, if the entire map fits on one or two texture caches, this is set to `true` since it is more efficient than dynamic buffering.
             *
             * @property cacheAll
             * @type Boolean
             * @default false
             */
            cacheAll: false,

            /**
             * Whether to cache entities on this layer if the entity's render component requests caching.
             *
             * @property entityCache
             * @type boolean
             * @default false
             */
            entityCache: false,

            /**
             * This is a two dimensional array of the spritesheet indexes that describe the map that you're rendering.
             *
             * @property imageMap
             * @type Array
             * @default []
             */
            imageMap: [],

            /**
             * The amount of space that is buffered. Defaults to 2048 x 2048 or a smaller area that encloses the tile layer.
             *
             * @property maximumBuffer
             * @type number
             * @default 2048
             */
            maximumBuffer: 2048,

            /**
             * The x-scale the tilemap is being displayed at.
             *
             * @property scaleX
             * @type number
             * @default 1
             */
            scaleX: 1,

            /**
             * The y-scale the tilemap is being displayed at.
             *
             * @property scaleY
             * @type number
             * @default 1
             */
            scaleY: 1,

            /**
             * A sprite sheet describing all the tile images.
             *
             * Accepts an array of sprite sheet data since 0.8.4
             *
             * @property spriteSheet
             * @type Object|Array|String
             * @default null
             */
            spriteSheet: null,

            /**
             * Whether to cache the tile map to a large texture.
             *
             * @property tileCache
             * @type boolean
             * @default true
             */
            tileCache: true,

            /**
             * This is the height in pixels of individual tiles.
             *
             * @property tileHeight
             * @type number
             * @default 10
             */
            tileHeight: 10,

            /**
             * This is the width in pixels of individual tiles.
             *
             * @property tileWidth
             * @type number
             * @default 10
             */
            tileWidth: 10,
            
            /**
             * The map's top offset.
             *
             * @property top
             * @type Number
             * @default 0
             */
            top: 0,
            
            /**
             * The map's left offset.
             *
             * @property left
             * @type Number
             * @default 0
             */
            left: 0
        },

        /**
         * This component handles rendering tile map backgrounds.
         *
         * When rendering the background, this component figures out what tiles are being displayed and caches them so they are rendered as one image rather than individually.
         *
         * As the camera moves, the cache is updated by blitting the relevant part of the old cached image into a new cache and then rendering tiles that have shifted into the camera's view into the cache.
         *
         * @memberof platypus.components
         * @uses platypus.Component
         * @constructs
         * @listens platypus.Entity#add-tiles
         * @listens platypus.Entity#cache-sprite
         * @listens platypus.Entity#camera-loaded
         * @listens platypus.Entity#camera-update
         * @listens platypus.Entity#change-tile
         * @listens platypus.Entity#handle-render
         * @listens platypus.Entity#peer-entity-added
         */
        initialize: function (definition) {
            var imgMap = this.imageMap;

            this.doMap            = null; //list of display objects that should overlay tile map.
            this.cachedDisplayObjects = null;
            this.populate         = this.populateTiles;

            this.tiles            = {};

            this.renderer         = platypus.game.renderer;
            this.tilesSprite      = null;
            this.cacheTexture     = null;
            this.mapContainer      = null;
            this.laxCam = AABB.setUp();

            // temp values
            this.worldWidth    = this.tileWidth;
            this.worldHeight   = this.tileHeight;

            this.cache = AABB.setUp();
            this.cachePixels = AABB.setUp();

            this.uninitializedTiles = arrayCache.setUp();

            // Set up containers
            this.spriteSheet = PIXIAnimation.formatSpriteSheet(this.spriteSheet);
            this.tileSpriteSheet = new PIXIAnimation(this.spriteSheet);
            this.tileContainer = new Container();
            this.mapContainer = new Container();
            this.mapContainer.addChild(this.tileContainer);
            
            this.updateCache = false;

            // Prepare map tiles
            this.imageMap = arrayCache.setUp(this.createMap(imgMap));

            this.tilesWidth  = this.imageMap[0].length;
            this.tilesHeight = this.imageMap[0][0].length;
            this.layerWidth  = this.tilesWidth  * this.tileWidth;
            this.layerHeight = this.tilesHeight * this.tileHeight;

            // Set up buffer cache size
            this.cacheWidth  = Math.min(getPowerOfTwo(this.layerWidth  + EDGES_BLEED), this.maximumBuffer);
            this.cacheHeight = Math.min(getPowerOfTwo(this.layerHeight + EDGES_BLEED), this.maximumBuffer);

            if (!this.tileCache) {
                this.buffer = 0; // prevents buffer logic from running if tiles aren't being cached.
                this.cacheAll = false; // so tiles are updated as camera moves.
            }

            this.ready = false;

            if (!this.owner.container) {
                this.owner.addComponent(new RenderContainer(this.owner, definition, this.addToContainer.bind(this)));
            } else {
                this.addToContainer();
            }
        },

        events: {
            "cache-sprite": function (entity) {
                this.cacheSprite(entity);
            },

            "peer-entity-added": function (entity) {
                this.cacheSprite(entity);
            },

            /**
             * This event adds a layer of tiles to render on top of the existing layer of rendered tiles.
             *
             * @event platypus.Entity#add-tiles
             * @param message.imageMap {Array} This is a 2D mapping of tile indexes to be rendered.
             */
            "add-tiles": function (definition) {
                var map = definition.imageMap;

                if (map) {
                    this.imageMap.push(this.createMap(map));
                    this.updateCache = true;
                }
            },

            /**
             * This event edits the tile index of a rendered tile.
             *
             * @event platypus.Entity#change-tile
             * @param tile {String} A string representing the name of the tile to switch to.
             * @param x {Number} The column of the tile to edit.
             * @param y {Number} The row of the tile to edit.
             * @param [z] {Number} If RenderTiles has multiple layers, this value specifies the layer, with `0` being the bottom-most layer.
             */
            "change-tile": function (tile, x, y, z) {
                var map = this.imageMap;

                if (map) {
                    this.updateTile(tile, map[z || 0], x, y);
                    this.updateCache = true;
                }
            },

            "camera-loaded": function (camera) {
                this.worldWidth  = camera.world.width;
                this.worldHeight = camera.world.height;

                if (this.buffer && !this.cacheAll) { // do this here to set the correct mask before the first caching.
                    this.updateBufferRegion(camera.viewport);
                }
            },

            "camera-update": function (camera) {
                if (this.ready) {
                    this.updateCamera(camera);
                }
            },

            "handle-render": function () {
                if (this.updateCache) {
                    this.updateCache = false;
                    if (this.cacheGrid) {
                        this.updateGrid();
                    } else {
                        this.update(this.cacheTexture, this.cache);
                    }
                } else if (this.uninitializedTiles.length) { // Pre-render any tiles left to be prerendered to reduce lag on camera movement
                    this.uninitializedTiles[0].initialize();
                }
            }
        },

        methods: {
            addToContainer: function () {
                var container = this.container = this.owner.container,
                    extrusionMargin = 2,
                    mapContainer = this.mapContainer,
                    sprite = null,
                    z = this.owner.z;

                this.ready = true;

                this.updateRegion(0);

                if (!this.tileCache) {
                    this.render = doNothing;

                    mapContainer.scale.x = this.scaleX;
                    mapContainer.scale.y = this.scaleY;
                    mapContainer.x = this.left;
                    mapContainer.y = this.top;
                    mapContainer.z = z;
                    container.addChild(mapContainer);
                } else {
                    this.mapContainerWrapper = new Container();
                    this.mapContainerWrapper.addChild(mapContainer);

                    if ((this.layerWidth <= this.cacheWidth) && (this.layerHeight <= this.cacheHeight)) { // We never need to recache.
                        this.cacheAll   = true;

                        this.render = this.renderCache;
                        this.cacheTexture = RenderTexture.create(this.cacheWidth, this.cacheHeight);

                        this.tilesSprite = sprite = new Sprite(this.cacheTexture);
                        sprite.scale.x = this.scaleX;
                        sprite.scale.y = this.scaleY;
                        sprite.z = z;

                        this.cache.setBounds(0, 0, this.tilesWidth - 1, this.tilesHeight - 1);
                        this.update(this.cacheTexture, this.cache);
                        container.addChild(sprite);
                    } else if (this.cacheAll || ((this.layerWidth <= this.cacheWidth * 2) && (this.layerHeight <= this.cacheHeight)) || ((this.layerWidth <= this.cacheWidth) && (this.layerHeight <= this.cacheHeight * 2))) { // We cache everything across several textures creating a cache grid.
                        this.cacheAll = true;

                        // Make sure there's room for the one-pixel extrusion around edges of caches
                        this.cacheWidth = Math.min(getPowerOfTwo(this.layerWidth + extrusionMargin), this.maximumBuffer);
                        this.cacheHeight = Math.min(getPowerOfTwo(this.layerHeight + extrusionMargin), this.maximumBuffer);
                        this.updateRegion(extrusionMargin);

                        this.render = this.renderCacheWithExtrusion;
                        this.cacheGrid = this.createGrid(container);

                        this.updateCache = true;
                    } else {
                        this.render = this.renderCache;
                        this.cacheAll = false;

                        this.cacheTexture = RenderTexture.create(this.cacheWidth, this.cacheHeight);

                        this.tilesSprite = new Sprite(this.cacheTexture);
                        this.tilesSprite.scale.x = this.scaleX;
                        this.tilesSprite.scale.y = this.scaleY;
                        this.tilesSprite.z = z;

                        // Set up copy buffer and circular pointers
                        this.cacheTexture.alternate = RenderTexture.create(this.cacheWidth, this.cacheHeight);
                        this.tilesSpriteCache = new Sprite(this.cacheTexture.alternate);

                        this.cacheTexture.alternate.alternate = this.cacheTexture;
                        container.addChild(this.tilesSprite);
                    }
                }
            },

            cacheSprite: function (entity) {
                var x = 0,
                    y = 0,
                    object = entity.cacheRender,
                    bounds = null,
                    top = 0,
                    bottom = 0,
                    right = 0,
                    left = 0;

                // Determine whether to merge this image with the background.
                if (this.entityCache && object) { //TODO: currently only handles a single display object on the cached entity.
                    if (!this.doMap) {
                        this.doMap = arrayCache.setUp();
                        this.cachedDisplayObjects = arrayCache.setUp();
                        this.populate = this.populateTilesAndEntities;
                    }
                    this.cachedDisplayObjects.push(object);

                    // Determine range:
                    bounds = object.getBounds(object.transformMatrix);
                    bounds.x -= this.left;
                    bounds.y -= this.top;
                    top    = Math.max(0, Math.floor(bounds.y / this.tileHeight));
                    bottom = Math.min(this.tilesHeight, Math.ceil((bounds.y + bounds.height) / this.tileHeight));
                    left   = Math.max(0, Math.floor(bounds.x / this.tileWidth));
                    right  = Math.min(this.tilesWidth, Math.ceil((bounds.x + bounds.width) / this.tileWidth));

                    // Find tiles that should include this display object
                    for (x = left; x < right; x++) {
                        if (!this.doMap[x]) {
                            this.doMap[x] = arrayCache.setUp();
                        }
                        for (y = top; y < bottom; y++) {
                            if (!this.doMap[x][y]) {
                                this.doMap[x][y] = arrayCache.setUp();
                            }
                            this.doMap[x][y].push(object);
                        }
                    }

                    // Prevent subsequent draws
                    entity.removeComponent('RenderSprite');

                    this.updateCache = true; //TODO: This currently causes a blanket cache update - may be worthwhile to only recache if this entity's location is currently in a cache (either cacheGrid or the current viewable area).
                }
            },

            convertCamera: function (camera) {
                var worldWidth  = this.worldWidth / this.scaleX,
                    worldPosX   = worldWidth - camera.width,
                    worldHeight = this.worldHeight / this.scaleY,
                    worldPosY   = worldHeight - camera.height,
                    laxCam      = this.laxCam;

                if ((worldWidth === this.layerWidth) || !worldPosX) {
                    laxCam.moveX(camera.x);
                } else {
                    laxCam.moveX((camera.left - this.left) * (this.layerWidth - camera.width) / worldPosX + camera.halfWidth + this.left);
                }

                if ((worldHeight === this.layerHeight) || !worldPosY) {
                    laxCam.moveY(camera.y);
                } else {
                    laxCam.moveY((camera.top - this.top) * (this.layerHeight - camera.height) / worldPosY + camera.halfHeight + this.top);
                }

                if (camera.width !== laxCam.width || camera.height !== laxCam.height) {
                    laxCam.resize(camera.width, camera.height);
                }

                return laxCam;
            },

            createTile: function (imageName) {
                // "tile-1" is empty, so it remains a null reference.
                if (imageName === 'tile-1') {
                    return nullTemplate;
                }

                return Template.setUp(this.tileSpriteSheet, imageName, this.uninitializedTiles);
            },

            createMap: function (mapDefinition) {
                var x = 0,
                    y = 0,
                    index = '',
                    map   = null;

                if (typeof mapDefinition[0][0] !== 'string') { // This is not a map definition: it's an actual RenderTiles map.
                    return mapDefinition;
                }

                map = arrayCache.setUp();
                for (x = 0; x < mapDefinition.length; x++) {
                    map[x] = arrayCache.setUp();
                    for (y = 0; y < mapDefinition[x].length; y++) {
                        index = mapDefinition[x][y];
                        this.updateTile(index, map, x, y);
                    }
                }
                
                return map;
            },
            
            updateCamera: function (camera) {
                var x = 0,
                    y = 0,
                    inFrame = false,
                    sprite  = null,
                    ctw     = 0,
                    cth     = 0,
                    ctw2    = 0,
                    cth2    = 0,
                    cache   = this.cache,
                    cacheP  = this.cachePixels,
                    vp      = camera.viewport,
                    resized = (this.buffer && ((vp.width !== this.laxCam.width) || (vp.height !== this.laxCam.height))),
                    tempC   = tempCache,
                    laxCam  = this.convertCamera(vp);

                if (!this.cacheAll && (cacheP.empty || !cacheP.contains(laxCam)) && (this.imageMap.length > 0)) {
                    if (resized) {
                        this.updateBufferRegion(laxCam);
                    }
                    ctw     = this.cacheTilesWidth - 1;
                    cth     = this.cacheTilesHeight - 1;
                    ctw2    = ctw / 2;
                    cth2    = cth / 2;

                    //only attempt to draw children that are relevant
                    tempC.setAll(Math.round((laxCam.x - this.left) / this.tileWidth - ctw2) + ctw2, Math.round((laxCam.y - this.top) / this.tileHeight - cth2) + cth2, ctw, cth);
                    if (tempC.left < 0) {
                        tempC.moveX(tempC.halfWidth);
                    } else if (tempC.right > this.tilesWidth - 1) {
                        tempC.moveX(this.tilesWidth - 1 - tempC.halfWidth);
                    }
                    if (tempC.top < 0) {
                        tempC.moveY(tempC.halfHeight);
                    } else if (tempC.bottom > this.tilesHeight - 1) {
                        tempC.moveY(this.tilesHeight - 1 - tempC.halfHeight);
                    }
                    
                    if (!this.tileCache) {
                        this.update(null, tempC);
                    } else if (cache.empty || !tempC.contains(cache)) {
                        this.tilesSpriteCache.texture = this.cacheTexture;
                        this.cacheTexture = this.cacheTexture.alternate;
                        this.tilesSprite.texture = this.cacheTexture;
                        this.update(this.cacheTexture, tempC, this.tilesSpriteCache, cache);
                    }

                    // Store pixel bounding box for checking later.
                    cacheP.setAll((cache.x + 0.5) * this.tileWidth + this.left, (cache.y + 0.5) * this.tileHeight + this.top, (cache.width + 1) * this.tileWidth, (cache.height + 1) * this.tileHeight);
                }

                if (this.cacheGrid) {
                    for (x = 0; x < this.cacheGrid.length; x++) {
                        for (y = 0; y < this.cacheGrid[x].length; y++) {
                            sprite = this.cacheGrid[x][y];
                            cacheP.setAll((x + 0.5) * this.cacheClipWidth + this.left, (y + 0.5) * this.cacheClipHeight + this.top, this.cacheClipWidth, this.cacheClipHeight);

                            inFrame = cacheP.intersects(laxCam);
                            if (sprite.visible && !inFrame) {
                                sprite.visible = false;
                            } else if (!sprite.visible && inFrame) {
                                sprite.visible = true;
                            }
                            
                            if (sprite.visible && inFrame) {
                                sprite.x = vp.left - laxCam.left + x * this.cacheClipWidth + this.left;
                                sprite.y = vp.top  - laxCam.top  + y * this.cacheClipHeight + this.top;
                            }
                        }
                    }
                } else if (this.tileCache) {
                    this.tilesSprite.x = vp.left - laxCam.left + cache.left * this.tileWidth + this.left;
                    this.tilesSprite.y = vp.top  - laxCam.top  + cache.top  * this.tileHeight + this.top;
                }
            },

            updateTile: function (index, map, x, y) {
                var tile = null,
                    tiles = this.tiles;
                
                if (index.id) {
                    index = index.id;
                }
                tile = tiles[index];
                if (!tile && (tile !== null)) { // Empty grid spaces are null, so we needn't create a new tile.
                    tile = tiles[index] = this.createTile(index);
                }
                map[x][y] = tile;
            },

            createGrid: function (container) {
                var ch = this.cacheHeight,
                    cw = this.cacheWidth,
                    cth = this.cacheTilesHeight,
                    ctw = this.cacheTilesWidth,
                    h = 0,
                    w = 0,
                    outerMargin = EDGES_BLEED,
                    extrusion = EDGE_BLEED,
                    rt = null,
                    sx = this.scaleX,
                    sy = this.scaleY,
                    th = this.tileHeight,
                    tw = this.tileWidth,
                    tsh = this.tilesHeight,
                    tsw = this.tilesWidth,
                    x = 0,
                    y = 0,
                    z = this.owner.z,
                    col = null,
                    ct = null,
                    cg = arrayCache.setUp();

                for (x = 0; x < tsw; x += ctw) {
                    col = arrayCache.setUp();
                    cg.push(col);
                    for (y = 0; y < tsh; y += cth) {
                        // This prevents us from using too large of a cache for the right and bottom edges of the map.
                        w = Math.min(getPowerOfTwo((tsw - x) * tw + outerMargin), cw);
                        h = Math.min(getPowerOfTwo((tsh - y) * th + outerMargin), ch);

                        rt = RenderTexture.create(w, h);
                        rt.frame = new Rectangle(extrusion, extrusion, (((w - outerMargin) / tw) >> 0) * tw + extrusion, (((h - outerMargin) / th) >> 0) * th + extrusion);
                        ct = new Sprite(rt);
                        ct.z = z;
                        ct.scale.x = sx;
                        ct.scale.y = sy;
                        col.push(ct);
                        container.addChild(ct);

                        z -= 0.000001; // so that tiles of large caches overlap consistently.
                    }
                }
                
                return cg;
            },
            
            updateRegion: function (margin) {
                var tw = this.tileWidth * this.scaleX,
                    th = this.tileHeight * this.scaleY,
                    ctw = Math.min(this.tilesWidth,  ((this.cacheWidth - EDGES_BLEED)  / tw)  >> 0),
                    cth = Math.min(this.tilesHeight, ((this.cacheHeight - EDGES_BLEED) / th) >> 0);

                if (!ctw) {
                    platypus.debug.warn('"' + this.owner.type + '" RenderTiles: The tiles are ' + tw + 'px wide which is larger than ' + (this.cacheWidth - EDGES_BLEED) + 'px (maximum cache size of ' + this.cacheWidth + 'px minus a 2px edge bleed). Increase the maximum cache size or reduce tile size.');
                }
                if (!cth) {
                    platypus.debug.warn('"' + this.owner.type + '" RenderTiles: The tiles are ' + th + 'px high which is larger than ' + (this.cacheHeight - EDGES_BLEED) + 'px (maximum cache size of ' + this.cacheHeight + 'px minus a 2px edge bleed). Increase the maximum cache size or reduce tile size.');
                }

                this.cacheTilesWidth  = ctw;
                this.cacheTilesHeight = cth;
                this.cacheClipWidth   = ctw * tw;
                this.cacheClipHeight  = cth * th;

                if (this.tileCache) {
                    this.mapContainer.mask = new Graphics().beginFill(0x000000).drawRect(0, 0, this.cacheClipWidth + margin, this.cacheClipHeight + margin).endFill();
                }
            },

            updateBufferRegion: function (viewport) {
                var tw = this.tileWidth * this.scaleX,
                    th = this.tileHeight * this.scaleY;

                this.cacheTilesWidth  = Math.min(this.tilesWidth,  Math.ceil((viewport.width  + this.buffer * 2) / tw), (this.cacheWidth  / tw) >> 0);
                this.cacheTilesHeight = Math.min(this.tilesHeight, Math.ceil((viewport.height + this.buffer * 2) / th), (this.cacheHeight / th) >> 0);

                this.cacheClipWidth   = this.cacheTilesWidth  * tw;
                this.cacheClipHeight  = this.cacheTilesHeight * th;

                this.mapContainer.mask = new Graphics().beginFill(0x000000).drawRect(0, 0, this.cacheClipWidth, this.cacheClipHeight).endFill();
            },

            update: function (texture, bounds, tilesSpriteCache, oldBounds) {
                this.populate(bounds, oldBounds);

                this.render(bounds, texture, this.mapContainer, this.mapContainerWrapper, tilesSpriteCache, oldBounds);

                if (oldBounds) {
                    oldBounds.set(bounds);
                }
            },
            
            populateTiles: function (bounds, oldBounds) {
                var x = 0,
                    y = 0,
                    z = 0,
                    layer = 0,
                    tile  = null,
                    tiles = arrayCache.setUp();

                this.tileContainer.removeChildren();
                for (x = bounds.left; x <= bounds.right; x++) {
                    for (y = bounds.top; y <= bounds.bottom; y++) {
                        if (!oldBounds || oldBounds.empty || (y > oldBounds.bottom) || (y < oldBounds.top) || (x > oldBounds.right) || (x < oldBounds.left)) {
                            for (layer = 0; layer < this.imageMap.length; layer++) {
                                tile = this.imageMap[layer][x][y].getNext();
                                if (tile) {
                                    if (tile.template) {
                                        tiles.push(tile.template);
                                    }
                                    tile.x = (x + 0.5) * this.tileWidth;
                                    tile.y = (y + 0.5) * this.tileHeight;
                                    this.tileContainer.addChild(tile);
                                }
                            }
                        }
                    }
                }

                // Clear out tile instances
                for (z = 0; z < tiles.length; z++) {
                    tiles[z].clear();
                }
                arrayCache.recycle(tiles);
            },
            
            populateTilesAndEntities: function (bounds, oldBounds) {
                var x = 0,
                    y = 0,
                    z = 0,
                    layer   = 0,
                    tile    = null,
                    ent     = null,
                    ents    = arrayCache.setUp(),
                    tiles   = arrayCache.setUp(),
                    oList   = null;

                this.tileContainer.removeChildren();
                for (x = bounds.left; x <= bounds.right; x++) {
                    for (y = bounds.top; y <= bounds.bottom; y++) {
                        if (!oldBounds || oldBounds.empty || (y > oldBounds.bottom) || (y < oldBounds.top) || (x > oldBounds.right) || (x < oldBounds.left)) {
                            // draw tiles
                            for (layer = 0; layer < this.imageMap.length; layer++) {
                                tile = this.imageMap[layer][x][y].getNext();
                                if (tile) {
                                    if (tile.template) {
                                        tiles.push(tile.template);
                                    }
                                    tile.x = (x + 0.5) * this.tileWidth;
                                    tile.y = (y + 0.5) * this.tileHeight;
                                    this.tileContainer.addChild(tile);
                                }
                            }

                            // check for cached entities
                            if (this.doMap[x] && this.doMap[x][y]) {
                                oList = this.doMap[x][y];
                                for (z = 0; z < oList.length; z++) {
                                    if (!oList[z].drawn) {
                                        oList[z].drawn = true;
                                        ents.push(oList[z]);
                                    }
                                }
                            }
                        }
                    }
                }

                this.mapContainer.removeChildren();
                this.mapContainer.addChild(this.tileContainer);

                // Draw cached entities
                if (ents.length) {
                    ents.sort(sort);
                    for (z = 0; z < ents.length; z++) {
                        ent = ents[z];
                        delete ent.drawn;
                        this.mapContainer.addChild(ent);
                        if (ent.mask) {
                            this.mapContainer.addChild(ent.mask);
                        }
                    }
                }

                // Clear out tile instances
                for (z = 0; z < tiles.length; z++) {
                    tiles[z].clear();
                }
                
                arrayCache.recycle(tiles);
                arrayCache.recycle(ents);
            },
            
            renderCache: function (bounds, dest, src, wrapper, oldCache, oldBounds) {
                var renderer = this.renderer;

                if (oldCache && !oldBounds.empty) {
                    oldCache.x = oldBounds.left * this.tileWidth;
                    oldCache.y = oldBounds.top * this.tileHeight;
                    src.addChild(oldCache); // To copy last rendering over.
                }

                //clearRenderTexture(renderer, dest);
                src.x = -bounds.left * this.tileWidth;
                src.y = -bounds.top * this.tileHeight;
                renderer.render(wrapper, dest);
                dest.requiresUpdate = true;
            },

            renderCacheWithExtrusion: function (bounds, dest, src, wrapper) {
                var extrusion = 1,
                    border = new Graphics(),
                    renderer = this.renderer;

                // This mask makes only the extruded border drawn for the next 4 draws so that inner holes aren't extruded in addition to the outer rim.
                border.lineStyle(1, 0x000000);
                border.drawRect(0.5, 0.5, this.cacheClipWidth + 1, this.cacheClipHeight + 1);

                //clearRenderTexture(renderer, dest);

                // There is probably a better way to do this. Currently for the extrusion, everything is rendered once offset in the n, s, e, w directions and then once in the middle to create the effect.
                wrapper.mask = border;
                src.x = -bounds.left * this.tileWidth;
                src.y = -bounds.top * this.tileHeight + extrusion;
                renderer.render(wrapper, dest);
                src.x = -bounds.left * this.tileWidth + extrusion;
                src.y = -bounds.top * this.tileHeight;
                renderer.render(wrapper, dest);
                src.x = -bounds.left * this.tileWidth + extrusion * 2;
                src.y = -bounds.top * this.tileHeight + extrusion;
                renderer.render(wrapper, dest);
                src.x = -bounds.left * this.tileWidth + extrusion;
                src.y = -bounds.top * this.tileHeight + extrusion * 2;
                renderer.render(wrapper, dest);
                wrapper.mask = null;
                src.x = -bounds.left * this.tileWidth + extrusion;
                src.y = -bounds.top * this.tileHeight + extrusion;
                renderer.render(wrapper, dest);
                dest.requiresUpdate = true;
            },
            
            updateGrid: function () {
                var cache = this.cache,
                    cth = this.cacheTilesHeight,
                    ctw = this.cacheTilesWidth,
                    tsh = this.tilesHeight - 1,
                    tsw = this.tilesWidth - 1,
                    x = 0,
                    y = 0,
                    grid = this.cacheGrid;

                for (x = 0; x < grid.length; x++) {
                    for (y = 0; y < grid[x].length; y++) {
                        cache.setBounds(x * ctw, y * cth, Math.min((x + 1) * ctw, tsw), Math.min((y + 1) * cth, tsh));
                        this.update(grid[x][y].texture, cache);
                    }
                }
            },

            toJSON: function () {
                var imageMap = this.imageMap[0],
                    imgMap = [],
                    x = imageMap.length,
                    y = 0;
                
                while (x--) {
                    y = imageMap[x].length;
                    imgMap[x] = [];
                    while (y--) {
                        imgMap[x][y] = imageMap[x][y].id;
                    }
                }

                return {
                    type: 'RenderTiles',
                    buffer: this.buffer,
                    cacheAll: this.cacheAll,
                    entityCache: this.entityCache,
                    imageMap: imgMap,
                    maximumBuffer: this.maximumBuffer,
                    scaleX: this.scaleX,
                    scaleY: this.scaleY,
                    spriteSheet: this.spriteSheet,
                    tileCache: this.tileCache,
                    tileHeight: this.tileHeight,
                    tileWidth: this.tileWidth,
                    top: this.top,
                    left: this.left
                };
            },

            destroy: function () {
                var x = 0,
                    y = 0,
                    key = '',
                    grid = this.cacheGrid,
                    map = this.doMap,
                    img = this.imageMap;
                    
                if (grid) {
                    for (x = 0; x < grid.length; x++) {
                        for (y = 0; y < grid[x].length; y++) {
                            grid[x][y].texture.destroy(true);
                            this.container.removeChild(grid[x][y]);
                        }
                    }
                    arrayCache.recycle(grid, 2);
                    delete this.cacheGrid;
                } else if (this.tilesSprite) {
                    if (this.tilesSprite.texture.alternate) {
                        this.tilesSprite.texture.alternate.destroy(true);
                    }
                    this.tilesSprite.texture.destroy(true);
                    this.container.removeChild(this.tilesSprite);
                } else {
                    this.container.removeChild(this.mapContainer);
                }
                
                arrayCache.recycle(img, 2);
                
                for (key in this.tiles) {
                    if (this.tiles.hasOwnProperty(key)) {
                        this.tiles[key].destroy();
                    }
                }
                this.tiles = null;
                this.container = null;
                this.tilesSprite = null;
                this.spriteSheet.recycleSpriteSheet();
                
                if (map) {
                    for (x = 0; x < this.cachedDisplayObjects.length; x++) {
                        this.cachedDisplayObjects[x].destroy();
                    }
                    arrayCache.recycle(this.cachedDisplayObjects);

                    for (x = 0; x < map.length; x++) {
                        if (map[x]) {
                            for (y = 0; y < map.length; y++) {
                                if (map[x][y]) {
                                    map[x][y].recycle();
                                }
                            }
                            arrayCache.recycle(map[x]);
                        }
                    }
                    arrayCache.recycle(map);
                }
                
                this.laxCam.recycle();
                this.cache.recycle();
                this.cachePixels.recycle();
                arrayCache.recycle(this.uninitializedTiles);
            }
        },
        
        getAssetList: (function () {
            var
                getImages = function (ss, spriteSheets) {
                    if (ss) {
                        if (typeof ss === 'string') {
                            return getImages(spriteSheets[ss], spriteSheets);
                        } else if (ss.images) {
                            return greenSlice(ss.images);
                        }
                    }

                    return arrayCache.setUp();
                };
            
            return function (component, props, defaultProps) {
                var arr = null,
                    i = 0,
                    images = null,
                    spriteSheets = platypus.game.settings.spriteSheets,
                    ss = component.spriteSheet || props.spriteSheet || defaultProps.spriteSheet;
                
                if (ss) {
                    if (typeof ss === 'string' && (ss !== 'import')) {
                        return getImages(ss, spriteSheets);
                    } else if (Array.isArray(ss)) {
                        i = ss.length;
                        images = arrayCache.setUp();
                        while (i--) {
                            arr = getImages(ss[i], spriteSheets);
                            union(images, arr);
                            arrayCache.recycle(arr);
                        }
                        return images;
                    } else if (ss.images) {
                        return greenSlice(ss.images);
                    }
                }
                
                return arrayCache.setUp();
            };
        }())
    });
}());