← All posts

WebGL Tilemap Optimisation: Atlases, Chunks, and Instancing

Learn how texture atlases, map chunking, and instanced quads keep 2D browser games buttery smooth at 60 FPS without torching your GPU.

There is a unique flavour of heartbreak known only to indie web developers: you build a gorgeous, retro-inspired 2D top-down dungeon crawler, boot it up in Chrome, and watch your laptop fan spin up like a jet turbine while the frame rate tanks to a cinematic 14 FPS.

Why? Because your render loop is frantically asking WebGL to draw 50,000 tiny grass tiles one by one.

Browsers are surprisingly capable gaming platforms, but they are unforgiving when it comes to CPU-to-GPU overhead. If you want seamless scrolling across expansive retro worlds without melting your players' hardware, you must treat WebGL like a fickle, queue-hating bouncer. Here is how modern browser games combine Texture Atlases, Map Chunking, and Instanced Quads to achieve rock-solid 60 FPS performance.


Key Takeaway: The WebGL Tilemap Stack

Tilemap rendering optimisation in WebGL is the architectural process of reducing draw calls, state changes, and vertex memory overhead when rendering grid-based 2D worlds. By packing sprites into a single image (Texture Atlas), grouping tiles into renderable sectors (Chunking), and drawing repeated geometry via hardware replication (Instanced Quads), developers eliminate WebGL context switches and keep browser games performing smoothly.


1. Texture Atlases: Stop Swapping Textures

Every time WebGL has to switch textures mid-frame, execution grinds to a halt. In graphics programming, binding a new texture constitutes an expensive state change. If your dirt tile, stone wall, and water puddle live in separate PNG files, drawing a mixed scene forces the GPU to swap textures back and forth dozens of times per frame.

A texture atlas (or sprite sheet) packs all your environmental art into a single large texture (typically 1024×1024 or 2048×2048 pixels).


+-------------------+-------------------+
|  Grass (Tile 0)   |  Stone (Tile 1)   |
|  UV: [0.0, 0.5]   |  UV: [0.5, 0.5]   |
+-------------------+-------------------+
|  Water (Tile 2)   |  Sand (Tile 3)    |
|  UV: [0.0, 0.0]   |  UV: [0.5, 0.0]   |
+-------------------+-------------------+

By mapping each tile index to normalised texture coordinates (UV coordinates from 0.0 to 1.0), you bind the atlas once at the start of your pass and never switch it again. The GPU draws the entire environment without breaking stride.


2. Chunking: Cull What You Cannot See

A classic mistake in browser game development is feeding the entire map into a single massive buffer. If your map is 1000×1000 tiles, that is one million tiles. Even if 99% of them are off-screen, a naive pipeline sends all of them down the pipe.

Chunking divides your world into manageable grids—commonly 16×16 or 32×32 tiles.

  • Frustum Culling on the Cheap: Instead of checking visibility for 100,000 individual tiles, your camera only checks the bounding boxes of your chunks. If a chunk isn't inside the viewport, you ignore it entirely.
  • Dirty-Flag Updates: In games with destructible environments or dynamic water, you avoid rebuilding your entire map's vertex buffer when one block breaks. You only mark that single 16×16 chunk as "dirty" and re-upload its specific buffer slice.

3. Instanced Quads: WebGL 2.0 Magic

Traditionally, developers combined tiles by generating four vertices per tile and pushing massive static VBOs (Vertex Buffer Objects) to the GPU. While functional, it consumes significant memory and limits runtime alterations.

Enter Hardware Instancing (supported natively in WebGL 2.0 via gl.drawArraysInstanced or gl.drawElementsInstanced).

Instead of sending thousands of identical rectangular meshes to the GPU, you define one single unit quad (two triangles, four vertices). Then, you feed WebGL an instanced array containing per-tile metadata:

1. World Position Offset (vec2: x, y)

2. Atlas UV Offset / Tile ID (vec2 or float)

The vertex shader multiplies the single quad geometry across all provided instance offsets in parallel on the GPU.

Minimal Instanced Vertex Shader (GLSL 300 es)


#version 300 es
layout (location = 0) in vec2 a_quadVertex; // Base quad: (0,0) to (1,1)
layout (location = 1) in vec2 a_tileOffset;  // Instance world pos
layout (location = 2) in vec2 a_uvOffset;    // Instance atlas UV coords

uniform mat4 u_projection;
uniform vec2 u_tileSize;
uniform vec2 u_atlasTileSize;

out vec2 v_uv;

void main() {
    // Scale unit quad to tile size and apply world offset
    vec2 worldPos = (a_quadVertex * u_tileSize) + a_tileOffset;
    
    // Map vertex corner to texture atlas slice
    v_uv = a_uvOffset + (a_quadVertex * u_atlasTileSize);
    
    gl_Position = u_projection * vec4(worldPos, 0.0, 1.0);
}

This reduces dynamic memory bandwidth dramatically. Rather than passing 8–12 floats of vertex positions and texture coords per tile every frame, you pass a tiny per-instance attribute payload.


Architecture Comparison

Optimisation StageDraw Calls per FrameCPU OverheadMemory FootprintDynamic Editing Support
Naive (Individual Sprites)Very High (1 per tile)CatastrophicLowHigh (but unusable)
Merged Meshes (Pre-baked VBO)Low (1 per layer)LowHigh (lots of vertices)Poor (entire buffer rebuild)
Chunked BatchesModerate (1 per visible chunk)LowBalancedModerate (local rebuilds)
Instanced Chunks (Modern Standard)Lowest (1 call per chunk/layer)MinimalExtremely LowHigh (modify instance data)

The Verdict for Web Developers

Achieving console-like rendering fidelity inside a Chrome or Safari tab comes down to respecting the pipeline.

By grouping your sprites into a coherent texture atlas, dicing your world into digestible chunks, and rendering those chunks using instanced geometry arrays, you drop your draw call count from thousands down to double digits. Your CPU stays relaxed, your browser game runs at a crisp 60 FPS, and players can enjoy your sprawling retro worlds without wondering why their laptop is warming up the room.

Thanks for reading. Browse more from the Wobblox blog, or jump straight into all 100 free games.