feat: Add fallback AtlasTypes that don't need float color buffers (#10475) 5e6f683b9e
Floating point color buffers are only supported via extensions in GL.
Previously, the feather atlas would just break when this functionality
wasn't present.

This PR adds support for multiple different AtlasTypes that make use of
various GL extensions to render the atlas. As a final resort, if none of
the other extensions are available, it can split coverage up into rgba8
compoments. This mode works on unextended GL at the cost of quality.

Co-authored-by: Chris Dalton <99840794+csmartdalton@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index fcadeb3..820cba5 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-7868b2bb71cfd1321b2b0990d4245ab854d8957e
+5e6f683b9e3c3376975a009eaf1cb0af6f9c2b37
diff --git a/renderer/include/rive/renderer/gl/gles3.hpp b/renderer/include/rive/renderer/gl/gles3.hpp
index c8f21a0..d18519e 100644
--- a/renderer/include/rive/renderer/gl/gles3.hpp
+++ b/renderer/include/rive/renderer/gl/gles3.hpp
@@ -168,13 +168,15 @@
     bool ARB_fragment_shader_interlock : 1;
     bool ARB_shader_image_load_store : 1;
     bool ARB_shader_storage_buffer_object : 1;
+    bool OES_shader_image_atomic : 1;
     bool KHR_blend_equation_advanced : 1;
     bool KHR_blend_equation_advanced_coherent : 1;
     bool KHR_parallel_shader_compile : 1;
     bool EXT_base_instance : 1;
     bool EXT_clip_cull_distance : 1;
     bool EXT_color_buffer_half_float : 1;
-    bool EXT_float_blend : 1; // Implies EXT_color_buffer_float.
+    bool EXT_color_buffer_float : 1;
+    bool EXT_float_blend : 1;
     bool EXT_multisampled_render_to_texture : 1;
     bool EXT_shader_framebuffer_fetch : 1;
     bool EXT_shader_pixel_local_storage : 1;
diff --git a/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp b/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp
index 7b21e54..ddb4a56 100644
--- a/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp
+++ b/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp
@@ -74,6 +74,38 @@
 
     GLState* state() const { return m_state.get(); }
 
+    // Storage type and rendering method for the feather atlas.
+    //
+    // Ideally we would always use r32f or r16f, but floating point color
+    // buffers are only supported via extensions in GL.
+    //
+    // These are sorted with the most preferred types higher up in the list.
+    enum class AtlasType
+    {
+        r32f, // Most preferred. Uses HW blending to count coverage.
+        r16f, // Uses HW blending but loses precision on complex feathers.
+
+        r32uiFramebufferFetch, // Stores coverage as fp32 bits in a uint.
+        r32uiPixelLocalStorage,
+
+        r32iAtomicTexture, // Stores coverage as 16:16 fixed point.
+
+        rgba8, // Low quality, but always supported. Uses HW blending and breaks
+               // up coverage into all 4 components of an RGBA texture.
+    };
+
+    AtlasType atlasType() const { return m_atlasType; }
+
+#ifdef WITH_RIVE_TOOLS
+    // Changes the context's AtlasType for testing purposes. If atlasDesiredType
+    // is not supported, the next supported AtlasType down the list is chosen.
+    //
+    // NOTE: this also calls releaseResources() on the owning RenderContext to
+    // ensure the atlas texture gets reallocated.
+    void testingOnly_resetAtlasDesiredType(RenderContext* owningRenderContext,
+                                           AtlasType atlasDesiredType);
+#endif
+
 private:
     class DrawProgram;
 
@@ -152,6 +184,8 @@
                         std::unique_ptr<PixelLocalStorageImpl>,
                         ShaderCompilationMode);
 
+    void buildAtlasRenderPipelines();
+
     std::unique_ptr<BufferRing> makeUniformBufferRing(
         size_t capacityInBytes) override;
     std::unique_ptr<BufferRing> makeStorageBufferRing(
@@ -203,7 +237,7 @@
     glutils::Framebuffer m_tessellateFBO;
     GLuint m_tessVertexTexture = 0;
 
-    // Atlas rendering.
+    // Renders feathers to the atlas texture.
     class AtlasProgram
     {
     public:
@@ -227,9 +261,20 @@
         GLint m_baseInstanceUniformLocation = -1;
     };
 
+    // Atlas rendering pipelines.
+    AtlasType m_atlasType;
     glutils::Shader m_atlasVertexShader;
     AtlasProgram m_atlasFillProgram;
     AtlasProgram m_atlasStrokeProgram;
+    gpu::PipelineState m_atlasFillPipelineState;
+    gpu::PipelineState m_atlasStrokePipelineState;
+#ifdef RIVE_ANDROID
+    // Pipelines for clearing and resolving EXT_shader_pixel_local_storage.
+    glutils::Shader m_atlasResolveVertexShader;
+    glutils::Program m_atlasClearProgram = glutils::Program::Zero();
+    glutils::Program m_atlasResolveProgram = glutils::Program::Zero();
+    glutils::VAO m_atlasResolveVAO;
+#endif
     glutils::Texture m_atlasTexture = glutils::Texture::Zero();
     glutils::Framebuffer m_atlasFBO;
 
diff --git a/renderer/src/gl/gl_state.cpp b/renderer/src/gl/gl_state.cpp
index ec2ab1b..97a1e4b 100644
--- a/renderer/src/gl/gl_state.cpp
+++ b/renderer/src/gl/gl_state.cpp
@@ -258,7 +258,7 @@
             break;
         case gpu::BlendEquation::max:
             glBlendEquation(GL_MAX);
-            glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
+            glBlendFunc(GL_ONE, GL_ONE);
             break;
     }
     m_blendEquation = blendEquation;
diff --git a/renderer/src/gl/render_context_gl_impl.cpp b/renderer/src/gl/render_context_gl_impl.cpp
index c84ea2e..80d2b77 100644
--- a/renderer/src/gl/render_context_gl_impl.cpp
+++ b/renderer/src/gl/render_context_gl_impl.cpp
@@ -25,6 +25,10 @@
 #include "generated/shaders/blit_texture_as_draw.glsl.hpp"
 #include "generated/shaders/stencil_draw.glsl.hpp"
 
+#ifdef RIVE_ANDROID
+#include "generated/shaders/resolve_atlas.glsl.hpp"
+#endif
+
 #ifdef RIVE_WEBGL
 #include <emscripten/emscripten.h>
 #include <emscripten/html5.h>
@@ -70,6 +74,65 @@
     RIVE_UNREACHABLE();
 }
 
+// Returns atlasDesiredType, or the next supported AtlasType down the list if it
+// is not supported.
+static RenderContextGLImpl::AtlasType select_atlas_type(
+    const GLCapabilities& capabilities,
+    RenderContextGLImpl::AtlasType atlasDesiredType =
+        RenderContextGLImpl::AtlasType::r32f)
+{
+    switch (atlasDesiredType)
+    {
+        using AtlasType = RenderContextGLImpl::AtlasType;
+        case AtlasType::r32f:
+            if (capabilities.EXT_color_buffer_float &&
+                capabilities.EXT_float_blend)
+            {
+                // fp32 is ideal for the atlas. When there's a lot of overlap,
+                // fp16 can run out of precision.
+                return AtlasType::r32f;
+            }
+            [[fallthrough]];
+        case AtlasType::r16f:
+            if (capabilities.EXT_color_buffer_half_float)
+            {
+                return AtlasType::r16f;
+            }
+            [[fallthrough]];
+        case AtlasType::r32uiFramebufferFetch:
+            if (capabilities.EXT_shader_framebuffer_fetch)
+            {
+                return AtlasType::r32uiFramebufferFetch;
+            }
+            [[fallthrough]];
+        case AtlasType::r32uiPixelLocalStorage:
+#ifdef RIVE_ANDROID
+            if (capabilities.EXT_shader_pixel_local_storage)
+            {
+                return AtlasType::r32uiPixelLocalStorage;
+            }
+#else
+            if (capabilities.ANGLE_shader_pixel_local_storage_coherent)
+            {
+                return AtlasType::r32uiPixelLocalStorage;
+            }
+#endif
+            [[fallthrough]];
+        case AtlasType::r32iAtomicTexture:
+#ifndef RIVE_WEBGL
+            if (capabilities.ARB_shader_image_load_store ||
+                capabilities.OES_shader_image_atomic)
+            {
+                return AtlasType::r32iAtomicTexture;
+            }
+#endif
+            [[fallthrough]];
+        case AtlasType::rgba8:
+            return AtlasType::rgba8;
+    }
+    RIVE_UNREACHABLE();
+}
+
 RenderContextGLImpl::RenderContextGLImpl(
     const char* rendererString,
     GLCapabilities capabilities,
@@ -77,6 +140,7 @@
     ShaderCompilationMode shaderCompilationMode) :
     m_capabilities(capabilities),
     m_plsImpl(std::move(plsImpl)),
+    m_atlasType(select_atlas_type(m_capabilities)),
     m_vsManager(this),
     m_pipelineManager(shaderCompilationMode, this),
     m_state(make_rcp<GLState>(m_capabilities))
@@ -326,6 +390,123 @@
     m_state->invalidate();
 }
 
+void RenderContextGLImpl::buildAtlasRenderPipelines()
+{
+    std::vector<const char*> defines;
+    defines.push_back(GLSL_DRAW_PATH);
+    defines.push_back(GLSL_ENABLE_FEATHER);
+    defines.push_back(GLSL_ENABLE_INSTANCE_INDEX);
+    if (!m_capabilities.ARB_shader_storage_buffer_object)
+    {
+        defines.push_back(GLSL_DISABLE_SHADER_STORAGE_BUFFERS);
+    }
+    m_atlasFillPipelineState = gpu::ATLAS_FILL_PIPELINE_STATE;
+    m_atlasStrokePipelineState = gpu::ATLAS_STROKE_PIPELINE_STATE;
+    switch (m_atlasType)
+    {
+        case AtlasType::r32f:
+        case AtlasType::r16f:
+            break;
+        case AtlasType::r32uiFramebufferFetch:
+            defines.push_back(GLSL_ATLAS_RENDER_TARGET_R32UI_FRAMEBUFFER_FETCH);
+            m_atlasFillPipelineState.blendEquation = gpu::BlendEquation::none;
+            m_atlasStrokePipelineState.blendEquation = gpu::BlendEquation::none;
+            break;
+        case AtlasType::r32uiPixelLocalStorage:
+#ifdef RIVE_ANDROID
+            defines.push_back(GLSL_ATLAS_RENDER_TARGET_R32UI_PLS_EXT);
+#else
+            defines.push_back(GLSL_ATLAS_RENDER_TARGET_R32UI_PLS_ANGLE);
+#endif
+            m_atlasFillPipelineState.blendEquation = gpu::BlendEquation::none;
+            m_atlasStrokePipelineState.blendEquation = gpu::BlendEquation::none;
+            break;
+        case AtlasType::r32iAtomicTexture:
+#ifndef RIVE_WEBGL
+            defines.push_back(GLSL_ATLAS_RENDER_TARGET_R32I_ATOMIC_TEXTURE);
+            m_atlasFillPipelineState.colorWriteEnabled = false;
+            m_atlasFillPipelineState.blendEquation = gpu::BlendEquation::none;
+            m_atlasStrokePipelineState.colorWriteEnabled = false;
+            m_atlasStrokePipelineState.blendEquation = gpu::BlendEquation::none;
+#endif
+            break;
+        case AtlasType::rgba8:
+            defines.push_back(GLSL_ATLAS_RENDER_TARGET_RGBA8_UNORM);
+            break;
+    }
+
+    const char* atlasSources[] = {glsl::constants,
+                                  glsl::common,
+                                  glsl::draw_path_common,
+                                  glsl::render_atlas};
+    m_atlasVertexShader.compile(GL_VERTEX_SHADER,
+                                defines.data(),
+                                defines.size(),
+                                atlasSources,
+                                std::size(atlasSources),
+                                m_capabilities);
+
+    defines.push_back(GLSL_ATLAS_FEATHERED_FILL);
+    m_atlasFillProgram.compile(m_atlasVertexShader,
+                               defines.data(),
+                               defines.size(),
+                               atlasSources,
+                               std::size(atlasSources),
+                               m_capabilities,
+                               m_state.get());
+    defines.pop_back();
+
+    defines.push_back(GLSL_ATLAS_FEATHERED_STROKE);
+    m_atlasStrokeProgram.compile(m_atlasVertexShader,
+                                 defines.data(),
+                                 defines.size(),
+                                 atlasSources,
+                                 std::size(atlasSources),
+                                 m_capabilities,
+                                 m_state.get());
+    defines.pop_back();
+
+#ifdef RIVE_ANDROID
+    if (m_atlasType == AtlasType::r32uiPixelLocalStorage)
+    {
+        // Build the pipelines for clearing and resolving
+        // EXT_shader_pixel_local_storage.
+        m_atlasResolveVertexShader.compile(GL_VERTEX_SHADER,
+                                           glsl::resolve_atlas,
+                                           m_capabilities);
+
+        const char* atlasClearDefines[] = {
+            GLSL_ATLAS_RENDER_TARGET_R32UI_PLS_EXT,
+            GLSL_CLEAR_COVERAGE};
+        const char* atlasClearSources[] = {glsl::resolve_atlas};
+        m_atlasClearProgram = glutils::Program();
+        glAttachShader(m_atlasClearProgram, m_atlasResolveVertexShader);
+        m_atlasClearProgram.compileAndAttachShader(GL_FRAGMENT_SHADER,
+                                                   atlasClearDefines,
+                                                   std::size(atlasClearDefines),
+                                                   atlasClearSources,
+                                                   std::size(atlasClearSources),
+                                                   m_capabilities);
+        m_atlasClearProgram.link();
+
+        const char* atlasResolveDefines[] = {
+            GLSL_ATLAS_RENDER_TARGET_R32UI_PLS_EXT,
+        };
+        const char* atlasResolveSources[] = {glsl::resolve_atlas};
+        m_atlasResolveProgram = glutils::Program();
+        glAttachShader(m_atlasResolveProgram, m_atlasResolveVertexShader);
+        m_atlasResolveProgram.compileAndAttachShader(
+            GL_FRAGMENT_SHADER,
+            atlasResolveDefines,
+            std::size(atlasResolveDefines),
+            atlasResolveSources,
+            std::size(atlasResolveSources),
+            m_capabilities);
+        m_atlasResolveProgram.link();
+    }
+#endif
+}
+
 void RenderContextGLImpl::invalidateGLState()
 {
     glActiveTexture(GL_TEXTURE0 + TESS_VERTEX_TEXTURE_IDX);
@@ -667,14 +848,15 @@
     if (width == 0 || height == 0)
     {
         m_gradientTexture = 0;
-        return;
     }
-
-    glGenTextures(1, &m_gradientTexture);
-    glActiveTexture(GL_TEXTURE0 + GRAD_TEXTURE_IDX);
-    glBindTexture(GL_TEXTURE_2D, m_gradientTexture);
-    glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height);
-    glutils::SetTexture2DSamplingParams(GL_LINEAR, GL_LINEAR);
+    else
+    {
+        glGenTextures(1, &m_gradientTexture);
+        glActiveTexture(GL_TEXTURE0 + GRAD_TEXTURE_IDX);
+        glBindTexture(GL_TEXTURE_2D, m_gradientTexture);
+        glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height);
+        glutils::SetTexture2DSamplingParams(GL_LINEAR, GL_LINEAR);
+    }
 
     glBindFramebuffer(GL_FRAMEBUFFER, m_colorRampFBO);
     glFramebufferTexture2D(GL_FRAMEBUFFER,
@@ -691,20 +873,21 @@
     if (width == 0 || height == 0)
     {
         m_tessVertexTexture = 0;
-        return;
     }
-
-    glGenTextures(1, &m_tessVertexTexture);
-    glActiveTexture(GL_TEXTURE0 + TESS_VERTEX_TEXTURE_IDX);
-    glBindTexture(GL_TEXTURE_2D, m_tessVertexTexture);
-    glTexStorage2D(GL_TEXTURE_2D,
-                   1,
-                   m_capabilities.needsFloatingPointTessellationTexture
-                       ? GL_RGBA32F
-                       : GL_RGBA32UI,
-                   width,
-                   height);
-    glutils::SetTexture2DSamplingParams(GL_NEAREST, GL_NEAREST);
+    else
+    {
+        glGenTextures(1, &m_tessVertexTexture);
+        glActiveTexture(GL_TEXTURE0 + TESS_VERTEX_TEXTURE_IDX);
+        glBindTexture(GL_TEXTURE_2D, m_tessVertexTexture);
+        glTexStorage2D(GL_TEXTURE_2D,
+                       1,
+                       m_capabilities.needsFloatingPointTessellationTexture
+                           ? GL_RGBA32F
+                           : GL_RGBA32UI,
+                       width,
+                       height);
+        glutils::SetTexture2DSamplingParams(GL_NEAREST, GL_NEAREST);
+    }
 
     glBindFramebuffer(GL_FRAMEBUFFER, m_tessellateFBO);
     glFramebufferTexture2D(GL_FRAMEBUFFER,
@@ -757,78 +940,68 @@
     }
 }
 
+static GLenum atlas_gl_format(RenderContextGLImpl::AtlasType atlasType,
+                              const GLCapabilities& capabilities)
+{
+    switch (atlasType)
+    {
+        using AtlasType = RenderContextGLImpl::AtlasType;
+        case AtlasType::r32f:
+            return GL_R32F;
+        case AtlasType::r16f:
+            return GL_R16F;
+        case AtlasType::r32uiFramebufferFetch:
+        case AtlasType::r32uiPixelLocalStorage:
+            return GL_R32UI;
+        case AtlasType::r32iAtomicTexture:
+            return GL_R32I;
+        case AtlasType::rgba8:
+            return GL_RGBA8;
+    }
+    RIVE_UNREACHABLE();
+}
+
 void RenderContextGLImpl::resizeAtlasTexture(uint32_t width, uint32_t height)
 {
     if (width == 0 || height == 0)
     {
         m_atlasTexture = glutils::Texture::Zero();
-        return;
+    }
+    else
+    {
+        m_atlasTexture = glutils::Texture();
+        glActiveTexture(GL_TEXTURE0 + ATLAS_TEXTURE_IDX);
+        glBindTexture(GL_TEXTURE_2D, m_atlasTexture);
+        glTexStorage2D(GL_TEXTURE_2D,
+                       1,
+                       atlas_gl_format(m_atlasType, m_capabilities),
+                       width,
+                       height);
+        glutils::SetTexture2DSamplingParams(GL_NEAREST, GL_NEAREST);
+
+        if (m_atlasVertexShader == 0)
+        {
+            // Don't compile the atlas programs until we get an indication that
+            // they will be used.
+            // FIXME: Do this in parallel at startup!!
+            buildAtlasRenderPipelines();
+        }
     }
 
-    m_atlasTexture = glutils::Texture();
-    glActiveTexture(GL_TEXTURE0 + ATLAS_TEXTURE_IDX);
-    glBindTexture(GL_TEXTURE_2D, m_atlasTexture);
-    glTexStorage2D(
-        GL_TEXTURE_2D,
-        1,
-        m_capabilities.EXT_float_blend
-            ? GL_R32F  // fp32 is ideal for the atlas. When there's a lot of
-                       // overlap, fp16 can run out of precision.
-            : GL_R16F, // FIXME: Fallback for when EXT_color_buffer_half_float
-                       // isn't supported.
-        width,
-        height);
-    glutils::SetTexture2DSamplingParams(GL_NEAREST, GL_NEAREST);
-
     glBindFramebuffer(GL_FRAMEBUFFER, m_atlasFBO);
-    glFramebufferTexture2D(GL_FRAMEBUFFER,
-                           GL_COLOR_ATTACHMENT0,
-                           GL_TEXTURE_2D,
-                           m_atlasTexture,
-                           0);
-
-    // Don't compile the atlas programs until we get an indication that they
-    // will be used.
-    if (m_atlasVertexShader == 0)
+#ifndef RIVE_ANDROID
+    if (m_atlasType == AtlasType::r32uiPixelLocalStorage)
     {
-        std::vector<const char*> defines;
-        defines.push_back(GLSL_DRAW_PATH);
-        defines.push_back(GLSL_ENABLE_FEATHER);
-        defines.push_back(GLSL_ENABLE_INSTANCE_INDEX);
-        if (!m_capabilities.ARB_shader_storage_buffer_object)
-        {
-            defines.push_back(GLSL_DISABLE_SHADER_STORAGE_BUFFERS);
-        }
-        const char* atlasSources[] = {glsl::constants,
-                                      glsl::common,
-                                      glsl::draw_path_common,
-                                      glsl::render_atlas};
-        m_atlasVertexShader.compile(GL_VERTEX_SHADER,
-                                    defines.data(),
-                                    defines.size(),
-                                    atlasSources,
-                                    std::size(atlasSources),
-                                    m_capabilities);
-
-        defines.push_back(GLSL_ATLAS_FEATHERED_FILL);
-        m_atlasFillProgram.compile(m_atlasVertexShader,
-                                   defines.data(),
-                                   defines.size(),
-                                   atlasSources,
-                                   std::size(atlasSources),
-                                   m_capabilities,
-                                   m_state.get());
-        defines.pop_back();
-
-        defines.push_back(GLSL_ATLAS_FEATHERED_STROKE);
-        m_atlasStrokeProgram.compile(m_atlasVertexShader,
-                                     defines.data(),
-                                     defines.size(),
-                                     atlasSources,
-                                     std::size(atlasSources),
-                                     m_capabilities,
-                                     m_state.get());
-        defines.pop_back();
+        glFramebufferTexturePixelLocalStorageANGLE(0, m_atlasTexture, 0, 0);
+    }
+    else
+#endif
+    {
+        glFramebufferTexture2D(GL_FRAMEBUFFER,
+                               GL_COLOR_ATTACHMENT0,
+                               GL_TEXTURE_2D,
+                               m_atlasTexture,
+                               0);
     }
 }
 
@@ -938,6 +1111,22 @@
             break;
         case gpu::DrawType::atlasBlit:
             defines.push_back(GLSL_ATLAS_BLIT);
+            switch (renderContextImpl->m_atlasType)
+            {
+                case AtlasType::r32f:
+                case AtlasType::r16f:
+                    break;
+                case AtlasType::r32uiFramebufferFetch:
+                case AtlasType::r32uiPixelLocalStorage:
+                    defines.push_back(GLSL_ATLAS_TEXTURE_R32UI_FLOAT_BITS);
+                    break;
+                case AtlasType::r32iAtomicTexture:
+                    defines.push_back(GLSL_ATLAS_TEXTURE_R32I_FIXED_POINT);
+                    break;
+                case AtlasType::rgba8:
+                    defines.push_back(GLSL_ATLAS_TEXTURE_RGBA8_UNORM);
+                    break;
+            }
             [[fallthrough]];
         case gpu::DrawType::interiorTriangulation:
             defines.push_back(GLSL_DRAW_INTERIOR_TRIANGLES);
@@ -1535,17 +1724,70 @@
     {
         glBindFramebuffer(GL_FRAMEBUFFER, m_atlasFBO);
         glViewport(0, 0, desc.atlasContentWidth, desc.atlasContentHeight);
+
         glEnable(GL_SCISSOR_TEST);
         glScissor(0, 0, desc.atlasContentWidth, desc.atlasContentHeight);
-        glClearColor(0, 0, 0, 0);
-        glClear(GL_COLOR_BUFFER_BIT);
-        m_state->bindVAO(m_drawVAO);
+
         // Invert the front face for atlas draws because GL is bottom up.
         glFrontFace(GL_CCW);
 
+        // Finish setting up the atlas render pass and clear the atlas.
+        m_state->setPipelineState(gpu::COLOR_ONLY_PIPELINE_STATE);
+        switch (m_atlasType)
+        {
+            case AtlasType::r32f:
+            case AtlasType::r16f:
+            case AtlasType::rgba8:
+            {
+                constexpr GLfloat clearZero4f[4]{};
+                glClearBufferfv(GL_COLOR, 0, clearZero4f);
+                break;
+            }
+            case AtlasType::r32uiFramebufferFetch:
+            {
+                constexpr GLuint clearZero4ui[4]{};
+                glClearBufferuiv(GL_COLOR, 0, clearZero4ui);
+                break;
+            }
+            case AtlasType::r32uiPixelLocalStorage:
+            {
+#ifdef RIVE_ANDROID
+                glEnable(GL_SHADER_PIXEL_LOCAL_STORAGE_EXT);
+                // EXT_shader_pixel_local_storage doesn't support clearing.
+                // Render the clear color.
+                m_state->bindProgram(m_atlasClearProgram);
+                m_state->bindVAO(m_atlasResolveVAO);
+                m_state->setCullFace(GL_FRONT);
+                glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
+#else
+                glBeginPixelLocalStorageANGLE(
+                    1,
+                    std::array<GLenum, 1>{GL_LOAD_OP_ZERO_ANGLE}.data());
+#endif
+                break;
+            }
+            case AtlasType::r32iAtomicTexture:
+            {
+#ifndef RIVE_WEBGL
+                constexpr GLint clearZero4i[4]{};
+                glClearBufferiv(GL_COLOR, 0, clearZero4i);
+                glBindImageTexture(0,
+                                   m_atlasTexture,
+                                   0,
+                                   GL_FALSE,
+                                   0,
+                                   GL_READ_WRITE,
+                                   GL_R32I);
+#endif
+                break;
+            }
+        }
+        m_state->bindVAO(m_drawVAO);
+
+        // Draw the atlas fills.
         if (desc.atlasFillBatchCount != 0)
         {
-            m_state->setPipelineState(gpu::ATLAS_FILL_PIPELINE_STATE);
+            m_state->setPipelineState(m_atlasFillPipelineState);
             m_state->bindProgram(m_atlasFillProgram);
             for (size_t i = 0; i < desc.atlasFillBatchCount; ++i)
             {
@@ -1564,9 +1806,10 @@
             }
         }
 
+        // Draw the atlas strokes.
         if (desc.atlasStrokeBatchCount != 0)
         {
-            m_state->setPipelineState(gpu::ATLAS_STROKE_PIPELINE_STATE);
+            m_state->setPipelineState(m_atlasStrokePipelineState);
             m_state->bindProgram(m_atlasStrokeProgram);
             for (size_t i = 0; i < desc.atlasStrokeBatchCount; ++i)
             {
@@ -1586,6 +1829,26 @@
             }
         }
 
+        // Close the atlas render pass if needed. (i.e., pixel local storage
+        // needs to be disabled.)
+        if (m_atlasType == AtlasType::r32uiPixelLocalStorage)
+        {
+#ifdef RIVE_ANDROID
+            // EXT_shader_pixel_local_storage needs to be explicity resolved
+            // with a draw.
+            m_state->bindProgram(m_atlasResolveProgram);
+            m_state->bindVAO(m_atlasResolveVAO);
+            m_state->setCullFace(GL_FRONT);
+            glScissor(0, 0, desc.atlasContentWidth, desc.atlasContentHeight);
+            glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
+            glDisable(GL_SHADER_PIXEL_LOCAL_STORAGE_EXT);
+#else
+            glEndPixelLocalStorageANGLE(
+                1,
+                std::array<GLenum, 1>{GL_STORE_OP_STORE_ANGLE}.data());
+#endif
+        }
+
         glFrontFace(GL_CW);
         glDisable(GL_SCISSOR_TEST);
     }
@@ -2063,6 +2326,34 @@
     glDisable(GL_SCISSOR_TEST);
 }
 
+#ifdef WITH_RIVE_TOOLS
+void RenderContextGLImpl::testingOnly_resetAtlasDesiredType(
+    RenderContext* owningRenderContext,
+    AtlasType atlasDesiredType)
+{
+    owningRenderContext->releaseResources();
+    assert(m_atlasTexture == 0); // Should be cleared by releaseResources().
+    m_atlasFBO = {};
+
+    // Now release the atlas pipelines so they can be recompiled for the new
+    // AtlasType.
+    m_atlasVertexShader = {};
+    m_atlasFillProgram = {};
+    m_atlasStrokeProgram = {};
+#ifdef RIVE_ANDROID
+    m_atlasResolveVertexShader = {};
+    m_atlasClearProgram = glutils::Program::Zero();
+    m_atlasResolveProgram = glutils::Program::Zero();
+#endif
+
+    // ...And release all the DrawShaders in case any need to be recompiled for
+    // sampling a different AtlasType.
+    m_pipelineManager.clearCache();
+
+    m_atlasType = select_atlas_type(m_capabilities, atlasDesiredType);
+}
+#endif
+
 std::unique_ptr<RenderContext> RenderContextGLImpl::MakeContext(
     const ContextOptions& contextOptions)
 {
@@ -2174,6 +2465,10 @@
         {
             capabilities.ARB_shader_storage_buffer_object = true;
         }
+        if (capabilities.isContextVersionAtLeast(3, 2))
+        {
+            capabilities.OES_shader_image_atomic = true;
+        }
     }
     else
     {
@@ -2233,6 +2528,10 @@
         {
             capabilities.ARB_shader_storage_buffer_object = true;
         }
+        else if (strcmp(ext, "GL_OES_shader_image_atomic") == 0)
+        {
+            capabilities.OES_shader_image_atomic = true;
+        }
         else if (strcmp(ext, "GL_KHR_blend_equation_advanced") == 0)
         {
             capabilities.KHR_blend_equation_advanced = true;
@@ -2283,6 +2582,10 @@
         {
             capabilities.EXT_color_buffer_half_float = true;
         }
+        else if (strcmp(ext, "GL_EXT_color_buffer_float") == 0)
+        {
+            capabilities.EXT_color_buffer_float = true;
+        }
         else if (strcmp(ext, "GL_EXT_float_blend") == 0)
         {
             capabilities.EXT_float_blend = true;
@@ -2290,6 +2593,7 @@
         else if (strcmp(ext, "GL_ARB_color_buffer_float") == 0)
         {
             capabilities.EXT_color_buffer_half_float = true;
+            capabilities.EXT_color_buffer_float = true;
             capabilities.EXT_float_blend = true;
         }
         else if (strcmp(ext, "GL_EXT_shader_framebuffer_fetch") == 0)
@@ -2334,8 +2638,11 @@
     }
     if (emscripten_webgl_enable_extension(
             emscripten_webgl_get_current_context(),
-            "EXT_color_buffer_float") &&
-        emscripten_webgl_enable_extension(
+            "EXT_color_buffer_float"))
+    {
+        capabilities.EXT_color_buffer_float = true;
+    }
+    if (emscripten_webgl_enable_extension(
             emscripten_webgl_get_current_context(),
             "EXT_float_blend"))
     {
@@ -2377,6 +2684,22 @@
         }
     }
 
+    if (capabilities.OES_shader_image_atomic)
+    {
+        if (capabilities.isMali || capabilities.isPowerVR)
+        {
+            // Don't use shader images for feathering on Mali or PowerVR.
+            // On PowerVR they just don't render, and on Mali they lead to a
+            // failures that says:
+            //
+            //   Error:glDrawElementsInstanced::failed to allocate CPU memory
+            //
+            // This is not at all surprising since neither of these vendors
+            // support "fragmentStoresAndAtomics" on Vulkan.
+            capabilities.OES_shader_image_atomic = false;
+        }
+    }
+
     if (capabilities.ANGLE_shader_pixel_local_storage ||
         capabilities.ANGLE_shader_pixel_local_storage_coherent)
     {
@@ -2419,7 +2742,7 @@
     }
 
     if (strstr(rendererString, "ANGLE Metal Renderer") != nullptr &&
-        capabilities.EXT_float_blend)
+        capabilities.EXT_color_buffer_float)
     {
         capabilities.needsFloatingPointTessellationTexture = true;
     }
diff --git a/renderer/src/shaders/common.glsl b/renderer/src/shaders/common.glsl
index ae3e24b..2214bb7 100644
--- a/renderer/src/shaders/common.glsl
+++ b/renderer/src/shaders/common.glsl
@@ -37,6 +37,15 @@
 #define TESSDATA_AS_UINT(X) X
 #endif
 
+// Gathers a 4xN matrix of texels, in the same order as the textureGather() API.
+// clang-format off
+#define TEXTURE_GATHER_MATRIX(NAME, COORD, COMPONENTS)                         \
+    TEXEL_FETCH(NAME, int2(COORD) + int2(-1, 0))COMPONENTS,                    \
+        TEXEL_FETCH(NAME, int2(COORD) + int2(0, 0))COMPONENTS,                 \
+        TEXEL_FETCH(NAME, int2(COORD) + int2(0, -1))COMPONENTS,                \
+        TEXEL_FETCH(NAME, int2(COORD) + int2(-1, -1))COMPONENTS
+// clang-format on
+
 // This is a macro because we can't (at least for now) forward texture refs to a
 // function in a way that works in all the languages we support.
 // This is a macro because we can't (at least for now) forward texture refs to a
@@ -138,6 +147,8 @@
     return ret;
 }
 
+INLINE half4 make_half4(half4 x) { return x; }
+
 INLINE bool2 make_bool2(bool b) { return bool2(b, b); }
 
 INLINE half3x3 make_half3x3(half3 a, half3 b, half3 c)
@@ -157,6 +168,16 @@
     return ret;
 }
 
+INLINE half4x4 make_half4x4(half4 a, half4 b, half4 c, half4 d)
+{
+    half4x4 ret;
+    ret[0] = a;
+    ret[1] = b;
+    ret[2] = c;
+    ret[3] = d;
+    return ret;
+}
+
 INLINE float2x2 make_float2x2(float4 x) { return float2x2(x.xy, x.zw); }
 
 INLINE uint make_uint(ushort x) { return x; }
diff --git a/renderer/src/shaders/constants.glsl b/renderer/src/shaders/constants.glsl
index a48c9ef..46af45f 100644
--- a/renderer/src/shaders/constants.glsl
+++ b/renderer/src/shaders/constants.glsl
@@ -32,6 +32,11 @@
 // need at least one segment, thus a minimum of 2 (plus helper vertices).
 #define FEATHER_JOIN_MIN_SEGMENT_COUNT (2u + FEATHER_JOIN_HELPER_SEGMENT_COUNT)
 
+// The feather texture doesn't begin and end on 0 and 1. These are the actual
+// values that get returned by FEATHER(0) and FEATHER(1) respectively.
+#define MIN_FEATHER float(0.00137615203857421875)
+#define MAX_FEATHER float(0.99853515625)
+
 // Width to use for a texture that emulates a storage buffer.
 //
 // Minimize width since the texture needs to be updated in entire rows from the
@@ -239,3 +244,12 @@
 #define BORROWED_COVERAGE_PREPASS_SPECIALIZATION_IDX 8
 #define VULKAN_VENDOR_ID_SPECIALIZATION_IDX 9
 #define SPECIALIZATION_COUNT 10
+
+// When rendering to an r32i feather atlas, use 16:16 fixed point.
+#define ATLAS_R32I_FIXED_POINT_FACTOR 65536.
+
+// When we have to fall back on an 8-bit color buffer to render the feather
+// atlas, sacrifice precision to lessen overflows.
+// Throwing away the bottom 3 bits seems to be the best tradeoff, based on our
+// golden image suite.
+#define ATLAS_UNORM8_COVERAGE_SCALE_FACTOR 8.
diff --git a/renderer/src/shaders/draw_path_common.glsl b/renderer/src/shaders/draw_path_common.glsl
index 0d12b40..84bd94f 100644
--- a/renderer/src/shaders/draw_path_common.glsl
+++ b/renderer/src/shaders/draw_path_common.glsl
@@ -57,8 +57,16 @@
                       @featherTexture);
 #endif
 #ifdef @ATLAS_BLIT
+#ifdef @ATLAS_TEXTURE_R32UI_FLOAT_BITS
+TEXTURE_R32UI(PER_DRAW_BINDINGS_SET, ATLAS_TEXTURE_IDX, @atlasTexture);
+#elif defined(@ATLAS_TEXTURE_R32I_FIXED_POINT)
+TEXTURE_R32I(PER_DRAW_BINDINGS_SET, ATLAS_TEXTURE_IDX, @atlasTexture);
+#elif defined(@ATLAS_TEXTURE_RGBA8_UNORM)
+TEXTURE_RGBA8(PER_DRAW_BINDINGS_SET, ATLAS_TEXTURE_IDX, @atlasTexture);
+#else
 TEXTURE_R16F(PER_DRAW_BINDINGS_SET, ATLAS_TEXTURE_IDX, @atlasTexture);
 #endif
+#endif
 TEXTURE_RGBA8(PER_DRAW_BINDINGS_SET, IMAGE_TEXTURE_IDX, @imageTexture);
 // The Qualcomm compiler can't handle line breaks in #ifs.
 // clang-format off
@@ -267,10 +275,38 @@
     // Gather from the exact center of the quad to make sure there are no
     // rounding differences between us and the texture unit.
     float2 atlasQuadCenter = round(atlasCoord);
-    half4 coverages = TEXTURE_GATHER(@atlasTexture,
-                                     atlasSampler,
-                                     atlasQuadCenter,
-                                     atlasTextureInverseSize);
+    half4 coverages;
+#ifdef @ATLAS_TEXTURE_R32UI_FLOAT_BITS
+    coverages = uintBitsToFloat(uint4(TEXTURE_GATHER(@atlasTexture,
+                                                     atlasSampler,
+                                                     atlasQuadCenter,
+                                                     atlasTextureInverseSize)));
+#elif defined(@ATLAS_TEXTURE_R32I_FIXED_POINT)
+    int4 coverages_i32 = int4(TEXTURE_GATHER(@atlasTexture,
+                                             atlasSampler,
+                                             atlasQuadCenter,
+                                             atlasTextureInverseSize));
+    coverages = float4(coverages_i32) * (1. / ATLAS_R32I_FIXED_POINT_FACTOR);
+#elif defined(@ATLAS_TEXTURE_RGBA8_UNORM)
+    int2 coord = int2(atlasQuadCenter);
+    half4x4 coverages_u8x4 =
+        make_half4x4(TEXTURE_GATHER_MATRIX(@atlasTexture, coord, .rgba));
+    // Apply the following weights to the RGBA of each u8x4 coverage value:
+    //   - R counts fractional, positive coverage.
+    //   - G counts fractional, negative coverage.
+    //   - B counts integer, positive coverage.
+    //   - A counts integer, negative coverage.
+    coverages = make_half4(ATLAS_UNORM8_COVERAGE_SCALE_FACTOR,
+                           -ATLAS_UNORM8_COVERAGE_SCALE_FACTOR,
+                           255.,
+                           -255.) *
+                coverages_u8x4;
+#else
+    coverages = make_half4(TEXTURE_GATHER(@atlasTexture,
+                                          atlasSampler,
+                                          atlasQuadCenter,
+                                          atlasTextureInverseSize));
+#endif
     // Convert each pixel from gaussian space back to linear.
     coverages = make_half4(INVERSE_FEATHER(coverages.x),
                            INVERSE_FEATHER(coverages.y),
diff --git a/renderer/src/shaders/glsl.glsl b/renderer/src/shaders/glsl.glsl
index b4c21d0..178f649 100644
--- a/renderer/src/shaders/glsl.glsl
+++ b/renderer/src/shaders/glsl.glsl
@@ -24,6 +24,7 @@
 #define half4 mediump vec4
 #define half3x3 mediump mat3x3
 #define half2x3 mediump mat2x3
+#define half4x4 mediump mat4x4
 
 #define int2 ivec2
 #define int3 ivec3
@@ -61,6 +62,24 @@
 #extension GL_KHR_blend_equation_advanced : require
 #endif
 
+// Enable the necessary extensions for rendering the feather atlas.
+// NOTE: We do this here instead of render_atlas.glsl because extensions have to
+// be declared before any code.
+#ifdef @ATLAS_RENDER_TARGET_R32UI_FRAMEBUFFER_FETCH
+#extension GL_EXT_shader_framebuffer_fetch : require
+#elif defined(@ATLAS_RENDER_TARGET_R32UI_PLS_EXT)
+#extension GL_EXT_shader_pixel_local_storage : require
+#elif defined(@ATLAS_RENDER_TARGET_R32UI_PLS_ANGLE)
+#extension GL_ANGLE_shader_pixel_local_storage : require
+#elif defined(@ATLAS_RENDER_TARGET_R32I_ATOMIC_TEXTURE)
+#ifdef GL_ARB_shader_image_load_store
+#extension GL_ARB_shader_image_load_store : require
+#endif
+#ifdef GL_OES_shader_image_atomic
+#extension GL_OES_shader_image_atomic : require
+#endif
+#endif
+
 // clang-format off
 #if defined(@RENDER_MODE_MSAA) && defined(@ENABLE_CLIP_RECT) && defined(GL_ES)
 // clang-format on
@@ -145,6 +164,10 @@
     layout(set = SET, binding = IDX) uniform mediump texture2D NAME
 #define TEXTURE_R16F(SET, IDX, NAME)                                           \
     layout(binding = IDX) uniform mediump texture2D NAME
+#define TEXTURE_R32I(SET, IDX, NAME)                                           \
+    layout(binding = IDX) uniform highp itexture2D NAME
+#define TEXTURE_R32UI(SET, IDX, NAME)                                          \
+    layout(binding = IDX) uniform highp utexture2D NAME
 #if defined(@FRAGMENT) && defined(@RENDER_MODE_MSAA)
 #define DST_COLOR_TEXTURE(NAME)                                                \
     layout(input_attachment_index = 0,                                         \
@@ -160,6 +183,10 @@
     layout(binding = IDX) uniform mediump sampler2D NAME
 #define TEXTURE_R16F(SET, IDX, NAME)                                           \
     layout(binding = IDX) uniform mediump sampler2D NAME
+#define TEXTURE_R32I(SET, IDX, NAME)                                           \
+    layout(binding = IDX) uniform highp isampler2D NAME
+#define TEXTURE_R32UI(SET, IDX, NAME)                                          \
+    layout(binding = IDX) uniform highp usampler2D NAME
 #define DST_COLOR_TEXTURE(NAME)                                                \
     TEXTURE_RGBA8(PER_FLUSH_BINDINGS_SET, DST_COLOR_TEXTURE_IDX, NAME)
 #else
@@ -167,6 +194,8 @@
 #define TEXTURE_RGBA32F(SET, IDX, NAME) uniform highp sampler2D NAME
 #define TEXTURE_RGBA8(SET, IDX, NAME) uniform mediump sampler2D NAME
 #define TEXTURE_R16F(SET, IDX, NAME) uniform mediump sampler2D NAME
+#define TEXTURE_R32I(SET, IDX, NAME) uniform highp isampler2D NAME
+#define TEXTURE_R32UI(SET, IDX, NAME) uniform highp usampler2D NAME
 #define DST_COLOR_TEXTURE(NAME)                                                \
     TEXTURE_RGBA8(PER_FLUSH_BINDINGS_SET, DST_COLOR_TEXTURE_IDX, NAME)
 #endif
@@ -240,10 +269,7 @@
     textureGather(NAME, (COORD) * (TEXTURE_INVERSE_SIZE))
 #else
 #define TEXTURE_GATHER(NAME, SAMPLER_NAME, COORD, TEXTURE_INVERSE_SIZE)        \
-    make_half4(TEXEL_FETCH(NAME, int2(COORD) + int2(-1, 0)).r,                 \
-               TEXEL_FETCH(NAME, int2(COORD) + int2(0, 0)).r,                  \
-               TEXEL_FETCH(NAME, int2(COORD) + int2(0, -1)).r,                 \
-               TEXEL_FETCH(NAME, int2(COORD) + int2(-1, -1)).r)
+    TEXTURE_GATHER_MATRIX(NAME, COORD, .r)
 #endif
 
 #define VERTEX_STORAGE_BUFFER_BLOCK_BEGIN
@@ -332,7 +358,7 @@
 
 #ifdef @PLS_IMPL_EXT_NATIVE
 
-#extension GL_EXT_shader_pixel_local_storage : enable
+#extension GL_EXT_shader_pixel_local_storage : require
 
 #define PLS_BLOCK_BEGIN                                                        \
     __pixel_localEXT PLS                                                       \
@@ -356,31 +382,6 @@
 
 #endif
 
-#ifdef @PLS_IMPL_FRAMEBUFFER_FETCH
-
-#extension GL_EXT_shader_framebuffer_fetch : require
-
-#define PLS_BLOCK_BEGIN
-#define PLS_DECL4F(IDX, NAME) layout(location = IDX) inout lowp vec4 NAME
-#define PLS_DECLUI(IDX, NAME) layout(location = IDX) inout highp uvec4 NAME
-#define PLS_BLOCK_END
-
-#define PLS_LOAD4F(PLANE) PLANE
-#define PLS_LOADUI(PLANE) PLANE.r
-#define PLS_STORE4F(PLANE, VALUE) PLANE = (VALUE)
-#define PLS_STOREUI(PLANE, VALUE) PLANE.r = (VALUE)
-
-// When using multiple color attachments, we have to write a value to every
-// color attachment, every shader invocation, or else the contents become
-// undefined.
-#define PLS_PRESERVE_4F(PLANE) PLS_STORE4F(PLANE, PLS_LOAD4F(PLANE))
-#define PLS_PRESERVE_UI(PLANE) PLS_STOREUI(PLANE, PLS_LOADUI(PLANE))
-
-#define PLS_INTERLOCK_BEGIN
-#define PLS_INTERLOCK_END
-
-#endif // PLS_IMPL_FRAMEBUFFER_FETCH
-
 #ifdef @PLS_IMPL_STORAGE_TEXTURE
 
 #ifdef GL_ARB_shader_image_load_store
diff --git a/renderer/src/shaders/hlsl.glsl b/renderer/src/shaders/hlsl.glsl
index 339d51b..2dbe6c0 100644
--- a/renderer/src/shaders/hlsl.glsl
+++ b/renderer/src/shaders/hlsl.glsl
@@ -37,6 +37,7 @@
 #define float2x2 $float2x2
 #define half3x3 $half3x3
 #define half2x3 $half2x3
+#define half4x4 $half4x4
 #endif
 
 $typedef float3 packed_float3;
diff --git a/renderer/src/shaders/metal.glsl b/renderer/src/shaders/metal.glsl
index 2aa9c4e..f568670 100644
--- a/renderer/src/shaders/metal.glsl
+++ b/renderer/src/shaders/metal.glsl
@@ -40,6 +40,7 @@
 #define float2x2 $float2x2
 #define half3x3 $half3x3
 #define half2x3 $half2x3
+#define half4x4 $half4x4
 #endif
 
 #define INLINE $inline
diff --git a/renderer/src/shaders/minify.py b/renderer/src/shaders/minify.py
index 2e0343e..57fe3f8 100644
--- a/renderer/src/shaders/minify.py
+++ b/renderer/src/shaders/minify.py
@@ -224,7 +224,7 @@
     "outerProduct", "outerProduct", "outerProduct", "outerProduct", "outerProduct", "outerProduct",
     "outerProduct", "outerProduct", "packDouble2x32", "packHalf2x16", "packSnorm2x16",
     "packSnorm4x8", "packUnorm2x16", "packUnorm4x8", "pixelLocalLoadANGLE", "pixelLocalStoreANGLE",
-    "pow", "precision", "r16f", "r32f", "r32ui", "radians", "reflect", "reflect", "refract",
+    "pow", "precision", "r16f", "r32f", "r32i", "r32ui", "radians", "reflect", "reflect", "refract",
     "refract", "return", "rg16f", "rgb_2_yuv", "rgba8", "rgba8i", "rgba8ui", "round", "round",
     "roundEven", "roundEven", "sampler2D", "sampler2DArray", "sampler2DArrayShadow",
     "sampler2DShadow", "sampler3D", "samplerCube", "samplerCubeShadow", "shadow1D", "shadow1DLod",
diff --git a/renderer/src/shaders/render_atlas.glsl b/renderer/src/shaders/render_atlas.glsl
index 0ef4403..4c3dafb 100644
--- a/renderer/src/shaders/render_atlas.glsl
+++ b/renderer/src/shaders/render_atlas.glsl
@@ -59,13 +59,160 @@
 
 #ifdef @FRAGMENT
 
+#ifdef @ATLAS_RENDER_TARGET_R32UI_FRAMEBUFFER_FETCH
+
+// Store coverage as fp32 data bits in an r32ui color buffer, and use
+// framebuffer-fetch to manipulate it.
+layout(location = 0) inout highp uvec4 _fragCoverage;
+
+#ifdef @ATLAS_FEATHERED_FILL
+void main()
+{
+    float coverage = uintBitsToFloat(_fragCoverage.r);
+    coverage += eval_feathered_fill(v_coverages);
+    _fragCoverage.r = floatBitsToUint(coverage);
+}
+#endif
+
+#ifdef @ATLAS_FEATHERED_STROKE
+void main()
+{
+    float coverage = uintBitsToFloat(_fragCoverage.r);
+    coverage = max(coverage, eval_feathered_stroke(v_coverages));
+    _fragCoverage.r = floatBitsToUint(coverage);
+}
+#endif
+
+#elif defined(@ATLAS_RENDER_TARGET_R32UI_PLS_EXT)
+
+// Manipulate fp32 coverage in pixel local storage, which will be written out
+// to an r32ui color buffer during a separate resolve step.
+__pixel_localEXT PLS { layout(r32f) highp float _plsCoverage; };
+
+#ifdef @ATLAS_FEATHERED_FILL
+void main() { _plsCoverage += eval_feathered_fill(v_coverages); }
+#endif
+
+#ifdef @ATLAS_FEATHERED_STROKE
+void main()
+{
+    _plsCoverage = max(_plsCoverage, eval_feathered_stroke(v_coverages));
+}
+#endif
+
+#elif defined(@ATLAS_RENDER_TARGET_R32UI_PLS_ANGLE)
+
+// Store and manipulate coverage as fp32 data bits in r32ui-texture-backed pixel
+// local storage.
+layout(binding = 0, r32ui) uniform highp upixelLocalANGLE _plsCoverage;
+
+#ifdef @ATLAS_FEATHERED_FILL
+void main()
+{
+    float coverage = uintBitsToFloat(pixelLocalLoadANGLE(_plsCoverage).r);
+    coverage += eval_feathered_fill(v_coverages);
+    pixelLocalStoreANGLE(_plsCoverage, uint4(floatBitsToUint(coverage)));
+}
+#endif
+
+#ifdef @ATLAS_FEATHERED_STROKE
+void main()
+{
+    float coverage = uintBitsToFloat(pixelLocalLoadANGLE(_plsCoverage).r);
+    coverage = max(coverage, eval_feathered_stroke(v_coverages));
+    pixelLocalStoreANGLE(_plsCoverage, uint4(floatBitsToUint(coverage)));
+}
+#endif
+
+#elif defined(@ATLAS_RENDER_TARGET_R32I_ATOMIC_TEXTURE)
+
+// Store coverage as 16:16 fixed point in an r32i texture, which we manipulate
+// with atomics.
+layout(binding = 0, r32i) uniform highp coherent iimage2D _atlasImage;
+ivec2 image_coord() { return ivec2(floor(_fragCoord)); }
+int fixedpoint_coverage(float coverage)
+{
+    return int(coverage * ATLAS_R32I_FIXED_POINT_FACTOR);
+}
+
+#ifdef @ATLAS_FEATHERED_FILL
+void main()
+{
+    int coverage = fixedpoint_coverage(eval_feathered_fill(v_coverages));
+    imageAtomicAdd(_atlasImage, image_coord(), coverage);
+}
+#endif
+
+#ifdef @ATLAS_FEATHERED_STROKE
+void main()
+{
+    int coverage = fixedpoint_coverage(eval_feathered_stroke(v_coverages));
+    imageAtomicMax(_atlasImage, image_coord(), coverage);
+}
+#endif
+
+#elif defined(@ATLAS_RENDER_TARGET_RGBA8_UNORM)
+
+// We don't have any extensions to count high precision coverage. (This is very
+// rare.). Just split up coverage across rgba8 components and hope for the best.
+
+#ifdef @ATLAS_FEATHERED_FILL
+FRAG_DATA_MAIN(half4, @atlasFillFragmentMain)
+{
+    VARYING_UNPACK(v_coverages, float4);
+    half coverage = eval_feathered_fill(v_coverages TEXTURE_CONTEXT_FORWARD);
+    // i.e., is abs(coverage) ~= FEATHER(1), allowing for some sub-8-bit slop in
+    // the texture unit performing a clamp to edge.
+    if (abs(coverage) > MAX_FEATHER - 1e-3)
+    {
+        // All the "fan triangles" in a feather have solid coverage. This is a
+        // substantial number of triangles, so we dedicate 2 channels to
+        // counting solid coverage (i.e, +1 or -1). These channels are also much
+        // slower to overflow, so it preserves a basic skeleton of the feather
+        // when the fractional channels overflow.
+        EMIT_FRAG_DATA(coverage > .0
+                           // B counts integer, positive coverage.
+                           ? make_half4(.0, .0, 1. / 255., .0)
+                           // A counts integer, negative coverage.
+                           : make_half4(.0, .0, .0, 1. / 255.));
+    }
+    else
+    {
+        coverage *= 1. / ATLAS_UNORM8_COVERAGE_SCALE_FACTOR;
+        EMIT_FRAG_DATA(make_half4(
+            max(coverage, .0),  // R counts fractional, positive coverage.
+            max(-coverage, .0), // G counts fractional, negative coverage.
+            .0,
+            .0));
+    }
+}
+#endif // @ATLAS_FEATHERED_FILL
+
+#ifdef @ATLAS_FEATHERED_STROKE
+FRAG_DATA_MAIN(half4, @atlasStrokeFragmentMain)
+{
+    VARYING_UNPACK(v_coverages, float4);
+    half coverage = eval_feathered_stroke(v_coverages TEXTURE_CONTEXT_FORWARD);
+    // Strokes only have positive coverage, and since we only need to saturate
+    // the max for stroking, we can just use the R channel.
+    coverage *= 1. / ATLAS_UNORM8_COVERAGE_SCALE_FACTOR;
+    EMIT_FRAG_DATA(make_half4(coverage, .0, .0, .0));
+}
+#endif // @ATLAS_FEATHERED_STROKE
+
+#else
+
+// This is the ideal case. We have full support for floating point color
+// buffers, including blending. Render to float and let the fixed function blend
+// hardware count the coverage.
+
 #ifdef @ATLAS_FEATHERED_FILL
 FRAG_DATA_MAIN(float, @atlasFillFragmentMain)
 {
     VARYING_UNPACK(v_coverages, float4);
     EMIT_FRAG_DATA(eval_feathered_fill(v_coverages TEXTURE_CONTEXT_FORWARD));
 }
-#endif // @ATLAS_FEATHERED_FILL
+#endif
 
 #ifdef @ATLAS_FEATHERED_STROKE
 FRAG_DATA_MAIN(float, @atlasStrokeFragmentMain)
@@ -73,6 +220,8 @@
     VARYING_UNPACK(v_coverages, float4);
     EMIT_FRAG_DATA(eval_feathered_stroke(v_coverages TEXTURE_CONTEXT_FORWARD));
 }
-#endif // @ATLAS_FEATHERED_STROKE
+#endif
+
+#endif
 
 #endif // FRAGMENT
diff --git a/renderer/src/shaders/resolve_atlas.glsl b/renderer/src/shaders/resolve_atlas.glsl
new file mode 100644
index 0000000..599db9a
--- /dev/null
+++ b/renderer/src/shaders/resolve_atlas.glsl
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2025 Rive
+ */
+
+// The EXT_shader_pixel_local_storage extension does not provide a mechanism to
+// load, store, or clear pixel local storage contents. This shader provides
+// mechanisms to clear and store when rendering the atlas via PLS (i.e., when
+// floating point render targets are not supported).
+
+// TODO: We should eventually use this shader to convert the atlas from gamma to
+// linear as well, so we don't have to do that conversion for 4 different pixels
+// every time we sample and bilerp the atlas.
+
+#ifdef @VERTEX
+VERTEX_MAIN(@atlasResolveVertexMain, Attrs, attrs, _vertexID, _instanceID)
+{
+    // [-1, -1] .. [+1, +1]
+    float4 pos = float4(mix(float2(-1, 1),
+                            float2(1, -1),
+                            equal(_vertexID & int2(1, 2), int2(0))),
+                        .0,
+                        1.);
+
+    EMIT_VERTEX(pos);
+}
+#endif
+
+#ifdef @FRAGMENT
+
+#ifdef @CLEAR_COVERAGE
+__pixel_local_outEXT PLS { layout(r32f) highp float _plsCoverage; };
+#else
+__pixel_local_inEXT PLS { layout(r32f) highp float _plsCoverage; };
+layout(location = 0) out highp uvec4 _fragCoverage;
+#endif
+
+void main()
+{
+#ifdef @CLEAR_COVERAGE
+    _plsCoverage = .0;
+#else
+    _fragCoverage.r = floatBitsToUint(_plsCoverage);
+#endif
+}
+
+#endif // FRAGMENT
diff --git a/renderer/src/shaders/rhi.glsl b/renderer/src/shaders/rhi.glsl
index 217a0fa..021128d 100644
--- a/renderer/src/shaders/rhi.glsl
+++ b/renderer/src/shaders/rhi.glsl
@@ -46,6 +46,7 @@
 #define float2x2 $float2x2
 #define half3x3 $half3x3
 #define half2x3 $half2x3
+#define half4x4 $half4x4
 #endif
 
 $typedef float3 packed_float3;
diff --git a/tests/gm/atlastypes.cpp b/tests/gm/atlastypes.cpp
new file mode 100644
index 0000000..38c788e
--- /dev/null
+++ b/tests/gm/atlastypes.cpp
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2024 Rive
+ */
+
+#include "gm.hpp"
+#include "gmutils.hpp"
+
+#ifdef WITH_RIVE_TEXT
+
+#include "assets/roboto_flex.ttf.hpp"
+#include "common/testing_window.hpp"
+#include "rive/renderer/render_context.hpp"
+#include "rive/text/font_hb.hpp"
+#include "rive/text/raw_text.hpp"
+#include "common/rand.hpp"
+
+#ifndef RIVE_TOOLS_NO_GL
+#include "rive/renderer/gl/render_context_gl_impl.hpp"
+#endif
+
+using namespace rive;
+using namespace rivegm;
+
+// Validate all our atlas fallbacks by rendering feathered strokes and fills
+// with every AtlasType.
+DEF_SIMPLE_GM_WITH_CLEAR_COLOR(atlastypes, 0x80000000, 1600, 1600, renderer)
+{
+    gpu::RenderContext* renderContext = TestingWindow::Get()->renderContext();
+    gpu::RenderContext::FrameDescriptor frameDescriptor;
+    if (renderContext != nullptr)
+    {
+        frameDescriptor = renderContext->frameDescriptor();
+        frameDescriptor.loadAction = gpu::LoadAction::preserveRenderTarget;
+    }
+
+    Paint textPaint;
+    textPaint->color(0xffffffff);
+    textPaint->feather(30);
+
+    rive::RawText text(TestingWindow::Get()->factory());
+    text.maxWidth(800);
+    text.sizing(rive::TextSizing::fixed);
+    text.append("RIVE",
+                ref_rcp(textPaint.get()),
+                HBFont::Decode(assets::roboto_flex_ttf()),
+                325.0f);
+
+    Paint starPaint;
+    starPaint->color(0x80ffff80);
+    starPaint->blendMode(BlendMode::colorBurn);
+
+    Paint starStroke;
+    starStroke->color(0x80ffffff);
+    starStroke->blendMode(BlendMode::colorDodge);
+    starStroke->style(RenderPaintStyle::stroke);
+    starStroke->thickness(.05f);
+
+    for (int i = 0; i < 6; ++i)
+    {
+        if (renderContext != nullptr)
+        {
+#ifndef RIVE_TOOLS_NO_GL
+            if (gpu::RenderContextGLImpl* glImpl =
+                    TestingWindow::Get()->renderContextGLImpl())
+            {
+                TestingWindow::Get()->flushPLSContext();
+                glImpl->testingOnly_resetAtlasDesiredType(
+                    renderContext,
+                    static_cast<gpu::RenderContextGLImpl::AtlasType>(i));
+                renderContext->beginFrame(frameDescriptor);
+            }
+#endif
+        }
+
+        renderer->save();
+        renderer->translate(60, 100);
+        renderer->translate((i % 2) * 800, int(i / 2) * 500);
+
+        text.render(renderer);
+
+        Rand rando;
+        for (size_t i = 0; i < 30; ++i)
+        {
+            Path star;
+            star->fillRule(FillRule::clockwise);
+            rivegm::path_add_star(star,
+                                  rando.u32(2, 4) * 2 + 1,
+                                  rando.f32(),
+                                  1);
+            renderer->save();
+            renderer->translate(rando.f32(-50, 750), rando.f32(-50, 350));
+            float s = rando.f32(50, 100);
+            renderer->scale(s, s);
+            float f = rando.f32(.1f, .5f);
+            starPaint->feather(f);
+            starStroke->feather(f);
+            renderer->drawPath(star, starPaint);
+            renderer->drawPath(star, starStroke);
+            renderer->restore();
+        }
+        renderer->restore();
+    }
+
+    if (renderContext != nullptr)
+    {
+        // Restore the most ideal AtlasType for future tests.
+#ifndef RIVE_TOOLS_NO_GL
+        if (gpu::RenderContextGLImpl* glImpl =
+                TestingWindow::Get()->renderContextGLImpl())
+        {
+            TestingWindow::Get()->flushPLSContext();
+            glImpl->testingOnly_resetAtlasDesiredType(
+                renderContext,
+                gpu::RenderContextGLImpl::AtlasType::r32f);
+            renderContext->beginFrame(frameDescriptor);
+        }
+#endif
+    }
+}
+
+#endif
diff --git a/tests/gm/gmmain.cpp b/tests/gm/gmmain.cpp
index 8f48bf5..3f85f75 100644
--- a/tests/gm/gmmain.cpp
+++ b/tests/gm/gmmain.cpp
@@ -45,6 +45,7 @@
     MAKE_GM(feathertext_montserrat)
 
     // Add the normal (not slow) gms last.
+    MAKE_GM(atlastypes)
     MAKE_GM(batchedconvexpaths)
     MAKE_GM(batchedtriangulations)
     MAKE_GM(bevel180strokes)
diff --git a/tests/unit_tests/renderer/gpu_namespace_test.cpp b/tests/unit_tests/renderer/gpu_namespace_test.cpp
index 35179e4..a7e8977 100644
--- a/tests/unit_tests/renderer/gpu_namespace_test.cpp
+++ b/tests/unit_tests/renderer/gpu_namespace_test.cpp
@@ -42,9 +42,11 @@
 
     CHECK(gaussianTable[0] >= 0);
     CHECK(gaussianTable[0] <= expf(-.5f * FEATHER_TEXTURE_STDDEVS));
+    CHECK(gaussianTable[0] == MIN_FEATHER);
     CHECK(gaussianTable[gpu::GAUSSIAN_TABLE_SIZE - 1] <= 1);
     CHECK(gaussianTable[gpu::GAUSSIAN_TABLE_SIZE - 1] >=
           1 - expf(-.5f * FEATHER_TEXTURE_STDDEVS));
+    CHECK(gaussianTable[gpu::GAUSSIAN_TABLE_SIZE - 1] == MAX_FEATHER);
     if (gpu::GAUSSIAN_TABLE_SIZE & 1)
     {
         CHECK(gaussianTable[gpu::GAUSSIAN_TABLE_SIZE / 2] == .5f);