Example for how to pack an atlas with tools

Implements an example Atlas packer (ideally run offline/in a pre-runtime step using the runtime API). Imagine a game engine like Defold using this at edit time/from within engine tooling to pack all the images necessary for all the Rive files in one level into a single Atlas/set of Atlases to minimize state changes when drawing the whole scene.

Example implementation is mostly here: packages/runtime/viewer/src/sample_tools

The example does both atlas packing and runtime image resolving from the Atlas from the same set of utility classes and their corresponding objects in order to keep the example simple (no serialization of the atlas metadata or pixels). It also uses a very naive packing technique, but it illustrates these main features:

1. find all the image assets in a file
2. packing them sequentially into an atlas
3. ...while building up metadata of where they are in the atlas
4. strips the images from the file
5. shows how to then re-load the file (with no in-band images) and use the generated atlas
6. implements a FileAssetResolver to resolve the images in the Atlas and provide UV transforms

Steps 1-4 are what an engine would do at edit/export time. Steps 5-6 show what a runtime would do to then use the files generated in steps 1-4.

All of this is implemented in the viewer, minor changes were necessary to the runtime to transform UVs with a transform provided by the RenderImage and fix some bugs. See how the viewer wires this all up here: https://github.com/rive-app/rive/blob/86dbad80c66d9963701d229f637fe5bdc8173090/packages/runtime/viewer/src/viewer_content/scene_content.cpp#L185-L233

Diffs=
38b70a98d Cleanup
ac44c1ad7 Fix broken test.
40d2fa966 Fixing UV modification at load time.
736bcf133 Adding an example atlas packer.
e42e484eb Starting to add example atlas packer.
438f98716 Adding imgui menu bar
diff --git a/.rive_head b/.rive_head
index 5895a7e..54ed701 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-986b4967488b06539f41dad2cb1e7711eb51ab08
+38b70a98dd197e4bd59e2fca22f3d9e5b26f2dfb
diff --git a/build/premake5.lua b/build/premake5.lua
index 65b58b1..685ba8e 100644
--- a/build/premake5.lua
+++ b/build/premake5.lua
@@ -1,6 +1,8 @@
 workspace "rive"
     configurations {"debug", "release"}
-
+    filter {"options:with_rive_tools" }
+        defines {"WITH_RIVE_TOOLS"}
+        
 WINDOWS_CLANG_CL_SUPPRESSED_WARNINGS = {
     "-Wno-c++98-compat",
     "-Wno-c++98-compat-pedantic",
@@ -103,8 +105,6 @@
         defines {"NDEBUG"}
         optimize "On"
 
-    filter {"options:with_rive_tools" }
-        defines {"WITH_RIVE_TOOLS"}
 
 newoption {
     trigger = "variant",
diff --git a/include/rive/assets/image_asset.hpp b/include/rive/assets/image_asset.hpp
index f876a6e..0083e79 100644
--- a/include/rive/assets/image_asset.hpp
+++ b/include/rive/assets/image_asset.hpp
@@ -20,6 +20,7 @@
     bool decode(Span<const uint8_t>, Factory*) override;
     std::string fileExtension() override;
     RenderImage* renderImage() const { return m_RenderImage.get(); }
+    void renderImage(std::unique_ptr<RenderImage> renderImage);
 };
 } // namespace rive
 
diff --git a/include/rive/file.hpp b/include/rive/file.hpp
index c027fc3..60ed1e6 100644
--- a/include/rive/file.hpp
+++ b/include/rive/file.hpp
@@ -80,6 +80,8 @@
     size_t artboardCount() const { return m_Artboards.size(); }
     std::string artboardNameAt(size_t index) const;
 
+    std::vector<const FileAsset*> assets() const;
+
     // Instances
     std::unique_ptr<ArtboardInstance> artboardDefault() const;
     std::unique_ptr<ArtboardInstance> artboardAt(size_t index) const;
diff --git a/include/rive/importers/import_stack.hpp b/include/rive/importers/import_stack.hpp
index 6a40d4e..26f5b59 100644
--- a/include/rive/importers/import_stack.hpp
+++ b/include/rive/importers/import_stack.hpp
@@ -59,12 +59,15 @@
     }
 
     StatusCode resolve() {
-        for (auto& pair : m_Latests) {
-            StatusCode code = pair.second->resolve();
+        for (auto itr = m_LastAdded.rbegin(); itr != m_LastAdded.rend(); itr++) {
+            StatusCode code = (*itr)->resolve();
+            delete *itr;
             if (code != StatusCode::Ok) {
                 return code;
             }
         }
+        m_Latests.clear();
+        m_LastAdded.clear();
         return StatusCode::Ok;
     }
 
diff --git a/include/rive/renderer.hpp b/include/rive/renderer.hpp
index b660119..75b3a1b 100644
--- a/include/rive/renderer.hpp
+++ b/include/rive/renderer.hpp
@@ -72,13 +72,16 @@
 protected:
     int m_Width = 0;
     int m_Height = 0;
+    Mat2D m_uvTransform;
 
 public:
     RenderImage();
+    RenderImage(const Mat2D& uvTransform);
     virtual ~RenderImage();
 
     int width() const { return m_Width; }
     int height() const { return m_Height; }
+    const Mat2D& uvTransform() const { return m_uvTransform; }
 };
 
 class RenderPath : public CommandPath {
diff --git a/include/rive/shapes/mesh.hpp b/include/rive/shapes/mesh.hpp
index 3c193aa..473bce7 100644
--- a/include/rive/shapes/mesh.hpp
+++ b/include/rive/shapes/mesh.hpp
@@ -33,6 +33,11 @@
 
     void updateVertexRenderBuffer(Renderer* renderer);
     void markSkinDirty() override;
+    Core* clone() const override;
+
+    /// Initialize the any buffers that will be shared amongst instances (the
+    /// instance are guaranteed to use the same RenderImage).
+    void initializeSharedBuffers(RenderImage* renderImage);
 
 #ifdef TESTING
     std::vector<MeshVertex*>& vertices() { return m_Vertices; }
diff --git a/src/assets/image_asset.cpp b/src/assets/image_asset.cpp
index 7fe6304..1d07ef3 100644
--- a/src/assets/image_asset.cpp
+++ b/src/assets/image_asset.cpp
@@ -14,4 +14,8 @@
     return m_RenderImage != nullptr;
 }
 
+void ImageAsset::renderImage(std::unique_ptr<RenderImage> renderImage) {
+    m_RenderImage = std::move(renderImage);
+}
+
 std::string ImageAsset::fileExtension() { return "png"; }
diff --git a/src/file.cpp b/src/file.cpp
index a010120..41e4004 100644
--- a/src/file.cpp
+++ b/src/file.cpp
@@ -290,6 +290,14 @@
     return ab ? ab->instance() : nullptr;
 }
 
+std::vector<const FileAsset*> File::assets() const {
+    std::vector<const FileAsset*> assets;
+    for (auto itr = m_FileAssets.begin(); itr != m_FileAssets.end(); itr++) {
+        assets.push_back(itr->get());
+    }
+    return assets;
+}
+
 #ifdef WITH_RIVE_TOOLS
 const std::vector<uint8_t> File::stripAssets(Span<const uint8_t> bytes,
                                              std::set<uint16_t> typeKeys,
diff --git a/src/importers/backboard_importer.cpp b/src/importers/backboard_importer.cpp
index 35efecd..46b3303 100644
--- a/src/importers/backboard_importer.cpp
+++ b/src/importers/backboard_importer.cpp
@@ -2,6 +2,8 @@
 #include "rive/importers/backboard_importer.hpp"
 #include "rive/nested_artboard.hpp"
 #include "rive/assets/file_asset_referencer.hpp"
+#include "rive/assets/file_asset.hpp"
+#include <unordered_set>
 
 using namespace rive;
 
@@ -11,7 +13,29 @@
     m_NestedArtboards.push_back(artboard);
 }
 
-void BackboardImporter::addFileAsset(FileAsset* asset) { m_FileAssets.push_back(asset); }
+void BackboardImporter::addFileAsset(FileAsset* asset) {
+    m_FileAssets.push_back(asset);
+    {
+        // EDITOR BUG 4204
+        // --------------
+        // Ensure assetIds are unique. Due to an editor bug:
+        // https://github.com/rive-app/rive/issues/4204
+        std::unordered_set<uint32_t> ids;
+        uint32_t nextId = 1;
+        for (auto asset : m_FileAssets) {
+            if (ids.count(asset->assetId())) {
+                asset->assetId(nextId);
+            } else {
+                ids.insert(asset->assetId());
+                if (asset->assetId() >= nextId) {
+                    nextId = asset->assetId() + 1;
+                }
+            }
+        }
+
+        // --------------
+    }
+}
 
 void BackboardImporter::addFileAssetReferencer(FileAssetReferencer* referencer) {
     m_FileAssetReferencers.push_back(referencer);
@@ -24,6 +48,7 @@
 void BackboardImporter::addMissingArtboard() { m_NextArtboardId++; }
 
 StatusCode BackboardImporter::resolve() {
+
     for (auto nestedArtboard : m_NestedArtboards) {
         auto itr = m_ArtboardLookup.find(nestedArtboard->artboardId());
         if (itr != m_ArtboardLookup.end()) {
@@ -33,9 +58,9 @@
             }
         }
     }
-
     for (auto referencer : m_FileAssetReferencers) {
         referencer->assets(m_FileAssets);
     }
+
     return StatusCode::Ok;
 }
diff --git a/src/renderer.cpp b/src/renderer.cpp
index 165f1e0..45fb4df 100644
--- a/src/renderer.cpp
+++ b/src/renderer.cpp
@@ -79,6 +79,9 @@
 RenderPaint::RenderPaint() { Counter::update(Counter::kPaint, 1); }
 RenderPaint::~RenderPaint() { Counter::update(Counter::kPaint, -1); }
 
+RenderImage::RenderImage(const Mat2D& uvTransform) : m_uvTransform(uvTransform) {
+    Counter::update(Counter::kImage, 1);
+}
 RenderImage::RenderImage() { Counter::update(Counter::kImage, 1); }
 RenderImage::~RenderImage() { Counter::update(Counter::kImage, -1); }
 
diff --git a/src/shapes/image.cpp b/src/shapes/image.cpp
index 45af115..04d90a5 100644
--- a/src/shapes/image.cpp
+++ b/src/shapes/image.cpp
@@ -5,6 +5,7 @@
 #include "rive/assets/file_asset.hpp"
 #include "rive/assets/image_asset.hpp"
 #include "rive/shapes/mesh.hpp"
+#include "rive/artboard.hpp"
 
 using namespace rive;
 
@@ -74,6 +75,12 @@
     auto asset = assets[assetId()];
     if (asset->is<ImageAsset>()) {
         m_ImageAsset = asset->as<ImageAsset>();
+
+        // If we have a mesh and we're in the source artboard, let's initialize
+        // the mesh buffers.
+        if (m_Mesh != nullptr && !artboard()->isInstance()) {
+            m_Mesh->initializeSharedBuffers(m_ImageAsset->renderImage());
+        }
     }
 }
 
diff --git a/src/shapes/mesh.cpp b/src/shapes/mesh.cpp
index fd2811e..5c843d9 100644
--- a/src/shapes/mesh.cpp
+++ b/src/shapes/mesh.cpp
@@ -6,6 +6,7 @@
 #include "rive/artboard.hpp"
 #include "rive/factory.hpp"
 #include "rive/span.hpp"
+#include "rive/assets/image_asset.hpp"
 #include <limits>
 
 using namespace rive;
@@ -15,6 +16,7 @@
     if (skin() != nullptr) {
         skin()->addDirt(ComponentDirt::Skin);
     }
+
     addDirt(ComponentDirt::Vertices);
 }
 
@@ -70,30 +72,36 @@
 /// Called whenever a bone moves that is connected to the skin.
 void Mesh::markSkinDirty() { addDirt(ComponentDirt::Vertices); }
 
+Core* Mesh::clone() const {
+    auto clone = static_cast<Mesh*>(MeshBase::clone());
+    clone->m_UVRenderBuffer = m_UVRenderBuffer;
+    clone->m_IndexRenderBuffer = m_IndexRenderBuffer;
+    return clone;
+}
+
+void Mesh::initializeSharedBuffers(RenderImage* renderImage) {
+    Mat2D uvTransform = renderImage != nullptr ? renderImage->uvTransform() : Mat2D();
+
+    std::vector<float> uv = std::vector<float>(m_Vertices.size() * 2);
+    std::size_t index = 0;
+
+    for (auto vertex : m_Vertices) {
+        Vec2D xformedUV = uvTransform * Vec2D(vertex->u(), vertex->v());
+        uv[index++] = xformedUV.x;
+        uv[index++] = xformedUV.y;
+    }
+
+    auto factory = artboard()->factory();
+    m_UVRenderBuffer = factory->makeBufferF32(uv);
+    m_IndexRenderBuffer = factory->makeBufferU16(*m_IndexBuffer);
+}
+
 void Mesh::buildDependencies() {
     Super::buildDependencies();
     if (skin() != nullptr) {
         skin()->addDependent(this);
     }
     parent()->addDependent(this);
-
-    // TODO: This logic really needs to move to a "intializeGraphics/Renderer"
-    // method that is passed a reference to the Renderer.
-
-    // TODO: if this is an instance, share the index and uv buffer from the
-    // source. Consider storing an m_SourceMesh.
-
-    std::vector<float> uv = std::vector<float>(m_Vertices.size() * 2);
-    std::size_t index = 0;
-
-    for (auto vertex : m_Vertices) {
-        uv[index++] = vertex->u();
-        uv[index++] = vertex->v();
-    }
-
-    auto factory = artboard()->factory();
-    m_UVRenderBuffer = factory->makeBufferF32(uv);
-    m_IndexRenderBuffer = factory->makeBufferU16(*m_IndexBuffer);
 }
 
 void Mesh::update(ComponentDirt value) {
diff --git a/tess/include/rive/tess/sokol/sokol_tess_renderer.hpp b/tess/include/rive/tess/sokol/sokol_tess_renderer.hpp
index a8c9568..9ee0a73 100644
--- a/tess/include/rive/tess/sokol/sokol_tess_renderer.hpp
+++ b/tess/include/rive/tess/sokol/sokol_tess_renderer.hpp
@@ -10,18 +10,41 @@
 
 namespace rive {
 
-class SokolRenderImage : public RenderImage {
+// The actual graphics device image.
+class SokolRenderImageResource : public RefCnt {
 private:
-    sg_image m_image;
-    sg_buffer m_vertexBuffer;
+    sg_image m_gpuResource;
 
 public:
     // bytes is expected to be tightly packed RGBA*width*height.
-    SokolRenderImage(const uint8_t* bytes, uint32_t width, uint32_t height);
+    SokolRenderImageResource(const uint8_t* bytes, uint32_t width, uint32_t height);
+    ~SokolRenderImageResource();
+
+    sg_image image() const { return m_gpuResource; }
+};
+
+// The unique render image associated with a given source Rive asset. Can be stored in sub-region of
+// an actual graphics device image (SokolRenderImageResource).
+class SokolRenderImage : public RenderImage {
+private:
+    rcp<SokolRenderImageResource> m_gpuImage;
+    sg_buffer m_vertexBuffer;
+    sg_buffer m_uvBuffer;
+
+public:
+    // Needed by std::unique_ptr
+    // SokolRenderImage() {}
+
+    SokolRenderImage(rcp<SokolRenderImageResource> image,
+                     uint32_t width,
+                     uint32_t height,
+                     const Mat2D& uvTransform);
+
     ~SokolRenderImage() override;
 
-    sg_image image() const { return m_image; }
+    sg_image image() const { return m_gpuImage->image(); }
     sg_buffer vertexBuffer() const { return m_vertexBuffer; }
+    sg_buffer uvBuffer() const { return m_uvBuffer; }
 };
 
 class SokolTessRenderer : public TessRenderer {
@@ -46,7 +69,6 @@
     sg_pipeline m_incClipPipeline;
     sg_pipeline m_decClipPipeline;
     sg_buffer m_boundsIndices;
-    sg_buffer m_defaultUV;
 
     std::vector<SubPath> m_ClipPaths;
 
diff --git a/tess/src/sokol/sokol_tess_renderer.cpp b/tess/src/sokol/sokol_tess_renderer.cpp
index 59a9c36..e968d89 100644
--- a/tess/src/sokol/sokol_tess_renderer.cpp
+++ b/tess/src/sokol/sokol_tess_renderer.cpp
@@ -467,24 +467,9 @@
         .type = SG_BUFFERTYPE_INDEXBUFFER,
         .data = SG_RANGE(indices),
     });
-
-    // The UV buffer used by drawImage. Consider this the "default uv
-    // vertex buffer" used when a mesh isn't provided by Rive.
-    Vec2D defUV[] = {
-        Vec2D(0.0f, 0.0f),
-        Vec2D(1.0f, 0.0f),
-        Vec2D(1.0f, 1.0f),
-        Vec2D(0.0f, 1.0f),
-    };
-
-    m_defaultUV = sg_make_buffer((sg_buffer_desc){
-        .type = SG_BUFFERTYPE_VERTEXBUFFER,
-        .data = SG_RANGE(defUV),
-    });
 }
 
 SokolTessRenderer::~SokolTessRenderer() {
-    sg_destroy_buffer(m_defaultUV);
     sg_destroy_buffer(m_boundsIndices);
     sg_destroy_pipeline(m_meshPipeline);
     sg_destroy_pipeline(m_incClipPipeline);
@@ -554,7 +539,7 @@
     setPipeline(m_meshPipeline);
     sg_bindings bind = {
         .vertex_buffers[0] = sokolImage->vertexBuffer(),
-        .vertex_buffers[1] = m_defaultUV,
+        .vertex_buffers[1] = sokolImage->uvBuffer(),
         .index_buffer = m_boundsIndices,
         .fs_images[SLOT_tex] = sokolImage->image(),
     };
@@ -955,13 +940,24 @@
     static_cast<SokolRenderPaint*>(paint)->draw(vs_params, static_cast<SokolRenderPath*>(path));
 }
 
-SokolRenderImage::SokolRenderImage(const uint8_t* bytes, uint32_t width, uint32_t height) :
-    m_image(sg_make_image((sg_image_desc){
+SokolRenderImageResource::SokolRenderImageResource(const uint8_t* bytes,
+                                                   uint32_t width,
+                                                   uint32_t height) :
+    m_gpuResource(sg_make_image((sg_image_desc){
         .width = (int)width,
         .height = (int)height,
         .data.subimage[0][0] = {bytes, width * height * 4},
         .pixel_format = SG_PIXELFORMAT_RGBA8,
-    }))
+    })) {}
+SokolRenderImageResource::~SokolRenderImageResource() { sg_destroy_image(m_gpuResource); }
+
+SokolRenderImage::SokolRenderImage(rcp<SokolRenderImageResource> image,
+                                   uint32_t width,
+                                   uint32_t height,
+                                   const Mat2D& uvTransform) :
+
+    RenderImage(uvTransform),
+    m_gpuImage(image)
 
 {
     float halfWidth = width / 2.0f;
@@ -976,9 +972,21 @@
         .type = SG_BUFFERTYPE_VERTEXBUFFER,
         .data = SG_RANGE(points),
     });
+
+    Vec2D uv[] = {
+        uvTransform * Vec2D(0.0f, 0.0f),
+        uvTransform * Vec2D(1.0f, 0.0f),
+        uvTransform * Vec2D(1.0f, 1.0f),
+        uvTransform * Vec2D(0.0f, 1.0f),
+    };
+
+    m_uvBuffer = sg_make_buffer((sg_buffer_desc){
+        .type = SG_BUFFERTYPE_VERTEXBUFFER,
+        .data = SG_RANGE(uv),
+    });
 }
 
 SokolRenderImage::~SokolRenderImage() {
     sg_destroy_buffer(m_vertexBuffer);
-    sg_destroy_image(m_image);
+    sg_destroy_buffer(m_uvBuffer);
 }
\ No newline at end of file
diff --git a/utils/no_op_factory.cpp b/utils/no_op_factory.cpp
index 5e8079f..ab6ebbd 100644
--- a/utils/no_op_factory.cpp
+++ b/utils/no_op_factory.cpp
@@ -73,5 +73,5 @@
 }
 
 std::unique_ptr<RenderImage> NoOpFactory::decodeImage(Span<const uint8_t>) {
-    return std::make_unique<NoOpRenderImage>();
+    return std::unique_ptr<NoOpRenderImage>();
 }
diff --git a/viewer/build/macosx/build_viewer.sh b/viewer/build/macosx/build_viewer.sh
index 6f1a360..3876b04 100755
--- a/viewer/build/macosx/build_viewer.sh
+++ b/viewer/build/macosx/build_viewer.sh
@@ -62,7 +62,7 @@
 
 pushd ..
 
-$PREMAKE --file=./premake5_viewer.lua gmake2 --graphics=$GRAPHICS --renderer=$RENDERER
+$PREMAKE --file=./premake5_viewer.lua gmake2 --graphics=$GRAPHICS --renderer=$RENDERER --with_rive_tools
 
 for var in "$@"; do
     if [[ $var = "clean" ]]; then
diff --git a/viewer/build/premake5_viewer.lua b/viewer/build/premake5_viewer.lua
index aedf2e2..e048076 100644
--- a/viewer/build/premake5_viewer.lua
+++ b/viewer/build/premake5_viewer.lua
@@ -16,6 +16,8 @@
 if _OPTIONS.renderer == 'tess' then
     dofile('premake5_libpng.lua')
     dofile(path.join(path.getabsolute(rive_tess) .. '/build', 'premake5_tess.lua'))
+else
+    dofile(path.join(path.getabsolute(rive) .. '/build', 'premake5.lua'))
 end
 
 project 'rive_viewer'
@@ -30,12 +32,12 @@
     targetdir('%{cfg.system}/bin/%{cfg.buildcfg}/' .. _OPTIONS.renderer .. '/' .. _OPTIONS.graphics)
     objdir('%{cfg.system}/obj/%{cfg.buildcfg}/' .. _OPTIONS.renderer .. '/' .. _OPTIONS.graphics)
 
-    defines { "RIVE_TEXT" }
+    defines {'RIVE_TEXT'}
 
     includedirs {
         '../include',
         rive .. '/include',
-        rive .. '/skia/renderer/include',  -- for renderfont backends
+        rive .. '/skia/renderer/include', -- for renderfont backends
         rive_thirdparty .. '/externals/harfbuzz/src',
         dependencies,
         dependencies .. '/sokol',
@@ -44,20 +46,19 @@
 
     links {
         'rive',
-        'rive_harfbuzz',
+        'rive_harfbuzz'
     }
 
     libdirs {
         rive .. '/build/%{cfg.system}/bin/%{cfg.buildcfg}',
-        rive_thirdparty .. '/harfbuzz/build/%{cfg.buildcfg}/bin',
+        rive_thirdparty .. '/harfbuzz/build/%{cfg.buildcfg}/bin'
     }
 
     files {
         '../src/**.cpp',
-        '../../utils/rive_utf.cpp',
+        rive .. '/utils/**.cpp',
         rive .. '/skia/renderer/src/renderfont_coretext.cpp',
         rive .. '/skia/renderer/src/renderfont_hb.cpp',
-        rive .. '/utils/rive_utf.cpp',
         dependencies .. '/imgui/imgui.cpp',
         dependencies .. '/imgui/imgui_widgets.cpp',
         dependencies .. '/imgui/imgui_tables.cpp',
@@ -213,7 +214,7 @@
             skia .. '/include/core',
             skia .. '/include/effects',
             skia .. '/include/gpu',
-            skia .. '/include/config',
+            skia .. '/include/config'
         }
         defines {
             'RIVE_RENDERER_SKIA'
@@ -224,7 +225,7 @@
         }
         links {
             'skia',
-            'rive_skia_renderer',
+            'rive_skia_renderer'
         }
     end
 
diff --git a/viewer/include/viewer/sample_tools/sample_atlas_packer.hpp b/viewer/include/viewer/sample_tools/sample_atlas_packer.hpp
new file mode 100644
index 0000000..c131e3f
--- /dev/null
+++ b/viewer/include/viewer/sample_tools/sample_atlas_packer.hpp
@@ -0,0 +1,74 @@
+#ifndef _RIVE_SAMPLE_ATLAS_PACKER_HPP_
+#define _RIVE_SAMPLE_ATLAS_PACKER_HPP_
+
+#include "rive/span.hpp"
+#include "rive/file_asset_resolver.hpp"
+#include "rive/math/mat2d.hpp"
+#include "rive/renderer.hpp"
+#include "rive/tess/sokol/sokol_tess_renderer.hpp"
+#include <vector>
+#include <unordered_map>
+
+namespace rive {
+class ImageAsset;
+class SokolRenderImageResource;
+/// A single atlas image generated by the packer.
+class SampleAtlas {
+private:
+    uint32_t m_width;
+    uint32_t m_height;
+    std::vector<uint8_t> m_pixels;
+
+    uint32_t m_x = 0;
+    uint32_t m_y = 0;
+    uint32_t m_nextY = 0;
+
+public:
+    SampleAtlas(const uint8_t* pixels, uint32_t width, uint32_t height);
+    SampleAtlas(uint32_t width, uint32_t height);
+    bool pack(const uint8_t* pixels, uint32_t width, uint32_t height, Mat2D& packTransform);
+
+    uint32_t width() const { return m_width; }
+    uint32_t height() const { return m_height; }
+    Span<const uint8_t> pixels() const { return m_pixels; }
+};
+
+struct SampleAtlasLocation {
+    std::size_t atlasIndex;
+    Mat2D transform;
+    // Original width & height of the image, so we can make vertex buffers for the "default renderer
+    // drawImage"
+    uint32_t width;
+    uint32_t height;
+};
+
+/// An Atlas packer which will create multiple atlas images (SampleAtles) as
+/// necessary.
+class SampleAtlasPacker {
+private:
+    uint32_t m_maxWidth;
+    uint32_t m_maxHeight;
+    std::vector<SampleAtlas*> m_atlases;
+    std::unordered_map<uint32_t, SampleAtlasLocation> m_lookup;
+
+public:
+    SampleAtlasPacker(uint32_t maxWidth, uint32_t maxHeight);
+    ~SampleAtlasPacker();
+    // Pack the images found in the file represented by rivBytes.
+    void pack(Span<const uint8_t> rivBytes);
+
+    bool find(const ImageAsset& asset, SampleAtlasLocation* location);
+    SampleAtlas* atlas(std::size_t index);
+};
+
+class SampleAtlasResolver : public FileAssetResolver {
+private:
+    SampleAtlasPacker* m_packer;
+    std::unordered_map<uint32_t, rive::rcp<rive::SokolRenderImageResource>> m_sharedImageResources;
+
+public:
+    SampleAtlasResolver(SampleAtlasPacker* packer);
+    void loadContents(FileAsset& asset) override;
+};
+} // namespace rive
+#endif
\ No newline at end of file
diff --git a/viewer/src/sample_tools/sample_atlas_packer.cpp b/viewer/src/sample_tools/sample_atlas_packer.cpp
new file mode 100644
index 0000000..cc06ffd
--- /dev/null
+++ b/viewer/src/sample_tools/sample_atlas_packer.cpp
@@ -0,0 +1,212 @@
+#ifdef RIVE_RENDERER_TESS
+#include "viewer/sample_tools/sample_atlas_packer.hpp"
+#include "utils/no_op_factory.hpp"
+#include "viewer/tess/bitmap_decoder.hpp"
+#include "rive/file.hpp"
+#include "rive/assets/image_asset.hpp"
+#include "rive/tess/sokol/sokol_tess_renderer.hpp"
+#include <algorithm>
+
+using namespace rive;
+
+class AtlasRenderImage : public RenderImage {
+private:
+    std::vector<uint8_t> m_Pixels;
+
+public:
+    AtlasRenderImage(const uint8_t* pixels, uint32_t width, uint32_t height) :
+        m_Pixels(pixels, pixels + (width * height * 4)) {
+        m_Width = width;
+        m_Height = height;
+    }
+
+    Span<const uint8_t> pixels() { return m_Pixels; }
+};
+
+class AtlasPackerFactory : public NoOpFactory {
+    std::unique_ptr<RenderImage> decodeImage(Span<const uint8_t> bytes) override {
+        auto bitmap = Bitmap::decode(bytes);
+        if (bitmap) {
+            // We have a bitmap, let's make an image.
+
+            // For now only deal with RGBA.
+            if (bitmap->pixelFormat() != Bitmap::PixelFormat::RGBA) {
+                bitmap->pixelFormat(Bitmap::PixelFormat::RGBA);
+            }
+
+            return std::make_unique<AtlasRenderImage>(bitmap->bytes(),
+                                                      bitmap->width(),
+                                                      bitmap->height());
+        }
+        return nullptr;
+    }
+};
+
+SampleAtlasPacker::SampleAtlasPacker(uint32_t maxWidth, uint32_t maxHeight) :
+    m_maxWidth(maxWidth), m_maxHeight(maxHeight) {}
+
+void SampleAtlasPacker::pack(Span<const uint8_t> rivBytes) {
+    AtlasPackerFactory factory;
+    if (auto file = rive::File::import(rivBytes, &factory)) {
+        for (auto asset : file->assets()) {
+            if (asset->is<ImageAsset>()) {
+                Mat2D uvTransform;
+
+                auto imageAsset = asset->as<ImageAsset>();
+                auto renderImage = static_cast<AtlasRenderImage*>(imageAsset->renderImage());
+
+                if (m_atlases.empty()) {
+                    // Make the first atlas.
+                    m_atlases.push_back(new SampleAtlas(m_maxWidth, m_maxHeight));
+                }
+
+                // Pack into the current atlas.
+                if (!m_atlases.back()->pack(renderImage->pixels().data(),
+                                            renderImage->width(),
+                                            renderImage->height(),
+                                            uvTransform))
+                {
+                    // Didn't fit in previous atlas, make a new one.
+                    auto nextAtlas = new SampleAtlas(m_maxWidth, m_maxHeight);
+
+                    // Try to pack into the new one.
+                    if (!nextAtlas->pack(renderImage->pixels().data(),
+                                         renderImage->width(),
+                                         renderImage->height(),
+                                         uvTransform))
+                    {
+                        // Still failed. Image must be larger than max atlas. Just push the whole
+                        // image as an atlas.
+                        m_atlases.push_back(new SampleAtlas(renderImage->pixels().data(),
+                                                            renderImage->width(),
+                                                            renderImage->height()));
+
+                        // Clean up unused next atlas.
+                        delete nextAtlas;
+                    } else {
+                        m_atlases.push_back(nextAtlas);
+                    }
+                }
+
+                // Store where the image ended up.
+
+                m_lookup[asset->assetId()] = {
+                    .atlasIndex = m_atlases.size() - 1,
+                    .transform = uvTransform,
+                    .width = (uint32_t)renderImage->width(),
+                    .height = (uint32_t)renderImage->height(),
+                };
+            }
+        }
+    }
+}
+
+SampleAtlasPacker::~SampleAtlasPacker() {
+    for (auto atlas : m_atlases) {
+        delete atlas;
+    }
+}
+
+SampleAtlas* SampleAtlasPacker::atlas(std::size_t index) {
+    assert(index < m_atlases.size());
+    return m_atlases[index];
+}
+
+SampleAtlas::SampleAtlas(const uint8_t* pixels, uint32_t width, uint32_t height) :
+    m_width(width), m_height(height), m_pixels(pixels, pixels + width * height * 4) {}
+
+SampleAtlas::SampleAtlas(uint32_t width, uint32_t height) :
+    m_width(width), m_height(height), m_pixels(width * height * 4) {}
+
+bool SampleAtlas::pack(const uint8_t* sourcePixels,
+                       uint32_t width,
+                       uint32_t height,
+                       Mat2D& packTransform) {
+    if (m_x + width >= m_width) {
+        m_x = 0;
+        m_y = m_nextY;
+    }
+    // Check if we overflow vertically, we're done.
+    if (m_y + height >= m_height) {
+        return false;
+    }
+
+    // We fit, pack into m_x, m_y.
+    for (uint32_t sy = 0; sy < height; sy++) {
+        for (uint32_t sx = 0; sx < width; sx++) {
+            for (uint8_t channel = 0; channel < 4; channel++) {
+                m_pixels[((m_y + sy) * m_width + (m_x + sx)) * 4 + channel] =
+                    sourcePixels[(sy * width + sx) * 4 + channel];
+            }
+        }
+    }
+
+    // Set the UV transform.
+    packTransform = {
+        width / (float)m_width,
+        0,
+        0,
+        height / (float)m_height,
+        m_x / (float)m_width,
+        m_y / (float)m_height,
+    };
+
+    // Increment internal positions.
+    m_x += width;
+    if (m_y + height > m_nextY) {
+        m_nextY = m_y + height;
+    }
+
+    return true;
+}
+
+bool SampleAtlasPacker::find(const ImageAsset& asset, SampleAtlasLocation* location) {
+    auto assetId = asset.assetId();
+    auto result = m_lookup.find(assetId);
+    if (result != m_lookup.end()) {
+        *location = result->second;
+        return true;
+    }
+    return false;
+}
+
+SampleAtlasResolver::SampleAtlasResolver(SampleAtlasPacker* packer) : m_packer(packer) {}
+
+void SampleAtlasResolver::loadContents(FileAsset& asset) {
+    if (asset.is<ImageAsset>()) {
+        SampleAtlasLocation location;
+        auto imageAsset = asset.as<ImageAsset>();
+
+        // Find which location this image got packed into.
+        if (m_packer->find(*imageAsset, &location)) {
+            // Determine if we've already loaded the a render image.
+            auto sharedItr = m_sharedImageResources.find(location.atlasIndex);
+
+            rive::rcp<rive::SokolRenderImageResource> imageResource;
+            if (sharedItr != m_sharedImageResources.end()) {
+                imageResource = sharedItr->second;
+            } else {
+                auto atlas = m_packer->atlas(location.atlasIndex);
+                imageResource = rive::rcp<rive::SokolRenderImageResource>(
+                    new SokolRenderImageResource(atlas->pixels().data(),
+                                                 atlas->width(),
+                                                 atlas->height()));
+                m_sharedImageResources[location.atlasIndex] = imageResource;
+            }
+
+            // Make a render image from the atlas if we haven't yet. Our Factory
+            // doesn't have an abstraction for creating a RenderImage from a
+            // decoded set of pixels, we should either add that or live with/be
+            // ok with the fact that most people writing a resolver doing
+            // something like this will likely also have a custom/specific
+            // renderer (and hence will know which RenderImage they need to
+            // make).
+
+            imageAsset->renderImage(std::make_unique<SokolRenderImage>(imageResource,
+                                                                       location.width,
+                                                                       location.height,
+                                                                       location.transform));
+        }
+    }
+}
+#endif
\ No newline at end of file
diff --git a/viewer/src/tess/viewer_sokol_factory.cpp b/viewer/src/tess/viewer_sokol_factory.cpp
index cf9ac5c..f36b4aa 100644
--- a/viewer/src/tess/viewer_sokol_factory.cpp
+++ b/viewer/src/tess/viewer_sokol_factory.cpp
@@ -15,9 +15,19 @@
             bitmap->pixelFormat(Bitmap::PixelFormat::RGBA);
         }
 
-        return std::make_unique<rive::SokolRenderImage>(bitmap->bytes(),
+        // In this case the image is in-band and the imageGpuResource is only
+        // used once by the unique SokolRenderImage. We introduced this
+        // abstraction because when the image is loaded externally (say by
+        // something that built up an atlas) the image gpu resource may be
+        // shared by multiple RenderImage referenced by multiple Rive objects.
+        auto imageGpuResource = rive::rcp<rive::SokolRenderImageResource>(
+            new rive::SokolRenderImageResource(bitmap->bytes(), bitmap->width(), bitmap->height()));
+
+        static rive::Mat2D identity;
+        return std::make_unique<rive::SokolRenderImage>(imageGpuResource,
                                                         bitmap->width(),
-                                                        bitmap->height());
+                                                        bitmap->height(),
+                                                        identity);
     }
     return nullptr;
 }
diff --git a/viewer/src/viewer_content/scene_content.cpp b/viewer/src/viewer_content/scene_content.cpp
index 1234e6f..774084c 100644
--- a/viewer/src/viewer_content/scene_content.cpp
+++ b/viewer/src/viewer_content/scene_content.cpp
@@ -12,7 +12,9 @@
 #include "rive/file.hpp"
 #include "rive/layout.hpp"
 #include "rive/math/aabb.hpp"
+#include "rive/assets/image_asset.hpp"
 #include "viewer/viewer_content.hpp"
+#include "viewer/sample_tools/sample_atlas_packer.hpp"
 
 constexpr int REQUEST_DEFAULT_SCENE = -1;
 
@@ -177,6 +179,60 @@
     }
 
     void handleImgui() override {
+        // For now the atlas packer only works with tess as it compiles in our
+        // Bitmap decoder.
+#ifdef RIVE_RENDERER_TESS
+        if (ImGui::BeginMainMenuBar()) {
+            if (ImGui::BeginMenu("Tools")) {
+                if (ImGui::MenuItem("Build Atlas")) {
+                    // Create an atlas packer.
+                    rive::SampleAtlasPacker atlasPacker(2048, 2048);
+
+                    // Have it pack the riv file, note that we need to re-load
+                    // the file as the packer internally sets up a custom
+                    // factory to process the images.
+                    auto rivFileBytes = LoadFile(m_Filename.c_str());
+                    atlasPacker.pack(rivFileBytes);
+
+                    // The packer now contains the new atlas(es) and metadata
+                    // (which image is in which atlas and the transform to apply
+                    // to the UV coordiantes). This is where you'd probably
+                    // serialize these results to new images and some metadata
+                    // format containing the atlas locations.
+
+                    // But for this demo, we're just going to pass this data on
+                    // to the asset resolver used to load the file with no
+                    // in-line images.
+
+                    // On that note, let's strip the images.
+                    rive::ImportResult stripResult;
+                    auto strippedBytes = rive::File::stripAssets(rivFileBytes,
+                                                                 {rive::ImageAsset::typeKey},
+                                                                 &stripResult);
+                    if (stripResult != rive::ImportResult::success) {
+                        printf("Failed to strip images\n");
+                        return;
+                    }
+
+                    // We let the atlas packer handle loading the riv file using
+                    // the previously generated assets. Note that we're loading
+                    // our riv file with the images stripped out of it.
+                    rive::ImportResult loadAtlasedResult;
+
+                    rive::SampleAtlasResolver resolver(&atlasPacker);
+                    if (auto file = rive::File::import(strippedBytes,
+                                                       RiveFactory(),
+                                                       &loadAtlasedResult,
+                                                       &resolver)) {
+                        m_File = std::move(file);
+                        initArtboard(REQUEST_DEFAULT_SCENE);
+                    }
+                }
+                ImGui::EndMenu();
+            }
+            ImGui::EndMainMenuBar();
+        }
+#endif
         if (m_ArtboardInstance != nullptr) {
             ImGui::Begin(m_Filename.c_str(), nullptr);
             if (ImGui::ListBox(