DEV Community

Cover image for Generating Texture Atlases for Optimized Assets
bandinopla
bandinopla

Posted on

Generating Texture Atlases for Optimized Assets

When working with real-time graphics, reducing texture swaps is a huge performance win. One way to achieve this is through texture atlases: combining many small textures into a single larger one. Instead of binding dozens of individual textures during rendering, the GPU only needs to work with one atlas, with UVs mapped to the correct tile.

This is something I did when developing my ThreeJs browser game PIP:Skull Demo and will use it as example to showcase this technique.

Pre-Build process

When developing we can work with non optimized assets and allow each model to have it’s own texture. But once we are ready for production, we may want to optimize. This involves a pre-build step ( a script you will run before running your actual build script ) that will read the original files and write new optimized ones (you may chose to replace the original glb files with the new one or just tell your build script to read the optimized ones and ignore the development ones). For this article we will focus on GLB files, a very flexible format. We’ll assume all your game/application assets are in glb format.

1 image atlas with 2 textures ( Boss + Weapon )

Reading and writing new optimized glb files

For this, we will install the following package that will provide us with many useful tools for optimization:

npm install --save @gltf-transform/core @gltf-transform/extensions @gltf-transform/functions sharp
Enter fullscreen mode Exit fullscreen mode

gltf-transform is a powerful toolkit for processing and optimizing glTF files, making it ideal for a pre-build pipeline. Instead of manually tweaking assets, you can script transformations like texture compression, mesh simplification, deduplication, and pruning unused data. By integrating it into a pre-build step, the original glTF remains untouched while the script generates a new, optimized version ready for runtime. This ensures a clean workflow where source assets stay editable, and the final exported models are lightweight, faster to load, and tailored for the target platform.

I wrote a small pre-build script to automate this process (and you will have to write one for your own project too, each project is different and needs different operations) and a createAtlas function to allow me to create texture atlases for different groups of textures ( This depends on your personal organization choice and it is arbitrary ) .

The function takes a list of textures, resizes them to a defined tile size, and packs them into a single atlas according to a grid layout. Using sharp, it composites everything efficiently and can also save the result as a .png for inspection. Alongside the atlas image, it returns metadata about tile positions, making it easy to remap assets later in shaders or materials.

/**
 * Utility class to create texture atlases. You can use this to tell it to 
 * pack a bunch of textures into a single atlas.
 * 
 * @param {string} atlasName 
 * @param {Texture[]} textures 
 * @param {[number, number]} atlasSize Numbers representing how many tileSize on the width and height. Examlple: 2 = 2 times the tileSize in width.
 * @param {[number, number]} tileSize Each tile in the atlas will have this size.
 * @param {{ name:string, top:number, left:number }[]} tiles 
 * @param {boolean} savePngToDisk
 */
async function createAtlas( atlasName, textures, atlasSize, tileSize, tiles, savePngToDisk )
{ 
    const atlasTextures = tiles.map( tile=>textures.find(t=>t.getName()==tile.name )  );

    const resizedImages = await Promise.all( 

        //filer only the textures we are going to pack 
        atlasTextures

        .map(async tex => await sharp(Buffer.from(tex.getImage()))
                .resize({ width: tileSize[0], height: tileSize[1], fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
                .toFormat('png') //<-- you may change this to webp...
                .toBuffer()
            )
    );

    // Create atlas
    const atlas = await sharp({
        create: { 
            width: atlasSize[0]*tileSize[0], 
            height: atlasSize[1]*tileSize[1], 
            channels: 4, 
            background: { r: 0, g: 0, b: 0, alpha: 0 } 
        }
    }).composite(
        resizedImages.map((img, index) => ({
            input: img,
            left: tiles[index].left * tileSize[0],
            top: tiles[index].top * tileSize[1]
        }))
    ).png().toBuffer();

    if( savePngToDisk ) // for debugging. It will be packed in the GLB otherwise
        await fs.writeFile(`./${atlasName}.png`, atlas); 

    return {
        imageData: atlas,
        tiles,
        atlasSize,
        removeUnusedTextures() {
            atlasTextures.forEach( textureToRemove => { 

                textureToRemove.detach();       // Unlink from graph
                textureToRemove.setImage(null); // Clear binary data
                textureToRemove.dispose();      // Mark for removal

            });
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

A nice extra is the removeUnusedTextures() helper. Once packed, the source textures can be safely detached ( and should ) and disposed of, keeping memory usage clean and ensuring only the optimized atlas is used in runtime ( so the new glb file doesn’t hold the old textures in it )

+ Add the atlas to the document

This is how we now insert the atlas texture into the GLB file:

const targetTextures = myDoc.getRoot().listTextures().filter(...);
const myAtlas = createAtlas(...);
// This adds the atlas image into the glb
const myAtlasTexture = myDocument.createTexture('MyAtlas').setImage(myAtlas.imageData).setMimeType('image/png');
Enter fullscreen mode Exit fullscreen mode

By running this step during the build process, I can ship fewer files, smaller GPU state changes, and more predictable asset management — all of which translate into smoother rendering and faster load times.

But what about the UVs? Doesn’t that break the models?

Yes. Bye. The article is done...

boss and drill both have their own textures while developing

Joking, we will update the UVs obviously! And this can be done automatically too! Check this out: The above’s utility function was used several times to pack different textures into one ( this is arbitrary, depends on your use case, you may want to group them by level, zone, etc.. it’s on you). So we have now groups of textures packed into single images. The next thing we must do is scan the glb file looking for any material that may be using a texture that we have packed, and, if so, we should update the material and UVs to point to the atlas. This is how that is done…

Scanning for materials

// search for all the materials in the glb file...
myAssetDocument.getRoot().listMaterials().forEach((material) => {
    //... code here
})
Enter fullscreen mode Exit fullscreen mode

With a reference to the document of our glb file, we will loop each of it’s materials and check if that material exists in our atlas, like so:

// in this example we use the color texture, but you may want to scan for 
// the material.getMetallicRoughnessTexture() and/or 
// the material.getNormalTexture()
const colorTexture = material.getBaseColorTexture()
const name = colorTexture.getName();
const atlasUvTile = atlas.tiles.find( tile=>tile.name==name);
if( atlasUvTile ) {
  // This material must be updated!!
  material.setBaseColorTexture( myAtlasTexture )
  // and here you can call a function to scan the doc looking for   meshes using this material...
  // (*)
}
Enter fullscreen mode Exit fullscreen mode

In the above’s code atlas is the object returned by our createAtlas utility function. If any of the tiles in that atlas has the same name of the current texture being scanned, then that’s our sign to start doing the replacement!

Updating the UVs

When multiple textures are combined into a single atlas, the geometry using those textures must also be updated. Each mesh originally referenced its own texture coordinates (UVs) aligned to an individual image ( 0 to 1 related to the single image they were using, now 0 to 1 would refer to the total width and height of the atlas containing a bunch of textures in it). After packing textures into a single large atlas, the UVs need to be scaled and offset so that they now correctly point to their assigned tile within the atlas.

We have the atlasUvTile which holds the necessary information to do the correct adjustments. Now we have to find all the meshes that are using this texture, like so, scanning our document for meshes:

// (*)

const w = atlas.atlasSize[0];
const h = atlas.atlasSize[1];
const tx = atlasUvTile.left;
const ty = atlasUvTile.top;

myAssetDocument.getRoot().listMeshes().forEach(mesh => {
    mesh.listPrimitives().forEach(prim => {
        if (prim.getMaterial() === material) 
        {
            const uvs = prim.getAttribute('TEXCOORD_0');
            if (uvs) 
            {
                const accessor = uvs.getArray();
                for (let i = 0; i < accessor.length; i += 2) 
                {
                    accessor[i] = accessor[i] / w + tx / w; // scale then offset...
                    accessor[i + 1] = accessor[i+1] / h + ty / h;
                }
                uvs.setArray(accessor);
            }
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

Once a material is confirmed to use the atlas, the code finds every mesh and primitive using that material. For each primitive, it accesses the TEXCOORD_0 attribute (the UV data). The UVs are then modified in place by applying a scale and offset: scale shrinks the UVs to fit the size of a single tile inside the larger atlas and offset shifts them to the correct position (tx, ty) inside the grid layout of the atlas.

In practice, this step ensures that every mesh in the optimized glTF correctly maps its surfaces to the right part of the atlas image. Without it, textures would appear scrambled because the original UVs would no longer match the combined atlas layout.

The result is an optimized glTF where multiple textures are merged into fewer images, reducing texture bindings at runtime while preserving the original visual fidelity of the model. This is an essential optimization step for real-time rendering, especially in games and web applications.

After all that you may want to run this to remove the now unused textures from the new final optimized glb:

myAtlas.removeUnusedTextures() 
Enter fullscreen mode Exit fullscreen mode

Caveat: Non-Tiled Textures Only

This UV remapping approach works correctly only for non-tiled textures. Since the UVs are scaled down and offset to fit within a single cell of the atlas, any material that relies on texture tiling or repeating patterns will break — the repetition will be clipped to the bounds of the assigned tile. For assets that depend on seamless tiling (like bricks, floors, or fabrics), this method isn’t suitable without additional handling. In those cases, it’s often better to keep the textures separate or explore more advanced atlas techniques.

That’s it!

By integrating texture atlases into your pre-build process, you not only streamline asset management but also significantly reduce the workload on the client’s computer. Fewer texture bindings mean less GPU overhead, faster load times, and smoother rendering. In the end, this optimization helps you deliver leaner, more efficient applications that provide a better experience for your users.

Top comments (0)