Implement interior triangulation for PLS

Adds a drawing mode for large paths that:

1. Subdivides the curves into sections spanning no more than 16 segments.
2. Draws the outer curves using the existing PLS algorithm.
3. Triangulates the path interior into non-overlapping triangles and draws them separately.

In addition to simply eliminating overdraw, these interior triangles:

1. Can be drawn without raster ordered synchronization (since they don't overlap).
2. Don't have to update the coverage buffer (since we know each fragment is the final one for the path at that pixel).

Both of these properties lead to savings in a PC environment that uses read/write textures for pixel local storage (45 fps -> 78 fps on the Rope.riv at 3840x2241).

Apple Silicon also benefits from these changes, even though we can't turn off raster ordering there and the coverage updates should have been much cheaper in a tiled rendering architecture (375 fps -> 525 fps on the Rope.riv at 3200x2102).

Diffs=
16c4503b8 Implement interior triangulation for PLS (#5515)

Co-authored-by: Chris Dalton <99840794+csmartdalton@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index 6875d5f..9ab8e89 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-0796b5c1cd3376619615931ffe67ad93a2005a05
+16c4503b85087934fdbb5286e0c56bac5fb5bb54
diff --git a/glad/glad_custom.c b/glad/glad_custom.c
index 0e42ec5..c6458b9 100644
--- a/glad/glad_custom.c
+++ b/glad/glad_custom.c
@@ -19,6 +19,11 @@
         glad_glDrawElementsInstancedBaseInstanceEXT = (PFNGLDRAWELEMENTSINSTANCEDBASEINSTANCEEXTPROC)load("glDrawElementsInstancedBaseInstance");
         glad_glDrawElementsInstancedBaseVertexBaseInstanceEXT = (PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXBASEINSTANCEEXTPROC)load("glDrawElementsInstancedBaseVertexBaseInstance");
     }
+
+    if (GLAD_IS_GL_VERSION_AT_LEAST(4, 6))
+    {
+        GLAD_GL_ANGLE_base_vertex_base_instance_shader_builtin = 1;
+    }
 }
 
 PFNGLFRAMEBUFFERMEMORYLESSPIXELLOCALSTORAGEANGLEPROC glad_glFramebufferMemorylessPixelLocalStorageANGLE = NULL;
@@ -33,6 +38,9 @@
 PFNGLGETFRAMEBUFFERPIXELLOCALSTORAGEPARAMETERIVANGLEPROC glad_glGetFramebufferPixelLocalStorageParameterivANGLE = NULL;
 PFNGLPOLYGONMODEANGLEPROC glad_glPolygonModeANGLE = NULL;
 PFNGLPROVOKINGVERTEXANGLEPROC glad_glProvokingVertexANGLE = NULL;
+/* #ifdef RIVE_DESKTOP_GL */
+/* #endif */
+int GLAD_GL_ANGLE_base_vertex_base_instance_shader_builtin = 0;
 int GLAD_GL_ANGLE_shader_pixel_local_storage = 0;
 int GLAD_GL_ANGLE_shader_pixel_local_storage_coherent = 0;
 int GLAD_GL_ANGLE_polygon_mode = 0;
@@ -74,6 +82,10 @@
     for (int i = 0; i < extensionCount; ++i)
     {
         const char* ext = (const char*)glGetStringi(GL_EXTENSIONS, i);
+        if (strcmp(ext, "GL_ANGLE_base_vertex_base_instance_shader_builtin") == 0)
+        {
+            GLAD_GL_ANGLE_base_vertex_base_instance_shader_builtin = 1;
+        }
         if (strcmp(ext, "GL_ANGLE_shader_pixel_local_storage") == 0)
         {
             GLAD_GL_ANGLE_shader_pixel_local_storage = 1;
diff --git a/glad/glad_custom.h b/glad/glad_custom.h
index 8bd7698..aa19b5b 100644
--- a/glad/glad_custom.h
+++ b/glad/glad_custom.h
@@ -16,6 +16,11 @@
     ((GLAD_GL_version_major << 16) | GLAD_GL_version_minor) >= (((MAJOR) << 16) | (MINOR))
 
 // Manual additions for extensions not supported in the glad tool.
+#ifndef GL_ANGLE_base_vertex_base_instance_shader_builtin
+#define GL_ANGLE_base_vertex_base_instance_shader_builtin 1
+GLAPI int GLAD_GL_ANGLE_base_vertex_base_instance_shader_builtin;
+#endif
+
 #ifndef GL_ANGLE_shader_pixel_local_storage
 #define GL_ANGLE_shader_pixel_local_storage 1
 #define GL_MAX_PIXEL_LOCAL_STORAGE_PLANES_ANGLE 0x96E0
diff --git a/include/rive/pls/buffer_ring.hpp b/include/rive/pls/buffer_ring.hpp
index 6593014..01b1b39 100644
--- a/include/rive/pls/buffer_ring.hpp
+++ b/include/rive/pls/buffer_ring.hpp
@@ -161,15 +161,13 @@
 {
 public:
     BufferRing() = default;
-    BufferRing(std::unique_ptr<BufferRingImpl> impl) : m_impl(std::move(impl))
-    {
-        assert(m_impl->itemSizeInBytes() == sizeof(T));
-    }
+    BufferRing(std::unique_ptr<BufferRingImpl> impl) { reset(std::move(impl)); }
     BufferRing(BufferRing&& other) : m_impl(std::move(other.m_impl)) {}
 
     void reset(std::unique_ptr<BufferRingImpl> impl)
     {
         assert(!mapped());
+        assert(impl->itemSizeInBytes() == sizeof(T));
         m_impl = std::move(impl);
     }
 
diff --git a/include/rive/pls/gl/gles3.hpp b/include/rive/pls/gl/gles3.hpp
index 011c357..73ee2ce 100644
--- a/include/rive/pls/gl/gles3.hpp
+++ b/include/rive/pls/gl/gles3.hpp
@@ -4,19 +4,23 @@
 
 #pragma once
 
+#include <string.h>
+
 struct GLExtensions
 {
-    bool ANGLE_shader_pixel_local_storage = false;
-    bool ANGLE_shader_pixel_local_storage_coherent = false;
-    bool ANGLE_polygon_mode = false;
-    bool ANGLE_provoking_vertex = false;
-    bool ARM_shader_framebuffer_fetch = false;
-    bool ARB_fragment_shader_interlock = false;
-    bool EXT_base_instance = false;
-    bool INTEL_fragment_shader_ordering = false;
-    bool EXT_shader_framebuffer_fetch = false;
-    bool EXT_shader_pixel_local_storage = false;
-    bool QCOM_shader_framebuffer_fetch_noncoherent = false;
+    bool ANGLE_base_vertex_base_instance_shader_builtin : 1;
+    bool ANGLE_shader_pixel_local_storage : 1;
+    bool ANGLE_shader_pixel_local_storage_coherent : 1;
+    bool ANGLE_polygon_mode : 1;
+    bool ANGLE_provoking_vertex : 1;
+    bool ARM_shader_framebuffer_fetch : 1;
+    bool ARB_fragment_shader_interlock : 1;
+    bool EXT_base_instance : 1;
+    bool INTEL_fragment_shader_ordering : 1;
+    bool EXT_shader_framebuffer_fetch : 1;
+    bool EXT_shader_pixel_local_storage : 1;
+    bool QCOM_shader_framebuffer_fetch_noncoherent : 1;
+    GLExtensions() { memset(this, 0, sizeof(*this)); }
 };
 
 #ifdef RIVE_DESKTOP_GL
@@ -63,6 +67,7 @@
 extern PFNGLDRAWELEMENTSINSTANCEDBASEINSTANCEEXTPROC glDrawElementsInstancedBaseInstanceEXT;
 extern PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXBASEINSTANCEEXTPROC
     glDrawElementsInstancedBaseVertexBaseInstanceEXT;
+extern PFNGLFRAMEBUFFERFETCHBARRIERQCOMPROC glFramebufferFetchBarrierQCOM;
 #endif
 
 #ifdef RIVE_IOS
diff --git a/include/rive/pls/gl/pls_render_context_gl.hpp b/include/rive/pls/gl/pls_render_context_gl.hpp
index 02c0158..d04ad89 100644
--- a/include/rive/pls/gl/pls_render_context_gl.hpp
+++ b/include/rive/pls/gl/pls_render_context_gl.hpp
@@ -59,11 +59,33 @@
                                                const PLSRenderTargetGL*,
                                                LoadAction,
                                                bool needsClipBuffer) = 0;
-        virtual void deactivatePixelLocalStorage() = 0;
+        virtual void deactivatePixelLocalStorage(PLSRenderContextGL*) = 0;
 
         virtual const char* shaderDefineName() const = 0;
 
+        void ensureRasterOrderingEnabled(bool enabled)
+        {
+            if (m_rasterOrderingEnabled != enabled)
+            {
+                onBarrier();
+                onEnableRasterOrdering(enabled);
+                m_rasterOrderingEnabled = enabled;
+            }
+        }
+
+        virtual void barrier()
+        {
+            assert(!m_rasterOrderingEnabled);
+            onBarrier();
+        }
+
         virtual ~PLSImpl() {}
+
+    private:
+        virtual void onEnableRasterOrdering(bool enabled) {}
+        virtual void onBarrier() {}
+
+        bool m_rasterOrderingEnabled = true;
     };
 
     class PLSImplEXTNative;
@@ -72,7 +94,7 @@
     class PLSImplRWTexture;
 
     static std::unique_ptr<PLSImpl> MakePLSImplEXTNative();
-    static std::unique_ptr<PLSImpl> MakePLSImplFramebufferFetch();
+    static std::unique_ptr<PLSImpl> MakePLSImplFramebufferFetch(GLExtensions);
     static std::unique_ptr<PLSImpl> MakePLSImplWebGL();
     static std::unique_ptr<PLSImpl> MakePLSImplRWTexture();
 
@@ -83,7 +105,7 @@
     public:
         DrawProgram(const DrawProgram&) = delete;
         DrawProgram& operator=(const DrawProgram&) = delete;
-        DrawProgram(PLSRenderContextGL* context, const ShaderFeatures& shaderFeatures);
+        DrawProgram(PLSRenderContextGL*, DrawType, const ShaderFeatures&);
         ~DrawProgram();
 
         GLuint id() const { return m_id; }
@@ -96,9 +118,7 @@
 
     class DrawShader;
 
-    PLSRenderContextGL(const PlatformFeatures&,
-                       const GLExtensions& extensions,
-                       std::unique_ptr<PLSImpl>);
+    PLSRenderContextGL(const PlatformFeatures&, GLExtensions, std::unique_ptr<PLSImpl>);
 
     const PLSRenderTargetGL* renderTarget() const
     {
@@ -128,11 +148,14 @@
                  size_t tessDataHeight,
                  bool needsClipBuffer) override;
 
+    // GL state wrapping.
+    void bindProgram(GLuint);
+    void bindVAO(GLuint);
+
     GLExtensions m_extensions;
 
     constexpr static size_t kShaderVersionStringBuffSize = sizeof("#version 300 es\n") + 1;
     char m_shaderVersionString[kShaderVersionStringBuffSize];
-    bool m_supportsBaseInstanceInShader = false;
 
     std::unique_ptr<PLSImpl> m_plsImpl;
 
@@ -148,10 +171,15 @@
     GLuint m_tessVertexTexture = 0;
 
     // Not all programs have a unique vertex shader, so we cache and reuse them where possible.
-    std::map<uint64_t, DrawShader> m_vertexShaders;
-    std::map<uint64_t, DrawProgram> m_drawPrograms;
+    std::map<uint32_t, DrawShader> m_vertexShaders;
+    std::map<uint32_t, DrawProgram> m_drawPrograms;
     GLuint m_drawVAO;
-    GLuint m_pathWedgeVertexBuffer;
-    GLuint m_pathWedgeIndexBuffer;
+    GLuint m_interiorTrianglesVAO;
+    GLuint m_patchVerticesBuffer;
+    GLuint m_patchIndicesBuffer;
+
+    // Cached GL state.
+    GLuint m_boundProgramID = 0;
+    GLuint m_boundVAO = 0;
 };
 } // namespace rive::pls
diff --git a/include/rive/pls/metal/pls_render_context_metal.h b/include/rive/pls/metal/pls_render_context_metal.h
index 17846a8..afaf3f8 100644
--- a/include/rive/pls/metal/pls_render_context_metal.h
+++ b/include/rive/pls/metal/pls_render_context_metal.h
@@ -76,9 +76,9 @@
 
     // Renders paths to the main render target.
     class DrawPipeline;
-    std::map<uint64_t, DrawPipeline> m_drawPipelines;
-    id<MTLBuffer> m_pathWedgeVertexBuffer;
-    id<MTLBuffer> m_pathWedgeIndexBuffer;
+    std::map<uint32_t, DrawPipeline> m_drawPipelines;
+    id<MTLBuffer> m_pathPatchVertexBuffer;
+    id<MTLBuffer> m_pathPatchIndexBuffer;
 
     // Locks buffer contents until the GPU has finished rendering with them. Prevents the CPU from
     // overriding data before the GPU is done with it.
diff --git a/include/rive/pls/pls.hpp b/include/rive/pls/pls.hpp
index 147494c..eea399e 100644
--- a/include/rive/pls/pls.hpp
+++ b/include/rive/pls/pls.hpp
@@ -4,6 +4,7 @@
 
 #pragma once
 
+#include "rive/math/aabb.hpp"
 #include "rive/math/mat2d.hpp"
 #include "rive/math/path_types.hpp"
 #include "rive/math/simd.hpp"
@@ -88,21 +89,32 @@
 {
 // Tells the GPU that a given vertex is the *first* tessellated vertex in its contour.
 //
-// When emitting wedges, this flag is how the GPU detects when it has crossed past the end of the
+// When emitting patches, this flag is how the GPU detects when it has crossed past the end of the
 // current contour. When this happens, the GPU knows to no not connect those two vertices, and
 // instead, either wraps back around to the beginning of the contour to close it, or else handles
 // the endcap if it's an open stroke.
 constexpr static uint32_t kFirstVertexOfContour = 1u << 31;
 
+// Tells shaders that a cubic should actually be drawn as the single, non-AA triangle: [p0, p1, p3].
+// This is used to squeeze in more rare triangles, like "breadcrumb" triangles from
+// self-intersections on interior triangulation, where it wouldn't be worth it to put them in their
+// own dedicated draw call.
+constexpr static uint32_t kRetrofittedTriangle = 1u << 30;
+
+// Tells the tessellation shader to re-run Wang's formula on the given curve, figure out how many
+// segments it actually needs, and make any excess segments degenerate by co-locating their vertices
+// at T=0. (Used on the "outerCurve" patches that are drawn with interior triangulations.)
+constexpr static uint32_t kCullExcessTessellationSegments = 1u << 29;
+
 // Flags for specifying the join type.
-constexpr static uint32_t kJoinTypeMask = 3u << 29;
-constexpr static uint32_t kMiterClipJoin = 3u << 29;   // Miter that clips when too sharp.
-constexpr static uint32_t kMiterRevertJoin = 2u << 29; // Standard miter that pops when too sharp.
-constexpr static uint32_t kBevelJoin = 1u << 29;
+constexpr static uint32_t kJoinTypeMask = 3u << 27;
+constexpr static uint32_t kMiterClipJoin = 3u << 27;   // Miter that clips when too sharp.
+constexpr static uint32_t kMiterRevertJoin = 2u << 27; // Standard miter that pops when too sharp.
+constexpr static uint32_t kBevelJoin = 1u << 27;
 
 // When a join is being used to emulate a stroke cap, the shader emits additional vertices at T=0
 // and T=1 for round joins, and changes the miter limit to 1 for miter-clip joins.
-constexpr static uint32_t kEmulatedStrokeCap = 1u << 28;
+constexpr static uint32_t kEmulatedStrokeCap = 1u << 26;
 
 RIVE_ALWAYS_INLINE static uint32_t JoinTypeFlags(StrokeJoin join)
 {
@@ -126,7 +138,7 @@
 // Tells the GPU that a given gradient is a radial gradient.
 constexpr static uint32_t kRadialGradient = 1u << 29;
 
-// Says which part of the wedge instance a vertex belongs to.
+// Says which part of the patch a vertex belongs to.
 constexpr static int32_t kStrokeVertex = 0;
 constexpr static int32_t kFanVertex = 1;
 constexpr static int32_t kFanMidpointVertex = 2;
@@ -267,7 +279,7 @@
         uint32_t localParams = static_cast<uint32_t>(blendMode);
         localParams |= clipID << 4;
         localParams |= static_cast<uint32_t>(paintType) << 20;
-        if (fillRule == FillRule::evenOdd)
+        if (fillRule == FillRule::evenOdd && strokeRadius_ == 0)
         {
             localParams |= flags::kEvenOdd;
         }
@@ -328,8 +340,20 @@
     uint32_t contourIDWithFlags; // flags | contourID
 };
 
-// Once all curves in a contour have been tessellated, we render "wedges" to connect the tessellated
-// vertices.
+// Per-vertex data for shaders that draw triangles.
+struct TriangleVertex
+{
+    TriangleVertex() = default;
+    TriangleVertex(Vec2D point_, int16_t weight, uint16_t pathID) :
+        point(point_), weight_pathID((static_cast<int32_t>(weight) << 16) | pathID)
+    {}
+    Vec2D point;
+    int32_t weight_pathID; // [(weight << 16]
+};
+static_assert(sizeof(TriangleVertex) == 3 * sizeof(float));
+
+// Once all curves in a contour have been tessellated, we render the tessellated vertices in
+// "patches" (aka specific instanced geometry).
 //
 // See:
 // https://docs.google.com/document/d/19Uk9eyFxav6dNSYsI2ZyiX9zHU1YOaJsMB2sdDFVz6s/edit#heading=h.fa4kubk3vimk
@@ -337,32 +361,68 @@
 // With strokes:
 // https://docs.google.com/document/d/1CRKihkFjbd1bwT08ErMCP4fwSR7D4gnHvgdw_esY9GM/edit#heading=h.dcd0c58pxfs5
 //
-// A single wedge instance spans "kWedgeSize" tessellation segments, and is composed of a an AA
-// border and fan triangles coming out from the midpoint in a middle-out topology.
-enum class WedgeType
+// A single patch spans N tessellation segments, connecting N + 1 tessellation vertices. It is
+// composed of a an AA border and fan triangles. The specifics of the fan triangles depend on the
+// PatchType.
+enum class PatchType
 {
-    centerStroke, // Fan vertices go the center of the stroke.
-    outerStroke   // Fan vertices are inset, and go to the inset edge of the stroke.
+    // Patches fan around the contour midpoint. Outer edges are inset by ~1px, followed by a ~1px AA
+    // ramp.
+    midpointFan,
+
+    // Patches only cover the AA ramps and interiors of bezier curves. The interior path triangles
+    // that connect the outer curves are triangulated on the CPU to eliminate overlap, and are drawn
+    // in a separate call. AA ramps are split down the middle (on the same lines as the interior
+    // triangulation), and drawn with a ~1/2px outset AA ramp and a ~1/2px inset AA ramp that
+    // overlaps the inner tessellation and has negative coverage. A lone bowtie join is emitted at
+    // the end of the patch to tie the outer curves together.
+    outerCurves,
 };
 
-struct WedgeVertex
+struct PatchVertex
 {
     float localVertexID; // 0 or 1 -- which tessellated vertex of the two that we are connecting?
     float outset;        // Outset from the tessellated position, in the direction of the normal.
     float fillCoverage;  // 0..1 for the stroke. 1 all around for the triangles.
                          // (Coverage will be negated later for counterclockwise triangles.)
-    int32_t params;      // "(wedgeSize << 2) | [flags::kStrokeVertex,
+    int32_t params;      // "(patchSize << 2) | [flags::kStrokeVertex,
                          //                      flags::kFanVertex,
                          //                      flags::kFanMidpointVertex]"
 };
 
-constexpr static int kWedgeSize = 8; // # of tessellation segments spanned by the instance.
+// # of tessellation segments spanned by the midpoint fan patch.
+constexpr static uint32_t kMidpointFanPatchSegmentSpan = 8;
 
-constexpr static int kCenterStrokeWedgeVertexCount = (kWedgeSize + 1) * 4 + 1;
-constexpr static int kCenterStrokeWedgeIndexCount = kWedgeSize * 15;
+// # of tessellation segments spanned by the outer curve patch. (In this particular instance, the
+// final segment is a bowtie join with zero length and no fan triangle.)
+constexpr static uint32_t kOuterCurvePatchSegmentSpan = 17;
 
-constexpr static int kOuterStrokeWedgeVertexCount = (kWedgeSize + 1) * 3 + 1;
-constexpr static int kOuterStrokeWedgeIndexCount = kWedgeSize * 9;
+// Define vertex and index buffers that contain all the triangles in every PatchType.
+constexpr static uint32_t kMidpointFanPatchVertexCount =
+    (kMidpointFanPatchSegmentSpan + 1) * 2 /*AA outer ramp*/ +
+    (kMidpointFanPatchSegmentSpan + 1) /*Curve fan*/ + 1 /*Triangle from path midpoint*/;
+constexpr static uint32_t kMidpointFanPatchIndexCount =
+    kMidpointFanPatchSegmentSpan * 6 /*AA outer ramp*/ +
+    (kMidpointFanPatchSegmentSpan - 1) * 3 /*Curve fan*/ + 3 /*Triangle from path midpoint*/;
+constexpr static uint32_t kMidpointFanPatchIndexOffset = 0;
+static_assert(kMidpointFanPatchIndexOffset % 4 == 0);
+constexpr static uint32_t kOuterCurvePatchVertexCount =
+    (kOuterCurvePatchSegmentSpan + 1) * 3 /*AA center ramp with bowtie*/ +
+    kOuterCurvePatchSegmentSpan /*Curve fan*/;
+constexpr static uint32_t kOuterCurvePatchIndexCount =
+    kOuterCurvePatchSegmentSpan * 12 /*AA center ramp with bowtie*/ +
+    (kOuterCurvePatchSegmentSpan - 2) * 3 /*Curve fan*/;
+constexpr static uint32_t kOuterCurvePatchIndexOffset =
+    kMidpointFanPatchIndexCount * sizeof(uint16_t);
+static_assert(kOuterCurvePatchIndexOffset % 4 == 0);
+constexpr static uint32_t kPatchVertexBufferCount =
+    kMidpointFanPatchVertexCount + kOuterCurvePatchVertexCount;
+constexpr static uint32_t kPatchIndexBufferCount =
+    kMidpointFanPatchIndexCount + kOuterCurvePatchIndexCount;
+void GeneratePatchBufferData(PatchVertex[kPatchVertexBufferCount],
+                             uint16_t indices[kPatchIndexBufferCount]);
 
-void GenerateWedgeTriangles(WedgeVertex[], uint16_t indices[], WedgeType);
+// Returns the area of the (potentially non-rectangular) quadrilateral that results from
+// transforming the given bounds by the given matrix.
+float FindTransformedArea(const AABB& bounds, const Mat2D&);
 } // namespace rive::pls
diff --git a/include/rive/pls/pls_render_context.hpp b/include/rive/pls/pls_render_context.hpp
index a3b3274..5edec76 100644
--- a/include/rive/pls/pls_render_context.hpp
+++ b/include/rive/pls/pls_render_context.hpp
@@ -14,6 +14,12 @@
 #include <functional>
 #include <unordered_map>
 
+namespace rive
+{
+class GrInnerFanTriangulator;
+class RawPath;
+} // namespace rive
+
 namespace rive::pls
 {
 class GradientLibrary;
@@ -132,6 +138,25 @@
         return m_clipContentID;
     }
 
+    // Returns the context's TrivialBlockAllocator, which is automatically reset at the end of every
+    // flush.
+    TrivialBlockAllocator* trivialPerFlushAllocator()
+    {
+        assert(m_didBeginFrame);
+        return &m_trivialPerFlushAllocator;
+    }
+
+    // Allocates a trivially destructible object that will be automatically deleted at the end of
+    // the current flush.
+    template <typename T, typename... Args> T* make(Args&&... args)
+    {
+        assert(m_didBeginFrame);
+        return m_trivialPerFlushAllocator.make<T>(std::forward<Args>(args)...);
+    }
+
+    // Returns the number of tessellation vertices that have been pushed during the current flush.
+    uint32_t currentTessVertexCount() const { return m_tessVertexCount; }
+
     // Reserves space for 'pathCount', 'contourCount', 'curveCount', and 'tessVertexCount' records
     // in their respective GPU buffers, prior to calling pushPath(), pushContour(), pushCubic().
     //
@@ -151,21 +176,27 @@
 
     // Pushes a record to the GPU for the given path, which will be referenced by future calls to
     // pushContour() and pushCubic().
-    void pushPath(const Mat2D&,
+    //
+    // The first curve of the path will be pre-padded with 'paddingVertexCount' tessellation
+    // vertices, colocated at T=0. The caller must use this argument to align the beginning of the
+    // path on a boundary of the patch size.
+    void pushPath(PatchType,
+                  const Mat2D&,
                   float strokeRadius,
                   FillRule,
                   PaintType,
                   uint32_t clipID,
                   PLSBlendMode,
-                  const PaintData&);
+                  const PaintData&,
+                  uint32_t tessVertexCount,
+                  uint32_t paddingVertexCount);
 
     // Pushes a contour record to the GPU for the given contour, which references the most-recently
     // pushed path and will be referenced by future calls to pushCubic().
     //
-    // The first curve of the contour will be pre-padded with "paddingVertexCount" tessellation
-    // vertices, colocated at T=0. This allows the caller to align wedge boundaries with contour
-    // boundaries by making the number of tessellation vertices in the contour an exact multiple of
-    // kWedgeSize.
+    // The first curve of the contour will be pre-padded with 'paddingVertexCount' tessellation
+    // vertices, colocated at T=0. The caller must use this argument to align the end of the contour
+    // on a boundary of the patch size.
     void pushContour(Vec2D midpoint, bool closed, uint32_t paddingVertexCount);
 
     // Appends a cubic curve and join to the most-recently pushed contour, and reserves the
@@ -181,11 +212,18 @@
     // "joinTangent" is the ending tangent of the join that follows the cubic.
     void pushCubic(const Vec2D pts[4],
                    Vec2D joinTangent,
-                   uint32_t joinTypeFlag,
+                   uint32_t additionalPLSFlags,
                    uint32_t parametricSegmentCount,
                    uint32_t polarSegmentCount,
                    uint32_t joinSegmentCount);
 
+    // Pushes triangles to be drawn using the data records from the most recent calls to pushPath()
+    // and pushPaint().
+    void pushInteriorTriangulation(GrInnerFanTriangulator*,
+                                   PaintType,
+                                   uint32_t clipID,
+                                   PLSBlendMode);
+
     enum class FlushType : bool
     {
         // The flush was kicked off mid-frame because GPU buffers ran out of room. The flush
@@ -247,10 +285,8 @@
     }
     const BufferRingImpl* gradSpanBufferRing() const { return m_gradSpanBuffer.impl(); }
     const BufferRingImpl* tessSpanBufferRing() { return m_tessSpanBuffer.impl(); }
-    const BufferRingImpl* uniformBufferRing() const
-    {
-        return static_cast<const BufferRingImpl*>(m_uniformBuffer.impl());
-    }
+    const BufferRingImpl* triangleBufferRing() { return m_triangleBuffer.impl(); }
+    const BufferRingImpl* uniformBufferRing() const { return m_uniformBuffer.impl(); }
 
     size_t gradTextureRowsForSimpleRamps() const { return m_gradTextureRowsForSimpleRamps; }
 
@@ -274,7 +310,7 @@
     // Indicates which "uber shader" features to enable in the draw shader.
     struct ShaderFeatures
     {
-        enum PreprocessorDefines : uint64_t
+        enum PreprocessorDefines : uint32_t
         {
             ENABLE_ADVANCED_BLEND = 1 << 0,
             ENABLE_PATH_CLIPPING = 1 << 1,
@@ -284,7 +320,7 @@
 
         // Returns a bitmask of which preprocessor macros must be defined in order to support the
         // current feature set.
-        uint64_t getPreprocessorDefines(SourceType) const;
+        uint32_t getPreprocessorDefines(SourceType) const;
 
         struct
         {
@@ -309,21 +345,71 @@
     const PlatformFeatures m_platformFeatures;
     const size_t m_maxPathID;
 
-    enum class DrawType
+    enum class DrawType : uint8_t
     {
-        pathWedges, // Standard paths and/or strokes.
+        midpointFanPatches, // Standard paths and/or strokes.
+        outerCurvePatches,  // Just the outer curves of a path; the interior will be triangulated.
+        interiorTriangulation
     };
 
+    constexpr static uint32_t PatchSegmentSpan(DrawType drawType)
+    {
+        switch (drawType)
+        {
+            case DrawType::midpointFanPatches:
+                return kMidpointFanPatchSegmentSpan;
+            case DrawType::outerCurvePatches:
+                return kOuterCurvePatchSegmentSpan;
+            default:
+                RIVE_UNREACHABLE();
+        }
+    }
+
+    constexpr static uint32_t PatchIndexCount(DrawType drawType)
+    {
+        switch (drawType)
+        {
+            case DrawType::midpointFanPatches:
+                return kMidpointFanPatchIndexCount;
+            case DrawType::outerCurvePatches:
+                return kOuterCurvePatchIndexCount;
+            default:
+                RIVE_UNREACHABLE();
+        }
+    }
+
+    constexpr static uintptr_t PatchIndexOffset(DrawType drawType)
+    {
+        switch (drawType)
+        {
+            case DrawType::midpointFanPatches:
+                return kMidpointFanPatchIndexOffset;
+            case DrawType::outerCurvePatches:
+                return kOuterCurvePatchIndexOffset;
+            default:
+                RIVE_UNREACHABLE();
+        }
+    }
+
+    constexpr static uint32_t ShaderUniqueKey(SourceType sourceType,
+                                              DrawType drawType,
+                                              const ShaderFeatures& shaderFeatures)
+    {
+        return (shaderFeatures.getPreprocessorDefines(sourceType) << 1) |
+               (drawType == DrawType::interiorTriangulation);
+    }
+
     // Linked list of draws to be issued by the subclass during onFlush().
     struct DrawList
     {
-        DrawList(DrawType drawType_, size_t baseVertex_) :
-            drawType(drawType_), baseVertex(baseVertex_)
+        DrawList(DrawType drawType_, uint32_t baseVertexOrInstance_) :
+            drawType(drawType_), baseVertexOrInstance(baseVertexOrInstance_)
         {}
         const DrawType drawType;
-        const size_t baseVertex;
-        size_t vertexCount = 0; // Calculated during PLSRenderContext::flush().
+        uint32_t baseVertexOrInstance;
+        uint32_t vertexOrInstanceCount = 0; // Calculated during PLSRenderContext::flush().
         ShaderFeatures shaderFeatures;
+        GrInnerFanTriangulator* triangulator = nullptr; // Used by "interiorTriangulation" draws.
         DrawList* next = nullptr;
     };
 
@@ -331,8 +417,10 @@
     DrawList* m_lastDraw = nullptr;
     size_t m_drawListCount = 0;
 
-    constexpr static size_t kPerFlushAllocatorInitialBlockSize = 1024 * 1024; // 1 MiB.
-    TrivialBlockAllocator m_perFlushAllocator{kPerFlushAllocatorInitialBlockSize};
+    // GrTriangulator provides an upper bound on the number of vertices it will emit. Triangulations
+    // are not writen out until the last minute, during flush(), and this variable provides an upper
+    // bound on the number of vertices that will be written.
+    size_t m_maxTriangleVertexCount = 0;
 
 private:
     static BlendTier BlendTierForBlendMode(PLSBlendMode);
@@ -342,8 +430,8 @@
     [[nodiscard]] bool pushGradient(const PLSGradient*, PaintData*);
 
     // Either appends a draw to m_drawList or merges into m_lastDraw.
-    // The caller is responsible for updating the returned ShaderFeatures to reflect its use case.
-    [[nodiscard]] ShaderFeatures* pushDraw(DrawType, size_t baseVertex);
+    // Updates the draw's ShaderFeatures according to the passed parameters.
+    void pushDraw(DrawType, size_t baseVertex, FillRule, PaintType, uint32_t clipID, PLSBlendMode);
 
     // Capacities of all our GPU resource allocations.
     struct GPUResourceLimits
@@ -355,6 +443,7 @@
         size_t maxComplexGradientSpans;
         size_t maxTessellationSpans;
         size_t maxTessellationVertices;
+        size_t maxTriangleVertices;
 
         // "*this = max(*this, other)"
         void accumulateMax(const GPUResourceLimits& other)
@@ -368,7 +457,8 @@
             maxTessellationSpans = std::max(maxTessellationSpans, other.maxTessellationSpans);
             maxTessellationVertices =
                 std::max(maxTessellationVertices, other.maxTessellationVertices);
-            static_assert(sizeof(*this) == sizeof(size_t) * 7); // Make sure we got every field.
+            maxTriangleVertices = std::max(maxTriangleVertices, other.maxTriangleVertices);
+            static_assert(sizeof(*this) == sizeof(size_t) * 8); // Make sure we got every field.
         }
 
         // Scale each limit > threshold by a factor of "scaleFactor".
@@ -393,7 +483,9 @@
             if (maxTessellationVertices > threshold.maxTessellationVertices)
                 scaled.maxTessellationVertices =
                     static_cast<double>(maxTessellationVertices) * scaleFactor;
-            static_assert(sizeof(*this) == sizeof(size_t) * 7); // Make sure we got every field.
+            if (maxTriangleVertices > threshold.maxTriangleVertices)
+                scaled.maxTriangleVertices = static_cast<double>(maxTriangleVertices) * scaleFactor;
+            static_assert(sizeof(*this) == sizeof(size_t) * 8); // Make sure we got every field.
             return scaled;
         }
 
@@ -441,6 +533,7 @@
     BufferRing<TwoTexelRamp> m_gradTexelBuffer; // Simple gradients get written by the CPU.
     BufferRing<GradientSpan> m_gradSpanBuffer;  // Complex gradients get rendered by the GPU.
     BufferRing<TessVertexSpan> m_tessSpanBuffer;
+    BufferRing<TriangleVertex> m_triangleBuffer;
     BufferRing<FlushUniforms> m_uniformBuffer;
 
     // How many rows of the gradient texture are dedicated to simple (two-texel) ramps?
@@ -462,7 +555,8 @@
     uint32_t m_currentContourID = 0;
     uint32_t m_currentContourIDWithFlags = 0;
     uint32_t m_currentContourPaddingVertexCount = 0; // Padding vertices to add to the first curve.
-    size_t m_tessVertexCount = 0;
+    uint32_t m_tessVertexCount = 0;
+    RIVE_DEBUG_CODE(uint32_t m_expectedTessVertexCountAtEndOfPath = 0;)
 
     // Simple gradients have one stop at t=0 and one stop at t=1. They're implemented with 2 texels.
     std::unordered_map<uint64_t, uint32_t> m_simpleGradients; // [color0, color1] -> rampTexelsIdx
@@ -472,5 +566,11 @@
     // the entire gradient texture width.
     std::unordered_map<GradientContentKey, uint32_t, DeepHashGradient>
         m_complexGradients; // [colors[0..n], stops[0..n]] -> rowIdx
+
+    // Simple allocator for trivially-destructible data that needs to persist until the current
+    // flush has completed. Any object created with this allocator is automatically deleted during
+    // the next call to flush().
+    constexpr static size_t kPerFlushAllocatorInitialBlockSize = 1024 * 1024; // 1 MiB.
+    TrivialBlockAllocator m_trivialPerFlushAllocator{kPerFlushAllocatorInitialBlockSize};
 };
 } // namespace rive::pls
diff --git a/include/rive/pls/pls_renderer.hpp b/include/rive/pls/pls_renderer.hpp
index 118fb1d..48df4ac 100644
--- a/include/rive/pls/pls_renderer.hpp
+++ b/include/rive/pls/pls_renderer.hpp
@@ -8,8 +8,14 @@
 #include "rive/renderer.hpp"
 #include "rive/pls/aligned_buffer.hpp"
 #include "rive/pls/fixed_queue.hpp"
+#include "rive/pls/pls.hpp"
 #include <vector>
 
+namespace rive
+{
+class GrInnerFanTriangulator;
+};
+
 namespace rive::pls
 {
 class PLSPath;
@@ -41,6 +47,8 @@
     static bool IsAABB(const RawPath&);
 
 private:
+    class InteriorTriangulationHelper;
+
     // Pushes any necessary clip updates to m_pathBatch and writes back the clipID the next path
     // should be drawn with.
     // Returns false if the operation failed, at which point the caller should flush and try again.
@@ -112,6 +120,7 @@
     {
         Mat2D matrix;
         RawPath path;
+        AABB pathBounds;
         FillRule fillRule;
         uint32_t clipID;
     };
@@ -125,14 +134,23 @@
     {
         PathDraw(const Mat2D* matrix_,
                  const RawPath* rawPath_,
+                 const AABB& pathBounds_,
                  FillRule fillRule_,
                  uint32_t clipID_) :
-            matrix(matrix_), rawPath(rawPath_), fillRule(fillRule_), clipID(clipID_)
+            matrix(matrix_),
+            rawPath(rawPath_),
+            pathBounds(pathBounds_),
+            fillRule(fillRule_),
+            clipID(clipID_)
         {}
         const Mat2D* matrix;
         const RawPath* rawPath;
+        AABB pathBounds;
         FillRule fillRule;
         uint32_t clipID;
+        GrInnerFanTriangulator* triangulator = nullptr; // Non-null if using interior triangulation.
+        uint32_t tessVertexCount = 0;
+        uint32_t paddingVertexCount = 0;
     };
     std::vector<PathDraw> m_pathBatch;
 
@@ -147,6 +165,9 @@
     AlignedBuffer<4, uint32_t> m_parametricSegmentCounts;
     AlignedBuffer<4, uint32_t> m_polarSegmentCounts;
 
+    // Used to build coarse path interiors for the "interior triangulation" algorithm.
+    RawPath m_scratchPath;
+
     // Consistency checks for pushContour.
     RIVE_DEBUG_CODE(size_t m_pushedLineCount;)
     RIVE_DEBUG_CODE(size_t m_pushedCurveCount;)
@@ -155,6 +176,5 @@
     RIVE_DEBUG_CODE(size_t m_pushedStrokeCapCount;)
     // Counts how many additional curves were pushed by pushEmulatedStrokeCapAsJoinBeforeCubic().
     RIVE_DEBUG_CODE(size_t m_pushedEmptyStrokeCountForCaps;)
-    RIVE_DEBUG_CODE(size_t m_pushedTessVertexCount;)
 };
 } // namespace rive::pls
diff --git a/path_fiddle/fiddle_context_gl.cpp b/path_fiddle/fiddle_context_gl.cpp
index 69e3789..03ebb40 100644
--- a/path_fiddle/fiddle_context_gl.cpp
+++ b/path_fiddle/fiddle_context_gl.cpp
@@ -34,6 +34,12 @@
     }
     else if (type == GL_DEBUG_TYPE_PERFORMANCE)
     {
+        if (strcmp(message,
+                   "API_ID_REDUNDANT_FBO performance warning has been generated. Redundant state "
+                   "change in glBindFramebuffer API call, FBO 0, \"\", already bound.") == 0)
+        {
+            return;
+        }
         printf("GL PERF: %s\n", message);
         fflush(stdout);
     }
diff --git a/renderer/gl/load_gles_extensions.cpp b/renderer/gl/load_gles_extensions.cpp
index eb64777..4a4eb79 100644
--- a/renderer/gl/load_gles_extensions.cpp
+++ b/renderer/gl/load_gles_extensions.cpp
@@ -10,6 +10,7 @@
 PFNGLDRAWELEMENTSINSTANCEDBASEINSTANCEEXTPROC glDrawElementsInstancedBaseInstanceEXT = nullptr;
 PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXBASEINSTANCEEXTPROC
 glDrawElementsInstancedBaseVertexBaseInstanceEXT = nullptr;
+PFNGLFRAMEBUFFERFETCHBARRIERQCOMPROC glFramebufferFetchBarrierQCOM = nullptr;
 
 void loadGLESExtensions(const GLExtensions& extensions)
 {
@@ -27,4 +28,11 @@
                 "glDrawElementsInstancedBaseVertexBaseInstanceEXT");
         loadedExtensions.EXT_base_instance = true;
     }
+    if (extensions.QCOM_shader_framebuffer_fetch_noncoherent &&
+        !loadedExtensions.QCOM_shader_framebuffer_fetch_noncoherent)
+    {
+        glFramebufferFetchBarrierQCOM = (PFNGLFRAMEBUFFERFETCHBARRIERQCOMPROC)eglGetProcAddress(
+            "glFramebufferFetchBarrierQCOM");
+        loadedExtensions.QCOM_shader_framebuffer_fetch_noncoherent = true;
+    }
 }
diff --git a/renderer/gl/pls_impl_ext_native.cpp b/renderer/gl/pls_impl_ext_native.cpp
index 61ecc52..6bb75a7 100644
--- a/renderer/gl/pls_impl_ext_native.cpp
+++ b/renderer/gl/pls_impl_ext_native.cpp
@@ -77,14 +77,8 @@
 
     ~PLSLoadStoreProgram() { glDeleteProgram(m_id); }
 
-    void bind(const float clearColor[4]) const
-    {
-        glUseProgram(m_id);
-        if (m_clearColorUniLocation >= 0)
-        {
-            glUniform4fv(m_clearColorUniLocation, 1, clearColor);
-        }
-    }
+    GLuint id() const { return m_id; }
+    GLint clearColorUniLocation() const { return m_clearColorUniLocation; }
 
 private:
     GLuint m_id;
@@ -165,21 +159,25 @@
             const PLSLoadStoreProgram& plsProgram =
                 m_plsLoadStorePrograms.try_emplace(ops, ops, m_plsLoadStoreVertexShader)
                     .first->second;
-            plsProgram.bind(clearColor4f);
-            glBindVertexArray(m_plsLoadStoreVAO);
+            context->bindProgram(plsProgram.id());
+            if (plsProgram.clearColorUniLocation() >= 0)
+            {
+                glUniform4fv(plsProgram.clearColorUniLocation(), 1, clearColor4f);
+            }
+            context->bindVAO(m_plsLoadStoreVAO);
             glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
         }
     }
 
-    void deactivatePixelLocalStorage() override
+    void deactivatePixelLocalStorage(PLSRenderContextGL* context) override
     {
         // Issue a fullscreen draw that transfers the color information in pixel local storage to
         // the main framebuffer.
         uint32_t ops = loadstoreops::kStoreColor;
         const PLSLoadStoreProgram& plsProgram =
             m_plsLoadStorePrograms.try_emplace(ops, ops, m_plsLoadStoreVertexShader).first->second;
-        plsProgram.bind(nullptr);
-        glBindVertexArray(m_plsLoadStoreVAO);
+        context->bindProgram(plsProgram.id());
+        context->bindVAO(m_plsLoadStoreVAO);
         glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
 
         glDisable(GL_SHADER_PIXEL_LOCAL_STORAGE_EXT);
diff --git a/renderer/gl/pls_impl_framebuffer_fetch.cpp b/renderer/gl/pls_impl_framebuffer_fetch.cpp
index 7314887..6796b4d 100644
--- a/renderer/gl/pls_impl_framebuffer_fetch.cpp
+++ b/renderer/gl/pls_impl_framebuffer_fetch.cpp
@@ -17,6 +17,9 @@
 
 class PLSRenderContextGL::PLSImplFramebufferFetch : public PLSRenderContextGL::PLSImpl
 {
+public:
+    PLSImplFramebufferFetch(GLExtensions extensions) : m_extensions(extensions) {}
+
     rcp<PLSRenderTargetGL> wrapGLRenderTarget(GLuint framebufferID,
                                               size_t width,
                                               size_t height,
@@ -79,7 +82,7 @@
         }
     }
 
-    void deactivatePixelLocalStorage() override
+    void deactivatePixelLocalStorage(PLSRenderContextGL*) override
     {
         // Instruct the driver not to flush PLS contents from tiled memory, with the exception of
         // the color buffer.
@@ -91,10 +94,39 @@
     }
 
     const char* shaderDefineName() const override { return GLSL_PLS_IMPL_FRAMEBUFFER_FETCH; }
+
+    void onEnableRasterOrdering(bool rasterOrderingEnabled) override
+    {
+        if (!m_extensions.QCOM_shader_framebuffer_fetch_noncoherent)
+        {
+            return;
+        }
+        if (rasterOrderingEnabled)
+        {
+            glDisable(GL_FRAMEBUFFER_FETCH_NONCOHERENT_QCOM);
+        }
+        else
+        {
+            glEnable(GL_FRAMEBUFFER_FETCH_NONCOHERENT_QCOM);
+        }
+    }
+
+    void onBarrier() override
+    {
+        if (!m_extensions.QCOM_shader_framebuffer_fetch_noncoherent)
+        {
+            return;
+        }
+        glFramebufferFetchBarrierQCOM();
+    }
+
+private:
+    const GLExtensions m_extensions;
 };
 
-std::unique_ptr<PLSRenderContextGL::PLSImpl> PLSRenderContextGL::MakePLSImplFramebufferFetch()
+std::unique_ptr<PLSRenderContextGL::PLSImpl> PLSRenderContextGL::MakePLSImplFramebufferFetch(
+    GLExtensions extensions)
 {
-    return std::make_unique<PLSImplFramebufferFetch>();
+    return std::make_unique<PLSImplFramebufferFetch>(extensions);
 }
 } // namespace rive::pls
diff --git a/renderer/gl/pls_impl_rw_texture.cpp b/renderer/gl/pls_impl_rw_texture.cpp
index b386ac0..383d240 100644
--- a/renderer/gl/pls_impl_rw_texture.cpp
+++ b/renderer/gl/pls_impl_rw_texture.cpp
@@ -96,9 +96,14 @@
         glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
     }
 
-    void deactivatePixelLocalStorage() override { glMemoryBarrier(GL_ALL_BARRIER_BITS); }
+    void deactivatePixelLocalStorage(PLSRenderContextGL*) override
+    {
+        glMemoryBarrier(GL_ALL_BARRIER_BITS);
+    }
 
     const char* shaderDefineName() const override { return GLSL_PLS_IMPL_RW_TEXTURE; }
+
+    void onBarrier() override { return glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); }
 };
 
 std::unique_ptr<PLSRenderContextGL::PLSImpl> PLSRenderContextGL::MakePLSImplRWTexture()
diff --git a/renderer/gl/pls_impl_webgl.cpp b/renderer/gl/pls_impl_webgl.cpp
index d76f26a..7a91781 100644
--- a/renderer/gl/pls_impl_webgl.cpp
+++ b/renderer/gl/pls_impl_webgl.cpp
@@ -76,7 +76,7 @@
         glBeginPixelLocalStorageWEBGL(4, loadOps);
     }
 
-    void deactivatePixelLocalStorage() override
+    void deactivatePixelLocalStorage(PLSRenderContextGL*) override
     {
         constexpr static GLenum kStoreOps[4] = {GL_STORE_OP_STORE_WEBGL,
                                                 GL_DONT_CARE,
diff --git a/renderer/gl/pls_render_context_gl.cpp b/renderer/gl/pls_render_context_gl.cpp
index d18f09b..0562f24 100644
--- a/renderer/gl/pls_render_context_gl.cpp
+++ b/renderer/gl/pls_render_context_gl.cpp
@@ -33,7 +33,7 @@
 #endif
 
 PLSRenderContextGL::PLSRenderContextGL(const PlatformFeatures& platformFeatures,
-                                       const GLExtensions& extensions,
+                                       GLExtensions extensions,
                                        std::unique_ptr<PLSImpl> plsImpl) :
     PLSRenderContext(platformFeatures), m_extensions(extensions), m_plsImpl(std::move(plsImpl))
 
@@ -48,10 +48,8 @@
                  "#version %d%d0\n",
                  GLAD_GL_version_major,
                  GLAD_GL_version_minor);
-        m_supportsBaseInstanceInShader = GLAD_IS_GL_VERSION_AT_LEAST(4, 6);
     }
 #endif
-    assert(!m_supportsBaseInstanceInShader || m_extensions.EXT_base_instance);
 
     m_colorRampProgram = glCreateProgram();
     const char* colorRampSources[] = {glsl::common, glsl::color_ramp};
@@ -75,7 +73,7 @@
                           0);
 
     glGenVertexArrays(1, &m_colorRampVAO);
-    glBindVertexArray(m_colorRampVAO);
+    bindVAO(m_colorRampVAO);
     glEnableVertexAttribArray(0);
     glVertexAttribDivisor(0, 1);
 
@@ -98,12 +96,17 @@
                                     2,
                                     m_shaderVersionString);
     glutils::LinkProgram(m_tessellateProgram);
+    bindProgram(m_tessellateProgram);
     glUniformBlockBinding(m_tessellateProgram,
                           glGetUniformBlockIndex(m_tessellateProgram, GLSL_Uniforms),
                           0);
+    glUniform1i(glGetUniformLocation(m_tessellateProgram, GLSL_pathTexture),
+                kGLTexIdxOffset + kPathTextureIdx);
+    glUniform1i(glGetUniformLocation(m_tessellateProgram, GLSL_contourTexture),
+                kGLTexIdxOffset + kContourTextureIdx);
 
     glGenVertexArrays(1, &m_tessellateVAO);
-    glBindVertexArray(m_tessellateVAO);
+    bindVAO(m_tessellateVAO);
     for (int i = 0; i < 4; ++i)
     {
         glEnableVertexAttribArray(i);
@@ -113,25 +116,29 @@
     glGenFramebuffers(1, &m_tessellateFBO);
 
     glGenVertexArrays(1, &m_drawVAO);
-    glBindVertexArray(m_drawVAO);
+    bindVAO(m_drawVAO);
 
-    WedgeVertex wedgeVertices[kOuterStrokeWedgeVertexCount];
-    uint16_t wedgeIndices[kOuterStrokeWedgeIndexCount];
-    GenerateWedgeTriangles(wedgeVertices, wedgeIndices, WedgeType::outerStroke);
+    PatchVertex patchVertices[kPatchVertexBufferCount];
+    uint16_t patchIndices[kPatchIndexBufferCount];
+    GeneratePatchBufferData(patchVertices, patchIndices);
 
-    glGenBuffers(1, &m_pathWedgeVertexBuffer);
-    glBindBuffer(GL_ARRAY_BUFFER, m_pathWedgeVertexBuffer);
-    glBufferData(GL_ARRAY_BUFFER, sizeof(wedgeVertices), wedgeVertices, GL_STATIC_DRAW);
+    glGenBuffers(1, &m_patchVerticesBuffer);
+    glBindBuffer(GL_ARRAY_BUFFER, m_patchVerticesBuffer);
+    glBufferData(GL_ARRAY_BUFFER, sizeof(patchVertices), patchVertices, GL_STATIC_DRAW);
 
-    glGenBuffers(1, &m_pathWedgeIndexBuffer);
-    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_pathWedgeIndexBuffer);
-    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(wedgeIndices), wedgeIndices, GL_STATIC_DRAW);
+    glGenBuffers(1, &m_patchIndicesBuffer);
+    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_patchIndicesBuffer);
+    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(patchIndices), patchIndices, GL_STATIC_DRAW);
 
     glEnableVertexAttribArray(0);
     glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, nullptr);
 
     glVertexAttribDivisor(3, 1);
 
+    glGenVertexArrays(1, &m_interiorTrianglesVAO);
+    bindVAO(m_interiorTrianglesVAO);
+    glEnableVertexAttribArray(0);
+
     glFrontFace(GL_CW);
 
     // ANGLE_shader_pixel_local_storage doesn't allow dither.
@@ -163,8 +170,8 @@
     glDeleteFramebuffers(1, &m_tessellateFBO);
 
     glDeleteVertexArrays(1, &m_drawVAO);
-    glDeleteBuffers(1, &m_pathWedgeVertexBuffer);
-    glDeleteBuffers(1, &m_pathWedgeIndexBuffer);
+    glDeleteBuffers(1, &m_patchVerticesBuffer);
+    glDeleteBuffers(1, &m_patchIndicesBuffer);
 }
 
 std::unique_ptr<BufferRingImpl> PLSRenderContextGL::makeVertexBufferRing(size_t capacity,
@@ -224,7 +231,10 @@
     DrawShader(const DrawShader&) = delete;
     DrawShader& operator=(const DrawShader&) = delete;
 
-    DrawShader(PLSRenderContextGL* context, GLenum shaderType, const ShaderFeatures& shaderFeatures)
+    DrawShader(PLSRenderContextGL* context,
+               GLenum shaderType,
+               DrawType drawType,
+               const ShaderFeatures& shaderFeatures)
     {
         auto sourceType =
             shaderType == GL_VERTEX_SHADER ? SourceType::vertexOnly : SourceType::wholeProgram;
@@ -232,6 +242,10 @@
         std::vector<const char*> defines;
         defines.push_back(context->m_plsImpl->shaderDefineName());
         uint64_t shaderFeatureDefines = shaderFeatures.getPreprocessorDefines(sourceType);
+        if (drawType == DrawType::interiorTriangulation)
+        {
+            defines.push_back(GLSL_DRAW_INTERIOR_TRIANGLES);
+        }
         if (shaderFeatureDefines & ShaderFeatures::PreprocessorDefines::ENABLE_ADVANCED_BLEND)
         {
             defines.push_back(GLSL_ENABLE_ADVANCED_BLEND);
@@ -248,7 +262,8 @@
         {
             defines.push_back(GLSL_ENABLE_HSL_BLEND_MODES);
         }
-        if (shaderType == GL_VERTEX_SHADER && !context->m_supportsBaseInstanceInShader)
+        if (shaderType == GL_VERTEX_SHADER &&
+            !context->m_extensions.ANGLE_base_vertex_base_instance_shader_builtin)
         {
             defines.push_back(GLSL_BASE_INSTANCE_POLYFILL);
         }
@@ -288,26 +303,27 @@
 };
 
 PLSRenderContextGL::DrawProgram::DrawProgram(PLSRenderContextGL* context,
+                                             DrawType drawType,
                                              const ShaderFeatures& shaderFeatures)
 {
     m_id = glCreateProgram();
 
     // Not every vertex shader is unique. Cache them by just the vertex features and reuse when
     // possible.
-    uint64_t vertexShaderKey = shaderFeatures.getPreprocessorDefines(SourceType::vertexOnly);
+    uint32_t vertexShaderKey = ShaderUniqueKey(SourceType::vertexOnly, drawType, shaderFeatures);
     const DrawShader& vertexShader =
         context->m_vertexShaders
-            .try_emplace(vertexShaderKey, context, GL_VERTEX_SHADER, shaderFeatures)
+            .try_emplace(vertexShaderKey, context, GL_VERTEX_SHADER, drawType, shaderFeatures)
             .first->second;
     glAttachShader(m_id, vertexShader.id());
 
     // Every fragment shader is unique.
-    DrawShader fragmentShader(context, GL_FRAGMENT_SHADER, shaderFeatures);
+    DrawShader fragmentShader(context, GL_FRAGMENT_SHADER, drawType, shaderFeatures);
     glAttachShader(m_id, fragmentShader.id());
 
     glutils::LinkProgram(m_id);
 
-    glUseProgram(m_id);
+    context->bindProgram(m_id);
     glUniformBlockBinding(m_id, glGetUniformBlockIndex(m_id, GLSL_Uniforms), 0);
     glUniform1i(glGetUniformLocation(m_id, GLSL_tessVertexTexture),
                 kGLTexIdxOffset + kTessVertexTextureIdx);
@@ -315,7 +331,7 @@
     glUniform1i(glGetUniformLocation(m_id, GLSL_contourTexture),
                 kGLTexIdxOffset + kContourTextureIdx);
     glUniform1i(glGetUniformLocation(m_id, GLSL_gradTexture), kGLTexIdxOffset + kGradTextureIdx);
-    if (!context->m_supportsBaseInstanceInShader)
+    if (!context->m_extensions.ANGLE_base_vertex_base_instance_shader_builtin)
     {
         m_baseInstancePolyfillLocation = glGetUniformLocation(m_id, GLSL_baseInstancePolyfill);
     }
@@ -348,8 +364,8 @@
     if (gradSpanCount > 0)
     {
         glBindBuffer(GL_ARRAY_BUFFER, gl_buffer_id(gradSpanBufferRing()));
-        glBindVertexArray(m_colorRampVAO);
-        glVertexAttribIPointer(0, 4, GL_UNSIGNED_INT, sizeof(GradientSpan), nullptr);
+        bindVAO(m_colorRampVAO);
+        glVertexAttribIPointer(0, 4, GL_UNSIGNED_INT, 0, nullptr);
         glViewport(0, gradTextureRowsForSimpleRamps(), kGradTextureWidth, gradSpansHeight);
         glBindFramebuffer(GL_FRAMEBUFFER, m_colorRampFBO);
         glFramebufferTexture2D(GL_FRAMEBUFFER,
@@ -357,7 +373,7 @@
                                GL_TEXTURE_2D,
                                gl_texture_id(gradTexelBufferRing()),
                                0);
-        glUseProgram(m_colorRampProgram);
+        bindProgram(m_colorRampProgram);
         glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, gradSpanCount);
     }
 
@@ -365,7 +381,7 @@
     if (tessVertexSpanCount > 0)
     {
         glBindBuffer(GL_ARRAY_BUFFER, gl_buffer_id(tessSpanBufferRing()));
-        glBindVertexArray(m_tessellateVAO);
+        bindVAO(m_tessellateVAO);
         for (int i = 0; i < 3; ++i)
         {
             glVertexAttribPointer(i,
@@ -382,7 +398,7 @@
                                reinterpret_cast<const void*>(offsetof(TessVertexSpan, x0x1)));
         glViewport(0, 0, kTessTextureWidth, tessDataHeight);
         glBindFramebuffer(GL_FRAMEBUFFER, m_tessellateFBO);
-        glUseProgram(m_tessellateProgram);
+        bindProgram(m_tessellateProgram);
         glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, tessVertexSpanCount);
     }
 
@@ -390,19 +406,28 @@
     // (ANGLE_shader_pixel_local_storage doesn't allow shader compilation while active.)
     size_t drawIdx = 0;
     auto drawPrograms = reinterpret_cast<const DrawProgram**>(
-        m_perFlushAllocator.alloc(sizeof(void*) * m_drawListCount));
+        trivialPerFlushAllocator()->alloc(sizeof(void*) * m_drawListCount));
     for (DrawList* draw = m_drawList; draw; draw = draw->next, ++drawIdx)
     {
         // Compile the draw program before activating pixel local storage.
         // Cache specific compilations of draw.glsl by ShaderFeatures.
         const ShaderFeatures& shaderFeatures = draw->shaderFeatures;
-        uint64_t fragmentShaderKey =
-            shaderFeatures.getPreprocessorDefines(SourceType::wholeProgram);
+        uint32_t fragmentShaderKey =
+            ShaderUniqueKey(SourceType::wholeProgram, draw->drawType, shaderFeatures);
         drawPrograms[drawIdx] =
-            &m_drawPrograms.try_emplace(fragmentShaderKey, this, shaderFeatures).first->second;
+            &m_drawPrograms.try_emplace(fragmentShaderKey, this, draw->drawType, shaderFeatures)
+                 .first->second;
     }
     assert(drawIdx == m_drawListCount);
 
+    // Bind the currently-submitted buffer in the triangleBufferRing to its vertex array.
+    if (m_maxTriangleVertexCount > 0)
+    {
+        bindVAO(m_interiorTrianglesVAO);
+        glBindBuffer(GL_ARRAY_BUFFER, gl_buffer_id(triangleBufferRing()));
+        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
+    }
+
     glViewport(0, 0, renderTarget()->width(), renderTarget()->height());
 
 #ifdef RIVE_DESKTOP_GL
@@ -415,41 +440,59 @@
 
     m_plsImpl->activatePixelLocalStorage(this, renderTarget(), loadAction, needsClipBuffer);
 
-    // Issue all the draws.
-    glBindVertexArray(m_drawVAO);
+    // Execute the DrawList.
     drawIdx = 0;
-    for (DrawList* draw = m_drawList; draw; draw = draw->next, ++drawIdx)
+    for (const DrawList* draw = m_drawList; draw; draw = draw->next, ++drawIdx)
     {
-        // Draw wedges connecting all tessellated vertices.
-        const DrawProgram* drawProgram = drawPrograms[drawIdx];
-        glUseProgram(drawProgram->id());
-        size_t wedgeInstanceCount = draw->vertexCount / kWedgeSize;
-        assert(wedgeInstanceCount > 0);
-        assert(wedgeInstanceCount * kWedgeSize == draw->vertexCount);
-        size_t wedgeBaseInstance = draw->baseVertex / kWedgeSize;
-        assert(wedgeBaseInstance * kWedgeSize == draw->baseVertex);
-        if (m_supportsBaseInstanceInShader)
+        if (draw->vertexOrInstanceCount == 0)
         {
-            glDrawElementsInstancedBaseInstanceEXT(GL_TRIANGLES,
-                                                   kOuterStrokeWedgeIndexCount,
-                                                   GL_UNSIGNED_SHORT,
-                                                   nullptr,
-                                                   wedgeInstanceCount,
-                                                   wedgeBaseInstance);
+            continue;
         }
-        else
+        const DrawProgram* drawProgram = drawPrograms[drawIdx];
+        bindProgram(drawProgram->id());
+        switch (DrawType drawType = draw->drawType)
         {
-            glUniform1i(drawProgram->baseInstancePolyfillLocation(), wedgeBaseInstance);
-            glDrawElementsInstanced(GL_TRIANGLES,
-                                    kOuterStrokeWedgeIndexCount,
-                                    GL_UNSIGNED_SHORT,
-                                    nullptr,
-                                    wedgeInstanceCount);
+            case DrawType::midpointFanPatches:
+            case DrawType::outerCurvePatches:
+            {
+                // Draw PLS patches that connect the tessellation vertices.
+                m_plsImpl->ensureRasterOrderingEnabled(true);
+                bindVAO(m_drawVAO);
+                uint32_t indexCount = PatchIndexCount(drawType);
+                void* indexOffset = reinterpret_cast<void*>(PatchIndexOffset(drawType));
+                if (m_extensions.ANGLE_base_vertex_base_instance_shader_builtin)
+                {
+                    glDrawElementsInstancedBaseInstanceEXT(GL_TRIANGLES,
+                                                           indexCount,
+                                                           GL_UNSIGNED_SHORT,
+                                                           indexOffset,
+                                                           draw->vertexOrInstanceCount,
+                                                           draw->baseVertexOrInstance);
+                }
+                else
+                {
+                    glUniform1i(drawProgram->baseInstancePolyfillLocation(),
+                                draw->baseVertexOrInstance);
+                    glDrawElementsInstanced(GL_TRIANGLES,
+                                            indexCount,
+                                            GL_UNSIGNED_SHORT,
+                                            indexOffset,
+                                            draw->vertexOrInstanceCount);
+                }
+                break;
+            }
+            case DrawType::interiorTriangulation:
+                // Draw generic triangles.
+                m_plsImpl->ensureRasterOrderingEnabled(false);
+                bindVAO(m_interiorTrianglesVAO);
+                glDrawArrays(GL_TRIANGLES, draw->baseVertexOrInstance, draw->vertexOrInstanceCount);
+                m_plsImpl->barrier();
+                break;
         }
     }
     assert(drawIdx == m_drawListCount);
 
-    m_plsImpl->deactivatePixelLocalStorage();
+    m_plsImpl->deactivatePixelLocalStorage(this);
 
 #ifdef RIVE_DESKTOP_GL
     if (m_extensions.ANGLE_polygon_mode && frameDescriptor().wireframe)
@@ -459,14 +502,36 @@
 #endif
 }
 
+void PLSRenderContextGL::bindProgram(GLuint programID)
+{
+    if (programID != m_boundProgramID)
+    {
+        glUseProgram(programID);
+        m_boundProgramID = programID;
+    }
+}
+
+void PLSRenderContextGL::bindVAO(GLuint vao)
+{
+    if (vao != m_boundVAO)
+    {
+        glBindVertexArray(vao);
+        m_boundVAO = vao;
+    }
+}
+
 std::unique_ptr<PLSRenderContextGL> PLSRenderContextGL::Make()
 {
-    GLExtensions extensions;
+    GLExtensions extensions{};
     GLint extensionCount;
     glGetIntegerv(GL_NUM_EXTENSIONS, &extensionCount);
     for (int i = 0; i < extensionCount; ++i)
     {
         auto* ext = reinterpret_cast<const char*>(glGetStringi(GL_EXTENSIONS, i));
+        if (strcmp(ext, "GL_ANGLE_base_vertex_base_instance_shader_builtin") == 0)
+        {
+            extensions.ANGLE_base_vertex_base_instance_shader_builtin = true;
+        }
         if (strcmp(ext, "GL_ANGLE_shader_pixel_local_storage") == 0)
         {
             extensions.ANGLE_shader_pixel_local_storage = true;
@@ -514,6 +579,10 @@
     }
 #ifdef RIVE_DESKTOP_GL
     // We implement some ES extensions with core Desktop GL in glad_custom.c.
+    if (GLAD_GL_ANGLE_base_vertex_base_instance_shader_builtin)
+    {
+        extensions.ANGLE_base_vertex_base_instance_shader_builtin = true;
+    }
     if (GLAD_GL_ANGLE_polygon_mode)
     {
         extensions.ANGLE_polygon_mode = true;
@@ -554,7 +623,9 @@
     if (extensions.EXT_shader_framebuffer_fetch)
     {
         return std::unique_ptr<PLSRenderContextGL>(
-            new PLSRenderContextGL(platformFeatures, extensions, MakePLSImplFramebufferFetch()));
+            new PLSRenderContextGL(platformFeatures,
+                                   extensions,
+                                   MakePLSImplFramebufferFetch(extensions)));
     }
 #endif
 
diff --git a/renderer/gr_inner_fan_triangulator.hpp b/renderer/gr_inner_fan_triangulator.hpp
index c4eac3d..93a3c1c 100644
--- a/renderer/gr_inner_fan_triangulator.hpp
+++ b/renderer/gr_inner_fan_triangulator.hpp
@@ -23,42 +23,45 @@
     using GrTriangulator::BreadcrumbTriangleList;
 
     GrInnerFanTriangulator(const RawPath& path,
-                           FillRule fillRule,
                            const AABB& pathBounds,
+                           FillRule fillRule,
                            TrivialBlockAllocator* alloc) :
-        GrTriangulator(path, fillRule, pathBounds, alloc)
+        GrTriangulator(pathBounds, fillRule, alloc)
     {
         fPreserveCollinearVertices = true;
         fCollectBreadcrumbTriangles = true;
-    }
-
-    int pathToTriangles(GrEagerVertexAllocator* vertexAlloc,
-                        BreadcrumbTriangleList* breadcrumbList,
-                        bool* isLinear)
-    {
-        Poly* polys = this->pathToPolys(breadcrumbList, isLinear);
-        return this->polysToTriangles(polys, vertexAlloc, breadcrumbList);
-    }
-
-    Poly* pathToPolys(BreadcrumbTriangleList* breadcrumbList, bool* isLinear)
-    {
-        auto [polys, success] = this->GrTriangulator::pathToPolys(0, AABB{}, isLinear);
-        if (!success)
+        bool isLinear;
+        auto [polys, success] = GrTriangulator::pathToPolys(path, 0, AABB{}, &isLinear);
+        if (success)
         {
-            return nullptr;
+            m_polys = polys;
+            m_maxVertexCount = countMaxTriangleVertices(m_polys);
         }
-        breadcrumbList->concat(std::move(fBreadcrumbList));
-        return polys;
     }
 
-    int polysToTriangles(Poly* polys,
-                         GrEagerVertexAllocator* vertexAlloc,
-                         BreadcrumbTriangleList* breadcrumbList) const
+    FillRule fillRule() const { return fFillRule; }
+
+    uint64_t maxVertexCount() const { return m_maxVertexCount; }
+
+    void setPathID(uint16_t pathID) { m_pathID = pathID; }
+    uint16_t pathID() const { return m_pathID; }
+
+    size_t polysToTriangles(pls::BufferRing<pls::TriangleVertex>* bufferRing) const
+
     {
-        int vertexCount = this->GrTriangulator::polysToTriangles(polys, vertexAlloc);
-        breadcrumbList->concat(std::move(fBreadcrumbList));
-        return vertexCount;
+        if (m_polys == nullptr)
+        {
+            return 0;
+        }
+        return GrTriangulator::polysToTriangles(m_polys, m_maxVertexCount, m_pathID, bufferRing);
     }
+
+    const BreadcrumbTriangleList& breadcrumbList() const { return fBreadcrumbList; }
+
+private:
+    uint16_t m_pathID = 0;
+    Poly* m_polys = nullptr;
+    uint64_t m_maxVertexCount = 0;
 };
 } // namespace rive
 
diff --git a/renderer/gr_triangulator.cpp b/renderer/gr_triangulator.cpp
index 5cba84e..0afcf63 100644
--- a/renderer/gr_triangulator.cpp
+++ b/renderer/gr_triangulator.cpp
@@ -111,44 +111,38 @@
     return fDirection == Direction::kHorizontal ? sweep_lt_horiz(a, b) : sweep_lt_vert(a, b);
 }
 
-static inline skgpu::VertexWriter emit_vertex(Vertex* v,
-                                              // bool emitCoverage,
-                                              skgpu::VertexWriter data)
+static inline void emit_vertex(Vertex* v,
+                               int winding,
+                               uint16_t pathID,
+                               pls::BufferRing<pls::TriangleVertex>* bufferRing)
 {
-    data << v->fPoint;
-
-#if 0
-    if (emitCoverage)
-    {
-        data << GrNormalizeByteToFloat(v->fAlpha);
-    }
-#endif
-
-    return data;
+    // GrTriangulator and pls unfortunately have opposite winding senses.
+    int16_t plsWeight = -winding;
+    bufferRing->emplace_back(v->fPoint, plsWeight, pathID);
 }
 
-static skgpu::VertexWriter emit_triangle(Vertex* v0,
-                                         Vertex* v1,
-                                         Vertex* v2,
-                                         // bool emitCoverage,
-                                         skgpu::VertexWriter data)
+static void emit_triangle(Vertex* v0,
+                          Vertex* v1,
+                          Vertex* v2,
+                          int winding,
+                          uint16_t pathID,
+                          pls::BufferRing<pls::TriangleVertex>* bufferRing)
 {
     TESS_LOG("emit_triangle %g (%g, %g) %d\n", v0->fID, v0->fPoint.x, v0->fPoint.y, v0->fAlpha);
     TESS_LOG("              %g (%g, %g) %d\n", v1->fID, v1->fPoint.x, v1->fPoint.y, v1->fAlpha);
     TESS_LOG("              %g (%g, %g) %d\n", v2->fID, v2->fPoint.x, v2->fPoint.y, v2->fAlpha);
 #if TESSELLATOR_WIREFRAME
-    data = emit_vertex(v0 /*, emitCoverage*/, std::move(data));
-    data = emit_vertex(v1 /*, emitCoverage*/, std::move(data));
-    data = emit_vertex(v1 /*, emitCoverage*/, std::move(data));
-    data = emit_vertex(v2 /*, emitCoverage*/, std::move(data));
-    data = emit_vertex(v2 /*, emitCoverage*/, std::move(data));
-    data = emit_vertex(v0 /*, emitCoverage*/, std::move(data));
+    emit_vertex(v0, winding, pathID, bufferRing);
+    emit_vertex(v1, winding, pathID, bufferRing);
+    emit_vertex(v1, winding, pathID, bufferRing);
+    emit_vertex(v2, winding, pathID, bufferRing);
+    emit_vertex(v2, winding, pathID, bufferRing);
+    emit_vertex(v0, winding, pathID, bufferRing);
 #else
-    data = emit_vertex(v0 /*, emitCoverage*/, std::move(data));
-    data = emit_vertex(v1 /*, emitCoverage*/, std::move(data));
-    data = emit_vertex(v2 /*, emitCoverage*/, std::move(data));
+    emit_vertex(v0, winding, pathID, bufferRing);
+    emit_vertex(v1, winding, pathID, bufferRing);
+    emit_vertex(v2, winding, pathID, bufferRing);
 #endif
-    return data;
 }
 
 void GrTriangulator::VertexList::insert(Vertex* v, Vertex* prev, Vertex* next)
@@ -413,8 +407,9 @@
     }
 }
 
-skgpu::VertexWriter GrTriangulator::emitMonotonePoly(const MonotonePoly* monotonePoly,
-                                                     skgpu::VertexWriter data) const
+void GrTriangulator::emitMonotonePoly(const MonotonePoly* monotonePoly,
+                                      uint16_t pathID,
+                                      pls::BufferRing<pls::TriangleVertex>* bufferRing) const
 {
     assert(monotonePoly->fWinding != 0);
     Edge* e = monotonePoly->fFirstEdge;
@@ -445,7 +440,7 @@
         Vertex* next = v->fNext;
         if (count == 3)
         {
-            return this->emitTriangle(prev, curr, next, monotonePoly->fWinding, std::move(data));
+            return emitTriangle(prev, curr, next, monotonePoly->fWinding, pathID, bufferRing);
         }
         double ax = static_cast<double>(curr->fPoint.x) - prev->fPoint.x;
         double ay = static_cast<double>(curr->fPoint.y) - prev->fPoint.y;
@@ -453,7 +448,7 @@
         double by = static_cast<double>(next->fPoint.y) - curr->fPoint.y;
         if (ax * by - ay * bx >= 0.0)
         {
-            data = this->emitTriangle(prev, curr, next, monotonePoly->fWinding, std::move(data));
+            emitTriangle(prev, curr, next, monotonePoly->fWinding, pathID, bufferRing);
             v->fPrev->fNext = v->fNext;
             v->fNext->fPrev = v->fPrev;
             count--;
@@ -471,14 +466,14 @@
             v = v->fNext;
         }
     }
-    return data;
 }
 
-skgpu::VertexWriter GrTriangulator::emitTriangle(Vertex* prev,
-                                                 Vertex* curr,
-                                                 Vertex* next,
-                                                 int winding,
-                                                 skgpu::VertexWriter data) const
+void GrTriangulator::emitTriangle(Vertex* prev,
+                                  Vertex* curr,
+                                  Vertex* next,
+                                  int winding,
+                                  uint16_t pathID,
+                                  pls::BufferRing<pls::TriangleVertex>* bufferRing) const
 {
     if (winding > 0)
     {
@@ -486,13 +481,7 @@
         // triangulated as a simple fan (a la red book).
         std::swap(prev, next);
     }
-    if (fCollectBreadcrumbTriangles && abs(winding) > 1 && fFillRule == FillRule::nonZero)
-    {
-        // The first winding count will come from the actual triangle we emit. The remaining counts
-        // come from the breadcrumb triangle.
-        fBreadcrumbList.append(fAlloc, prev->fPoint, curr->fPoint, next->fPoint, abs(winding) - 1);
-    }
-    return emit_triangle(prev, curr, next /*, fEmitCoverage*/, std::move(data));
+    return emit_triangle(prev, curr, next, winding, pathID, bufferRing);
 }
 
 GrTriangulator::Poly::Poly(Vertex* v, int winding) :
@@ -572,18 +561,20 @@
     }
     return poly;
 }
-skgpu::VertexWriter GrTriangulator::emitPoly(const Poly* poly, skgpu::VertexWriter data) const
+
+void GrTriangulator::emitPoly(const Poly* poly,
+                              uint16_t pathID,
+                              pls::BufferRing<pls::TriangleVertex>* bufferRing) const
 {
     if (poly->fCount < 3)
     {
-        return data;
+        return;
     }
     TESS_LOG("emit() %d, size %d\n", poly->fID, poly->fCount);
     for (MonotonePoly* m = poly->fHead; m != nullptr; m = m->fNext)
     {
-        data = this->emitMonotonePoly(m, std::move(data));
+        emitMonotonePoly(m, pathID, bufferRing);
     }
-    return data;
 }
 
 static bool coincident(const Vec2D& a, const Vec2D& b) { return a == b; }
@@ -678,7 +669,8 @@
 
 // Stage 1: convert the input path to a set of linear contours (linked list of Vertices).
 
-void GrTriangulator::pathToContours(float tolerance,
+void GrTriangulator::pathToContours(const RawPath& path,
+                                    float tolerance,
                                     const AABB& clipBounds,
                                     VertexList* contours,
                                     bool* isLinear) const
@@ -702,7 +694,7 @@
     }
     SkAutoConicToQuads converter;
 #endif
-    for (const auto [verb, pts] : fPath)
+    for (const auto [verb, pts] : path)
     {
         switch (verb)
         {
@@ -798,7 +790,7 @@
     return apply_fill_type(fFillRule, winding);
 }
 
-static inline bool apply_fill_type(FillRule fillType, Poly* poly)
+static inline bool apply_fill_type(FillRule fillType, const Poly* poly)
 {
     return poly && apply_fill_type(fillType, poly->fWinding);
 }
@@ -2079,18 +2071,18 @@
 }
 
 // Stage 6: Triangulate the monotone polygons into a vertex buffer.
-skgpu::VertexWriter GrTriangulator::polysToTriangles(Poly* polys,
-                                                     FillRule overrideFillType,
-                                                     skgpu::VertexWriter data) const
+void GrTriangulator::polysToTriangles(const Poly* polys,
+                                      FillRule overrideFillType,
+                                      uint16_t pathID,
+                                      pls::BufferRing<pls::TriangleVertex>* bufferRing) const
 {
-    for (Poly* poly = polys; poly; poly = poly->fNext)
+    for (const Poly* poly = polys; poly; poly = poly->fNext)
     {
         if (apply_fill_type(overrideFillType, poly))
         {
-            data = this->emitPoly(poly, std::move(data));
+            emitPoly(poly, pathID, bufferRing);
         }
     }
-    return data;
 }
 
 static int get_contour_count(const RawPath& path, float tolerance)
@@ -2128,11 +2120,12 @@
     return contourCnt;
 }
 
-std::tuple<Poly*, bool> GrTriangulator::pathToPolys(float tolerance,
+std::tuple<Poly*, bool> GrTriangulator::pathToPolys(const RawPath& path,
+                                                    float tolerance,
                                                     const AABB& clipBounds,
                                                     bool* isLinear)
 {
-    int contourCnt = get_contour_count(fPath, tolerance);
+    int contourCnt = get_contour_count(path, tolerance);
     if (contourCnt <= 0)
     {
         *isLinear = true;
@@ -2147,14 +2140,14 @@
 #endif
     std::unique_ptr<VertexList[]> contours(new VertexList[contourCnt]);
 
-    this->pathToContours(tolerance, clipBounds, contours.get(), isLinear);
+    this->pathToContours(path, tolerance, clipBounds, contours.get(), isLinear);
     return this->contoursToPolys(contours.get(), contourCnt);
 }
 
-int64_t GrTriangulator::CountPoints(Poly* polys, FillRule overrideFillType)
+int64_t GrTriangulator::CountPoints(const Poly* polys, FillRule overrideFillType)
 {
     int64_t count = 0;
-    for (Poly* poly = polys; poly; poly = poly->fNext)
+    for (const Poly* poly = polys; poly; poly = poly->fNext)
     {
         if (apply_fill_type(overrideFillType, poly) && poly->fCount >= 3)
         {
@@ -2166,37 +2159,35 @@
 
 // Stage 6: Triangulate the monotone polygons into a vertex buffer.
 
-int GrTriangulator::polysToTriangles(Poly* polys, GrEagerVertexAllocator* vertexAllocator) const
+size_t GrTriangulator::countMaxTriangleVertices(const Poly* polys) const
 {
-    int64_t count64 = CountPoints(polys, fFillRule);
-    if (0 == count64 || count64 > std::numeric_limits<int32_t>::max())
+    return CountPoints(polys, fFillRule);
+}
+
+size_t GrTriangulator::polysToTriangles(const Poly* polys,
+                                        uint64_t maxVertexCount,
+                                        uint16_t pathID,
+                                        pls::BufferRing<pls::TriangleVertex>* bufferRing) const
+{
+    if (0 == maxVertexCount || maxVertexCount > std::numeric_limits<int32_t>::max())
     {
         return 0;
     }
-    int count = count64;
 
-    size_t vertexStride = sizeof(Vec2D);
+    TESS_LOG("emitting %d verts\n", count);
+
+    size_t vertexStride = sizeof(pls::TriangleVertex);
 #if 0
     if (fEmitCoverage)
     {
         vertexStride += sizeof(float);
     }
 #endif
-    skgpu::VertexWriter verts = vertexAllocator->lockWriter(count64);
-    if (!verts)
-    {
-        fprintf(stderr, "Could not allocate vertices\n");
-        return 0;
-    }
 
-    TESS_LOG("emitting %d verts\n", count);
-
-    skgpu::VertexWriter::Mark start = verts.mark();
-    verts = this->polysToTriangles(polys, fFillRule, std::move(verts));
-
-    int actualCount = static_cast<int>((verts.mark() - start) / vertexStride);
-    assert(actualCount <= count);
-    vertexAllocator->unlock(actualCount);
+    size_t start = bufferRing->bytesWritten();
+    polysToTriangles(polys, fFillRule, pathID, bufferRing);
+    size_t actualCount = (bufferRing->bytesWritten() - start) / vertexStride;
+    assert(actualCount <= maxVertexCount * vertexStride);
     return actualCount;
 }
 } // namespace rive
diff --git a/renderer/gr_triangulator.hpp b/renderer/gr_triangulator.hpp
index d968bf1..b137216 100644
--- a/renderer/gr_triangulator.hpp
+++ b/renderer/gr_triangulator.hpp
@@ -17,56 +17,10 @@
 #include "rive/math/raw_path.hpp"
 #include "rive/math/vec2d.hpp"
 #include "rive/math/aabb.hpp"
+#include "rive/pls/pls.hpp"
+#include "rive/pls/buffer_ring.hpp"
 #include "rive/pls/trivial_block_allocator.hpp"
 
-namespace skgpu
-{
-class VertexWriter
-{
-public:
-    VertexWriter() = default;
-    VertexWriter(rive::Vec2D* buff, size_t buffCount) : m_buff(buff) {}
-    VertexWriter& operator<<(rive::Vec2D pt)
-    {
-        *m_buff++ = pt;
-        return *this;
-    }
-
-    operator bool() { return m_buff; }
-
-    using Mark = uintptr_t;
-    Mark mark() const { return reinterpret_cast<uintptr_t>(m_buff); }
-
-private:
-    rive::Vec2D* m_buff = nullptr;
-};
-} // namespace skgpu
-
-// This interface is used to allocate and map GPU vertex data before the exact number of required
-// vertices is known. Usage pattern:
-//
-//   1. Call lock(eagerCount) with an upper bound on the number of required vertices.
-//   2. Compute and write vertex data to the returned pointer (if not null).
-//   3. Call unlock(actualCount) and provide the actual number of vertices written during step #2.
-//
-// On step #3, the implementation will attempt to shrink the underlying GPU memory slot to fit the
-// actual vertex count.
-class GrEagerVertexAllocator
-{
-public:
-    virtual rive::Vec2D* lock(int eagerCount) = 0;
-
-    virtual void unlock(int actualCount) = 0;
-
-    virtual ~GrEagerVertexAllocator() {}
-
-    skgpu::VertexWriter lockWriter(size_t eagerCount)
-    {
-        rive::Vec2D* p = this->lock(eagerCount);
-        return p ? skgpu::VertexWriter{p, eagerCount} : skgpu::VertexWriter{};
-    }
-};
-
 namespace rive
 {
 #define TRIANGULATOR_LOGGING 0
@@ -80,29 +34,6 @@
 public:
     constexpr static int kArenaDefaultChunkSize = 16 * 1024;
 
-    static int PathToTriangles(const RawPath& path,
-                               FillRule fillRule,
-                               const AABB& pathBounds,
-                               float tolerance,
-                               const AABB& clipBounds,
-                               GrEagerVertexAllocator* vertexAllocator,
-                               bool* isLinear)
-    {
-        // if (!path.isFinite())
-        // {
-        //     return 0;
-        // }
-        TrivialBlockAllocator alloc(kArenaDefaultChunkSize);
-        GrTriangulator triangulator(path, fillRule, pathBounds, &alloc);
-        auto [polys, success] = triangulator.pathToPolys(tolerance, clipBounds, isLinear);
-        if (!success)
-        {
-            return 0;
-        }
-        int count = triangulator.polysToTriangles(polys, vertexAllocator);
-        return count;
-    }
-
     // Enums used by GrTriangulator internals.
     typedef enum
     {
@@ -127,18 +58,15 @@
     struct Comparator;
 
 protected:
-    GrTriangulator(const RawPath& path,
-                   FillRule fillRule,
-                   const AABB& pathBounds,
-                   TrivialBlockAllocator* alloc) :
-        fPath(path), fFillRule(fillRule), fPathBounds(pathBounds), fAlloc(alloc)
+    GrTriangulator(const AABB& pathBounds, FillRule fillRule, TrivialBlockAllocator* alloc) :
+        fPathBounds(pathBounds), fFillRule(fillRule), fAlloc(alloc)
     {}
-    virtual ~GrTriangulator() {}
 
     // There are six stages to the basic algorithm:
     //
     // 1) Linearize the path contours into piecewise linear segments:
-    void pathToContours(float tolerance,
+    void pathToContours(const RawPath& path,
+                        float tolerance,
                         const AABB& clipBounds,
                         VertexList* contours,
                         bool* isLinear) const;
@@ -167,9 +95,10 @@
     virtual std::tuple<Poly*, bool> tessellate(const VertexList& vertices, const Comparator&);
 
     // 6) Triangulate the monotone polygons directly into a vertex buffer:
-    skgpu::VertexWriter polysToTriangles(Poly* polys,
-                                         FillRule overrideFillRule,
-                                         skgpu::VertexWriter data) const;
+    void polysToTriangles(const Poly* polys,
+                          FillRule overrideFillRule,
+                          uint16_t pathID,
+                          pls::BufferRing<pls::TriangleVertex>*) const;
 
     // The vertex sorting in step (3) is a merge sort, since it plays well with the linked list
     // of vertices (and the necessity of inserting new vertices on intersection).
@@ -215,13 +144,16 @@
     // setting rotates 90 degrees counterclockwise, rather that transposing.
 
     // Additional helpers and driver functions.
-    skgpu::VertexWriter emitMonotonePoly(const MonotonePoly*, skgpu::VertexWriter data) const;
-    skgpu::VertexWriter emitTriangle(Vertex* prev,
-                                     Vertex* curr,
-                                     Vertex* next,
-                                     int winding,
-                                     skgpu::VertexWriter data) const;
-    skgpu::VertexWriter emitPoly(const Poly*, skgpu::VertexWriter data) const;
+    void emitMonotonePoly(const MonotonePoly*,
+                          uint16_t pathID,
+                          pls::BufferRing<pls::TriangleVertex>*) const;
+    void emitTriangle(Vertex* prev,
+                      Vertex* curr,
+                      Vertex* next,
+                      int winding,
+                      uint16_t pathID,
+                      pls::BufferRing<pls::TriangleVertex>*) const;
+    void emitPoly(const Poly*, uint16_t pathID, pls::BufferRing<pls::TriangleVertex>*) const;
 
     Poly* makePoly(Poly** head, Vertex* v, int winding) const;
     void appendPointToContour(const Vec2D& p, VertexList* contour) const;
@@ -299,15 +231,20 @@
     bool mergeCoincidentVertices(VertexList* mesh, const Comparator&) const;
     void buildEdges(VertexList* contours, int contourCnt, VertexList* mesh, const Comparator&);
     std::tuple<Poly*, bool> contoursToPolys(VertexList* contours, int contourCnt);
-    std::tuple<Poly*, bool> pathToPolys(float tolerance, const AABB& clipBounds, bool* isLinear);
-    static int64_t CountPoints(Poly* polys, FillRule overrideFillRule);
-    int polysToTriangles(Poly*, GrEagerVertexAllocator*) const;
+    std::tuple<Poly*, bool> pathToPolys(const RawPath&,
+                                        float tolerance,
+                                        const AABB& clipBounds,
+                                        bool* isLinear);
+    static int64_t CountPoints(const Poly* polys, FillRule overrideFillRule);
+    size_t countMaxTriangleVertices(const Poly*) const;
+    size_t polysToTriangles(const Poly*,
+                            uint64_t maxVertexCount,
+                            uint16_t pathID,
+                            pls::BufferRing<pls::TriangleVertex>*) const;
 
-    // FIXME: fPath should be plumbed through function parameters instead.
-    const RawPath fPath;
-    FillRule fFillRule;
     AABB fPathBounds;
-    TrivialBlockAllocator* const fAlloc;
+    FillRule fFillRule;
+    TrivialBlockAllocator* fAlloc;
     int fNumMonotonePolys = 0;
     int fNumEdges = 0;
 
diff --git a/renderer/metal/pls_render_context_metal.mm b/renderer/metal/pls_render_context_metal.mm
index 6277b8a..adccae0 100644
--- a/renderer/metal/pls_render_context_metal.mm
+++ b/renderer/metal/pls_render_context_metal.mm
@@ -75,12 +75,14 @@
 class PLSRenderContextMetal::DrawPipeline
 {
 public:
-    DrawPipeline(PLSRenderContextMetal* context, const ShaderFeatures& shaderFeatures)
+    DrawPipeline(PLSRenderContextMetal* context,
+                 DrawType drawType,
+                 const ShaderFeatures& shaderFeatures)
     {
         id<MTLFunction> vertexMain =
-            GetMainFunction(context, SourceType::vertexOnly, shaderFeatures);
+            GetMainFunction(context, drawType, SourceType::vertexOnly, shaderFeatures);
         id<MTLFunction> fragmentMain =
-            GetMainFunction(context, SourceType::wholeProgram, shaderFeatures);
+            GetMainFunction(context, drawType, SourceType::wholeProgram, shaderFeatures);
         constexpr static auto makePipelineState = [](id<MTLDevice> gpu,
                                                      id<MTLFunction> vertexMain,
                                                      id<MTLFunction> fragmentMain,
@@ -109,16 +111,24 @@
 
 private:
     id<MTLFunction> GetMainFunction(PLSRenderContextMetal* context,
+                                    DrawType drawType,
                                     SourceType sourceType,
                                     const ShaderFeatures& shaderFeatures)
     {
+        // Namespaces beginning in 'r' indicate the normal Rive renderer.
         char namespaceName[] = "r0000";
-        uint64_t shaderFeatureDefines = shaderFeatures.getPreprocessorDefines(sourceType);
+        if (drawType == DrawType::interiorTriangulation)
+        {
+            // Namespaces beginning in 't' indicate the special case when we draw non-overlapping
+            // interior triangles.
+            namespaceName[0] = 't';
+        }
         // draw.metal uses the following bits in the namespace names for each flag:
-        //     ENABLE_ADVANCED_BLEND:   r0001
-        //     ENABLE_PATH_CLIPPING:    r0010
-        //     ENABLE_EVEN_ODD:         r0100
-        //     ENABLE_HSL_BLEND_MODES:  r1000
+        //     ENABLE_ADVANCED_BLEND:   r0001 / t0001
+        //     ENABLE_PATH_CLIPPING:    r0010 / t0010
+        //     ENABLE_EVEN_ODD:         r0100 / t0100
+        //     ENABLE_HSL_BLEND_MODES:  r1000 / t1000
+        uint64_t shaderFeatureDefines = shaderFeatures.getPreprocessorDefines(sourceType);
         if (shaderFeatureDefines & ShaderFeatures::PreprocessorDefines::ENABLE_ADVANCED_BLEND)
         {
             namespaceName[4] = '1';
@@ -208,16 +218,13 @@
     m_colorRampPipeline = std::make_unique<ColorRampPipeline>(gpu, m_plsLibrary);
     m_tessPipeline = std::make_unique<TessellatePipeline>(gpu, m_plsLibrary);
 
-    // Create vertex and index buffers for the PLS "wedge".
-    WedgeVertex wedgeVertices[kOuterStrokeWedgeVertexCount];
-    uint16_t wedgeIndices[kOuterStrokeWedgeIndexCount];
-    GenerateWedgeTriangles(wedgeVertices, wedgeIndices, WedgeType::outerStroke);
-    m_pathWedgeVertexBuffer = [gpu newBufferWithBytes:wedgeVertices
-                                               length:sizeof(wedgeVertices)
+    // Create vertex and index buffers for the different PLS patches.
+    m_pathPatchVertexBuffer = [gpu newBufferWithLength:kPatchVertexBufferCount * sizeof(PatchVertex)
+                                               options:MTLResourceStorageModeShared];
+    m_pathPatchIndexBuffer = [gpu newBufferWithLength:kPatchIndexBufferCount * sizeof(uint16_t)
                                               options:MTLResourceStorageModeShared];
-    m_pathWedgeIndexBuffer = [gpu newBufferWithBytes:wedgeIndices
-                                              length:sizeof(wedgeIndices)
-                                             options:MTLResourceStorageModeShared];
+    GeneratePatchBufferData(reinterpret_cast<PatchVertex*>(m_pathPatchVertexBuffer.contents),
+                            reinterpret_cast<uint16_t*>(m_pathPatchIndexBuffer.contents));
 }
 
 PLSRenderContextMetal::~PLSRenderContextMetal() {}
@@ -355,6 +362,8 @@
         [tessEncoder setRenderPipelineState:m_tessPipeline->pipelineState()];
         [tessEncoder setVertexBuffer:mtl_buffer(uniformBufferRing()) offset:0 atIndex:0];
         [tessEncoder setVertexBuffer:mtl_buffer(tessSpanBufferRing()) offset:0 atIndex:1];
+        [tessEncoder setVertexTexture:mtl_texture(pathBufferRing()) atIndex:kPathTextureIdx];
+        [tessEncoder setVertexTexture:mtl_texture(contourBufferRing()) atIndex:kContourTextureIdx];
         [tessEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip
                         vertexStart:0
                         vertexCount:4
@@ -362,7 +371,7 @@
         [tessEncoder endEncoding];
     }
 
-    // Draw all the wedges that make up paths.
+    // Set up the render pass that draws path patches and triangles.
     MTLRenderPassDescriptor* pass = [MTLRenderPassDescriptor renderPassDescriptor];
     pass.colorAttachments[0].texture = renderTarget->targetTexture();
     if (loadAction == LoadAction::clear)
@@ -400,7 +409,6 @@
                                        0.0,
                                        1.0}];
     [encoder setVertexBuffer:mtl_buffer(uniformBufferRing()) offset:0 atIndex:0];
-    [encoder setVertexBuffer:m_pathWedgeVertexBuffer offset:0 atIndex:1];
     [encoder setVertexTexture:m_tessVertexTexture atIndex:kTessVertexTextureIdx];
     [encoder setVertexTexture:mtl_texture(pathBufferRing()) atIndex:kPathTextureIdx];
     [encoder setVertexTexture:mtl_texture(contourBufferRing()) atIndex:kContourTextureIdx];
@@ -410,31 +418,50 @@
         [encoder setTriangleFillMode:MTLTriangleFillModeLines];
     }
 
+    // Execute the DrawList.
     size_t drawIdx = 0;
-    for (DrawList* draw = m_drawList; draw; draw = draw->next, ++drawIdx)
+    for (const DrawList* draw = m_drawList; draw; draw = draw->next, ++drawIdx)
     {
-        // Cache specific compilations of draw.glsl by ShaderFeatures.
-        // TODO: Reuse vertex shader compilations where possible.
-        // TODO: Precompile these shaders and only ship bytecode.
-        uint64_t shaderKey = draw->shaderFeatures.getPreprocessorDefines(SourceType::wholeProgram);
+        if (draw->vertexOrInstanceCount == 0)
+        {
+            continue;
+        }
+
+        DrawType drawType = draw->drawType;
+
+        // Setup the pipeline for this specific drawType and shaderFeatures.
+        uint32_t pipelineKey =
+            ShaderUniqueKey(SourceType::wholeProgram, drawType, draw->shaderFeatures);
         const DrawPipeline& drawPipeline =
-            m_drawPipelines.try_emplace(shaderKey, this, draw->shaderFeatures).first->second;
-
-        size_t wedgeInstanceCount = draw->vertexCount / kWedgeSize;
-        assert(wedgeInstanceCount > 0);
-        assert(wedgeInstanceCount * kWedgeSize == draw->vertexCount);
-        size_t wedgeBaseInstance = draw->baseVertex / kWedgeSize;
-        assert(wedgeBaseInstance * kWedgeSize == draw->baseVertex);
-
+            m_drawPipelines.try_emplace(pipelineKey, this, drawType, draw->shaderFeatures)
+                .first->second;
         [encoder setRenderPipelineState:drawPipeline.pipelineState(renderTarget->pixelFormat())];
-        [encoder drawIndexedPrimitives:MTLPrimitiveTypeTriangle
-                            indexCount:kOuterStrokeWedgeIndexCount
-                             indexType:MTLIndexTypeUInt16
-                           indexBuffer:m_pathWedgeIndexBuffer
-                     indexBufferOffset:0
-                         instanceCount:wedgeInstanceCount
-                            baseVertex:0
-                          baseInstance:wedgeBaseInstance];
+
+        switch (drawType)
+        {
+            case DrawType::midpointFanPatches:
+            case DrawType::outerCurvePatches:
+            {
+                // Draw PLS patches that connect the tessellation vertices.
+                [encoder setVertexBuffer:m_pathPatchVertexBuffer offset:0 atIndex:1];
+                [encoder drawIndexedPrimitives:MTLPrimitiveTypeTriangle
+                                    indexCount:PatchIndexCount(drawType)
+                                     indexType:MTLIndexTypeUInt16
+                                   indexBuffer:m_pathPatchIndexBuffer
+                             indexBufferOffset:PatchIndexOffset(drawType)
+                                 instanceCount:draw->vertexOrInstanceCount
+                                    baseVertex:0
+                                  baseInstance:draw->baseVertexOrInstance];
+                break;
+            }
+            case DrawType::interiorTriangulation:
+                // Draw generic triangles.
+                [encoder setVertexBuffer:mtl_buffer(triangleBufferRing()) offset:0 atIndex:1];
+                [encoder drawPrimitives:MTLPrimitiveTypeTriangle
+                            vertexStart:draw->baseVertexOrInstance
+                            vertexCount:draw->vertexOrInstanceCount];
+                break;
+        }
     }
     [encoder endEncoding];
 
diff --git a/renderer/path_utils.cpp b/renderer/path_utils.cpp
index 08d39a6..ed08b00 100644
--- a/renderer/path_utils.cpp
+++ b/renderer/path_utils.cpp
@@ -102,8 +102,9 @@
 
 void ChopCubicAt(const Vec2D src[4], Vec2D dst[], const float tValues[], int tCount)
 {
-    assert(std::all_of(tValues, tValues + tCount, [](float t) { return t >= 0 && t <= 1; }));
-    assert(std::is_sorted(tValues, tValues + tCount));
+    assert(tValues == nullptr ||
+           std::all_of(tValues, tValues + tCount, [](float t) { return t >= 0 && t <= 1; }));
+    assert(tValues == nullptr || std::is_sorted(tValues, tValues + tCount));
 
     if (dst)
     {
@@ -115,14 +116,20 @@
         else
         {
             int i = 0;
+            float lastT = 0;
             for (; i < tCount - 1; i += 2)
             {
                 // Do two chops at once.
-                float2 tt = simd::load2f(tValues + i);
-                if (i != 0)
+                float2 tt;
+                if (tValues != nullptr)
                 {
-                    float lastT = tValues[i - 1];
+                    tt = simd::load2f(tValues + i);
                     tt = simd::clamp((tt - lastT) / (1 - lastT), float2(0), float2(1));
+                    lastT = tValues[i + 1];
+                }
+                else
+                {
+                    tt = float2{1, 2} / static_cast<float>(tCount + 1 - i);
                 }
                 ChopCubicAt(src, dst, tt[0], tt[1]);
                 src = dst = dst + 6;
@@ -131,13 +138,8 @@
             {
                 // Chop the final cubic if there was an odd number of chops.
                 assert(i + 1 == tCount);
-                float t = tValues[i];
-                if (i != 0)
-                {
-                    float lastT = tValues[i - 1];
-                    t = simd::clamp<float, 1>(math::ieee_float_divide(t - lastT, 1 - lastT), 0, 1)
-                            .x;
-                }
+                float t = tValues != nullptr ? tValues[i] : .5f;
+                t = simd::clamp<float, 1>(math::ieee_float_divide(t - lastT, 1 - lastT), 0, 1).x;
                 ChopCubicAt(src, dst, t);
             }
         }
diff --git a/renderer/pls.cpp b/renderer/pls.cpp
index 798d23b..27cd3de 100644
--- a/renderer/pls.cpp
+++ b/renderer/pls.cpp
@@ -6,60 +6,77 @@
 
 namespace rive::pls
 {
-constexpr static int32_t pack_params(int32_t wedgeSize, int32_t vertexType)
+constexpr static int32_t pack_params(int32_t patchSegmentSpan, int32_t vertexType)
 {
-    return (wedgeSize << 2) | vertexType;
+    return (patchSegmentSpan << 2) | vertexType;
 }
 
-void GenerateWedgeTriangles(WedgeVertex vertices[], uint16_t indices[], WedgeType wedgeType)
+static void generate_buffer_data_for_patch_type(PatchType patchType,
+                                                PatchVertex vertices[],
+                                                uint16_t indices[],
+                                                uint16_t baseVertex)
 {
-    // AA border vertices.
+    // AA border vertices. "Inner tessellation curves" have one more segment without a fan triangle
+    // whose purpose is to fill the join.
+    size_t patchSegmentSpan = patchType == PatchType::midpointFan ? kMidpointFanPatchSegmentSpan
+                                                                  : kOuterCurvePatchSegmentSpan;
     size_t vertexCount = 0;
-    for (int i = 0; i <= kWedgeSize; ++i)
+    for (int i = 0; i <= patchSegmentSpan; ++i)
     {
-        if (wedgeType == WedgeType::centerStroke)
+        if (patchType == PatchType::outerCurves)
         {
             vertices[vertexCount++] = {static_cast<float>(i),
                                        1,
                                        0,
-                                       pack_params(kWedgeSize, flags::kStrokeVertex)};
+                                       pack_params(patchSegmentSpan, flags::kStrokeVertex)};
             vertices[vertexCount++] = {static_cast<float>(i),
                                        0,
                                        .5f,
-                                       pack_params(kWedgeSize, flags::kStrokeVertex)};
+                                       pack_params(patchSegmentSpan, flags::kStrokeVertex)};
             vertices[vertexCount++] = {static_cast<float>(i),
                                        -1,
                                        0,
-                                       pack_params(kWedgeSize, flags::kStrokeVertex)};
+                                       pack_params(patchSegmentSpan, flags::kStrokeVertex)};
         }
         else
         {
+            assert(patchType == PatchType::midpointFan);
             vertices[vertexCount++] = {static_cast<float>(i),
                                        -1,
                                        1,
-                                       pack_params(kWedgeSize, flags::kStrokeVertex)};
+                                       pack_params(patchSegmentSpan, flags::kStrokeVertex)};
             vertices[vertexCount++] = {static_cast<float>(i),
                                        1,
                                        0,
-                                       pack_params(kWedgeSize, flags::kStrokeVertex)};
+                                       pack_params(patchSegmentSpan, flags::kStrokeVertex)};
         }
     }
 
-    // Triangle fan vertices.
+    // Triangle fan vertices. (These only touch the first "fanSegmentSpan" segments on inner
+    // tessellation curves.
+    size_t fanSegmentSpan =
+        patchType == PatchType::midpointFan ? patchSegmentSpan : patchSegmentSpan - 1;
+    assert((fanSegmentSpan & (fanSegmentSpan - 1)) == 0); // The fan must be a power of two.
     size_t fanVerticesIdx = vertexCount;
-    for (int i = 0; i <= kWedgeSize; ++i)
+    for (int i = 0; i <= fanSegmentSpan; ++i)
     {
         vertices[vertexCount++] = {static_cast<float>(i),
-                                   wedgeType == WedgeType::centerStroke ? 0.f : -1.f,
+                                   patchType == PatchType::outerCurves ? 0.f : -1.f,
                                    1,
-                                   pack_params(kWedgeSize, flags::kFanVertex)};
+                                   pack_params(patchSegmentSpan, flags::kFanVertex)};
     }
 
-    // Midpoint vertex.
+    // The midpoint vertex is only included on midpoint fan patches.
     size_t midpointIdx = vertexCount;
-    vertices[vertexCount++] = {0, 0, 1, pack_params(kWedgeSize, flags::kFanMidpointVertex)};
-    assert(vertexCount == (wedgeType == WedgeType::centerStroke ? kCenterStrokeWedgeVertexCount
-                                                                : kOuterStrokeWedgeVertexCount));
+    if (patchType == PatchType::midpointFan)
+    {
+        vertices[vertexCount++] = {0,
+                                   0,
+                                   1,
+                                   pack_params(patchSegmentSpan, flags::kFanMidpointVertex)};
+    }
+    assert(vertexCount == (patchType == PatchType::outerCurves ? kOuterCurvePatchVertexCount
+                                                               : kMidpointFanPatchVertexCount));
 
     // AA border indices.
     constexpr static size_t kCenterBorderPatternSize = 12;
@@ -70,32 +87,65 @@
     constexpr static uint16_t kOuterBorderPattern[kOuterBorderPatternSize] = {0, 1, 2, 2, 1, 3};
 
     size_t borderPatternSize =
-        wedgeType == WedgeType::centerStroke ? kCenterBorderPatternSize : kOuterBorderPatternSize;
+        patchType == PatchType::outerCurves ? kCenterBorderPatternSize : kOuterBorderPatternSize;
     const uint16_t* borderPattern =
-        wedgeType == WedgeType::centerStroke ? kCenterBorderPattern : kOuterBorderPattern;
-    size_t verticesPerNormal = wedgeType == WedgeType::centerStroke ? 3 : 2;
+        patchType == PatchType::outerCurves ? kCenterBorderPattern : kOuterBorderPattern;
+    size_t verticesPerNormal = patchType == PatchType::outerCurves ? 3 : 2;
     size_t indexCount = 0;
-    for (int i = 0; i < borderPatternSize * kWedgeSize; ++i)
+    for (int i = 0; i < borderPatternSize * patchSegmentSpan; ++i)
     {
-        indices[indexCount++] =
-            borderPattern[i % borderPatternSize] + i / borderPatternSize * verticesPerNormal;
+        indices[indexCount++] = borderPattern[i % borderPatternSize] +
+                                i / borderPatternSize * verticesPerNormal + baseVertex;
     }
 
     // Triangle fan indices, in a middle-out topology.
-    for (int step = 1; step < kWedgeSize; step <<= 1)
+    // Don't include the final bowtie join if this is an "outerStroke" patch. (i.e., use
+    // fanSegmentSpan and not "patchSegmentSpan".)
+    for (int step = 1; step < fanSegmentSpan; step <<= 1)
     {
-        for (int i = 0; i < kWedgeSize; i += step * 2)
+        for (int i = 0; i < fanSegmentSpan; i += step * 2)
         {
-            indices[indexCount++] = fanVerticesIdx + i;
-            indices[indexCount++] = fanVerticesIdx + i + step;
-            indices[indexCount++] = fanVerticesIdx + i + step * 2;
+            indices[indexCount++] = fanVerticesIdx + i + baseVertex;
+            indices[indexCount++] = fanVerticesIdx + i + step + baseVertex;
+            indices[indexCount++] = fanVerticesIdx + i + step * 2 + baseVertex;
         }
     }
-    // Triangle to the contour midpoint.
-    indices[indexCount++] = fanVerticesIdx;
-    indices[indexCount++] = fanVerticesIdx + kWedgeSize;
-    indices[indexCount++] = midpointIdx;
-    assert(indexCount == (wedgeType == WedgeType::centerStroke ? kCenterStrokeWedgeIndexCount
-                                                               : kOuterStrokeWedgeIndexCount));
+    if (patchType == PatchType::midpointFan)
+    {
+        // Triangle to the contour midpoint.
+        indices[indexCount++] = fanVerticesIdx + baseVertex;
+        indices[indexCount++] = fanVerticesIdx + fanSegmentSpan + baseVertex;
+        indices[indexCount++] = midpointIdx + baseVertex;
+        assert(indexCount == kMidpointFanPatchIndexCount);
+    }
+    else
+    {
+        assert(patchType == PatchType::outerCurves);
+        assert(indexCount == kOuterCurvePatchIndexCount);
+    }
+}
+
+void GeneratePatchBufferData(PatchVertex vertices[kPatchVertexBufferCount],
+                             uint16_t indices[kPatchIndexBufferCount])
+{
+    generate_buffer_data_for_patch_type(PatchType::midpointFan, vertices, indices, 0);
+    generate_buffer_data_for_patch_type(PatchType::outerCurves,
+                                        vertices + kMidpointFanPatchVertexCount,
+                                        indices + kMidpointFanPatchIndexCount,
+                                        kMidpointFanPatchVertexCount);
+}
+
+float FindTransformedArea(const AABB& bounds, const Mat2D& matrix)
+{
+    Vec2D pts[4] = {{bounds.left(), bounds.top()},
+                    {bounds.right(), bounds.top()},
+                    {bounds.right(), bounds.bottom()},
+                    {bounds.left(), bounds.bottom()}};
+    Vec2D screenSpacePts[4];
+    matrix.mapPoints(screenSpacePts, pts, 4);
+    Vec2D v[3] = {screenSpacePts[1] - screenSpacePts[0],
+                  screenSpacePts[2] - screenSpacePts[0],
+                  screenSpacePts[3] - screenSpacePts[0]};
+    return (fabsf(Vec2D::cross(v[0], v[1])) + fabsf(Vec2D::cross(v[1], v[2]))) * .5f;
 }
 } // namespace rive::pls
diff --git a/renderer/pls_path.hpp b/renderer/pls_path.hpp
index bbd4f8c..29a7556 100644
--- a/renderer/pls_path.hpp
+++ b/renderer/pls_path.hpp
@@ -16,31 +16,62 @@
     PLSPath() = default;
     PLSPath(FillRule fillRule, RawPath& rawPath) { m_rawPath.swap(rawPath); }
 
-    void rewind() override { m_rawPath.rewind(); }
+    void rewind() override
+    {
+        m_rawPath.rewind();
+        m_boundsDirty = true;
+    }
     void fillRule(FillRule rule) override { m_fillRule = rule; }
 
-    void moveTo(float x, float y) override { m_rawPath.moveTo(x, y); }
-    void lineTo(float x, float y) override { m_rawPath.lineTo(x, y); }
+    void moveTo(float x, float y) override
+    {
+        m_rawPath.moveTo(x, y);
+        m_boundsDirty = true;
+    }
+    void lineTo(float x, float y) override
+    {
+        m_rawPath.lineTo(x, y);
+        m_boundsDirty = true;
+    }
     void cubicTo(float ox, float oy, float ix, float iy, float x, float y) override
     {
         m_rawPath.cubicTo(ox, oy, ix, iy, x, y);
+        m_boundsDirty = true;
     }
-    void close() override { m_rawPath.close(); }
+    void close() override
+    {
+        m_rawPath.close();
+        m_boundsDirty = true;
+    }
 
     void addPath(CommandPath* path, const Mat2D& matrix) override
     {
         addRenderPath(path->renderPath(), matrix);
+        m_boundsDirty = true;
     }
     void addRenderPath(RenderPath* path, const Mat2D& matrix) override
     {
         m_rawPath.addPath(static_cast<PLSPath*>(path)->m_rawPath, &matrix);
+        m_boundsDirty = true;
     }
 
     const RawPath& getRawPath() const { return m_rawPath; }
     FillRule getFillRule() const { return m_fillRule; }
 
+    const AABB& getBounds()
+    {
+        if (m_boundsDirty)
+        {
+            m_bounds = m_rawPath.bounds();
+            m_boundsDirty = false;
+        }
+        return m_bounds;
+    }
+
 private:
     FillRule m_fillRule = FillRule::nonZero;
     RawPath m_rawPath;
+    AABB m_bounds;
+    bool m_boundsDirty = true;
 };
 } // namespace rive::pls
diff --git a/renderer/pls_render_context.cpp b/renderer/pls_render_context.cpp
index f85cce7..37011fc 100644
--- a/renderer/pls_render_context.cpp
+++ b/renderer/pls_render_context.cpp
@@ -4,6 +4,7 @@
 
 #include "rive/pls/pls_render_context.hpp"
 
+#include "gr_inner_fan_triangulator.hpp"
 #include "pls_path.hpp"
 #include "pls_paint.hpp"
 #include "rive/math/math_types.hpp"
@@ -18,9 +19,14 @@
 // When we exceed the capacity of a GPU resource mid-flush, double it immediately.
 constexpr static double kGPUResourceIntermediateGrowthFactor = 2;
 
-uint64_t PLSRenderContext::ShaderFeatures::getPreprocessorDefines(SourceType sourceType) const
+// The final patch of the final contour in a flush needs to see the kFirstVertexOfContour flag in
+// order to render properly, so we tessellate (and don't draw) one more empty line at the
+// end of the buffer as an "end of previous contour" marker.
+constexpr static size_t kEndOfTessMarkerVertexCount = 2;
+
+uint32_t PLSRenderContext::ShaderFeatures::getPreprocessorDefines(SourceType sourceType) const
 {
-    uint64_t defines = 0;
+    uint32_t defines = 0;
     if (programFeatures.blendTier != BlendTier::srcOver)
     {
         defines |= PreprocessorDefines::ENABLE_ADVANCED_BLEND;
@@ -336,6 +342,24 @@
     }
     COUNT_RESOURCE_SIZE(m_tessSpanBuffer.totalSizeInBytes());
 
+    // Instance buffer ring for literal triangles fed directly by the CPU.
+    constexpr size_t kMinTriangleVertices = 3072 * 3; // 324 KiB
+    // Triangle vertices don't have a maximum limit; we let the other components be the limiting
+    // factor and allocate whatever buffer size we need at flush time.
+    size_t targetTriangleVertices = std::max(targets.maxTriangleVertices, kMinTriangleVertices);
+    if (shouldReallocate(targetTriangleVertices, m_currentResourceLimits.maxTriangleVertices))
+    {
+        assert(!m_triangleBuffer.mapped());
+        m_triangleBuffer.reset(
+            makeVertexBufferRing(targetTriangleVertices, sizeof(TriangleVertex)));
+        LOG_CHANGED_SIZE("maxTriangleVertices",
+                         m_currentResourceLimits.maxTriangleVertices,
+                         targetTriangleVertices,
+                         m_triangleBuffer.totalSizeInBytes());
+        m_currentResourceLimits.maxTriangleVertices = targetTriangleVertices;
+    }
+    COUNT_RESOURCE_SIZE(m_triangleBuffer.totalSizeInBytes());
+
     // Texture that that path tessellation data is rendered into.
     size_t targetTessTextureHeight =
         std::clamp(resource_texture_height(kTessTextureWidth, targets.maxTessellationVertices),
@@ -394,7 +418,6 @@
                                        uint32_t tessVertexCount)
 {
     assert(m_didBeginFrame);
-    assert(tessVertexCount % kWedgeSize == 0);
 
     // Line breaks potentially introduce a new span. Count the maximum number of line breaks we
     // might encounter.
@@ -403,8 +426,8 @@
     size_t maxSpanBreakCount = y1 - y0;
     // +1 for our empty "end-of-contour" marker's span.
     size_t maxTessellationSpans = curveCount + maxSpanBreakCount + 1;
-    // +2 for our empty "end-of-contour" marker's vertices.
-    size_t tessVertexCountWithMarkers = tessVertexCount + 2;
+    // +kEndOfTessMarkerVertexCount for our empty "end-of-contour" marker's vertices.
+    size_t tessVertexCountWithMarkers = tessVertexCount + kEndOfTessMarkerVertexCount;
 
     // Guard against the case where a single draw overwhelms our GPU resources. Since nothing has
     // been mapped yet on the first draw, we have a unique opportunity at this time to grow our
@@ -595,15 +618,19 @@
     return true;
 }
 
-void PLSRenderContext::pushPath(const Mat2D& matrix,
+void PLSRenderContext::pushPath(PatchType patchType,
+                                const Mat2D& matrix,
                                 float strokeRadius,
                                 FillRule fillRule,
                                 PaintType paintType,
                                 uint32_t clipID,
                                 PLSBlendMode blendMode,
-                                const PaintData& paintData)
+                                const PaintData& paintData,
+                                uint32_t tessVertexCount,
+                                uint32_t paddingVertexCount)
 {
     assert(m_didBeginFrame);
+    assert(m_tessVertexCount == m_expectedTessVertexCountAtEndOfPath);
 
     m_currentPathIsStroked = strokeRadius != 0;
     m_pathBuffer.set_back(matrix, strokeRadius, fillRule, paintType, clipID, blendMode, paintData);
@@ -612,21 +639,27 @@
     assert(0 < m_currentPathID && m_currentPathID <= m_maxPathID);
     assert(m_currentPathID == m_pathBuffer.bytesWritten() / sizeof(PathData));
 
-    ShaderFeatures* shaderFeatures = pushDraw(DrawType::pathWedges, m_tessVertexCount);
-    if (blendMode > PLSBlendMode::srcOver)
-    {
-        assert(paintType != PaintType::clipReplace);
-        shaderFeatures->programFeatures.blendTier =
-            std::max(shaderFeatures->programFeatures.blendTier, BlendTierForBlendMode(blendMode));
-    }
-    if (clipID != 0)
-    {
-        shaderFeatures->programFeatures.enablePathClipping = true;
-    }
-    if (fillRule == FillRule::evenOdd)
-    {
-        shaderFeatures->fragmentFeatures.enableEvenOdd = true;
-    }
+    auto drawType = patchType == PatchType::midpointFan ? DrawType::midpointFanPatches
+                                                        : DrawType::outerCurvePatches;
+    uint32_t baseVertexToDraw = m_tessVertexCount + paddingVertexCount;
+    uint32_t patchSize = PatchSegmentSpan(drawType);
+    uint32_t baseInstance = baseVertexToDraw / patchSize;
+    // The caller is responsible to pad each path so it begins on a multiple of the patch size.
+    assert(baseInstance * patchSize == baseVertexToDraw);
+    pushDraw(drawType, baseInstance, fillRule, paintType, clipID, blendMode);
+    assert(m_lastDraw->baseVertexOrInstance + m_lastDraw->vertexOrInstanceCount == baseInstance);
+    uint32_t vertexCountToDraw = tessVertexCount - paddingVertexCount;
+    uint32_t instanceCount = vertexCountToDraw / patchSize;
+    // The caller is responsible to pad each contour so it ends on a multiple of the patch size.
+    assert(instanceCount * patchSize == vertexCountToDraw);
+    m_lastDraw->vertexOrInstanceCount += instanceCount;
+
+    // The first curve of the path will be pre-padded with 'paddingVertexCount' tessellation
+    // vertices, colocated at T=0. The caller must use this argument align the beginning of the path
+    // on a boundary of the patch size.
+    m_currentContourPaddingVertexCount = paddingVertexCount;
+
+    RIVE_DEBUG_CODE(m_expectedTessVertexCountAtEndOfPath = m_tessVertexCount + tessVertexCount);
 }
 
 void PLSRenderContext::pushContour(Vec2D midpoint, bool closed, uint32_t paddingVertexCount)
@@ -655,15 +688,14 @@
     m_currentContourIDWithFlags |= flags::kFirstVertexOfContour;
 
     // The first curve of the contour will be pre-padded with 'paddingVertexCount' tessellation
-    // vertices, colocated at T=0. This allows the caller to ensure the number of tessellation
-    // vertices in the contour is an exact multiple of kWedgeSize, ensuring that contour
-    // boundaries also fall on wedge boundaries.
-    m_currentContourPaddingVertexCount = paddingVertexCount;
+    // vertices, colocated at T=0. The caller must use this argument align the end of the contour on
+    // a boundary of the patch size.
+    m_currentContourPaddingVertexCount += paddingVertexCount;
 }
 
 void PLSRenderContext::pushCubic(const Vec2D pts[4],
                                  Vec2D joinTangent,
-                                 uint32_t joinTypeFlag,
+                                 uint32_t additionalPLSFlags,
                                  uint32_t parametricSegmentCount,
                                  uint32_t polarSegmentCount,
                                  uint32_t joinSegmentCount)
@@ -693,7 +725,7 @@
                                   parametricSegmentCount,
                                   polarSegmentCount,
                                   joinSegmentCount,
-                                  m_currentContourIDWithFlags | joinTypeFlag);
+                                  m_currentContourIDWithFlags | additionalPLSFlags);
         if (x1 > kTessTextureWidth)
         {
             // The span was too long to fit on the current line. Wrap and draw it again, this
@@ -719,15 +751,33 @@
     assert(m_tessVertexCount <= m_currentResourceLimits.maxTessellationVertices);
 }
 
-PLSRenderContext::ShaderFeatures* PLSRenderContext::pushDraw(DrawType drawType, size_t baseVertex)
+void PLSRenderContext::pushInteriorTriangulation(GrInnerFanTriangulator* triangulator,
+                                                 PaintType paintType,
+                                                 uint32_t clipID,
+                                                 PLSBlendMode blendMode)
 {
-    if (m_lastDraw && m_lastDraw->drawType == drawType)
+    pushDraw(DrawType::interiorTriangulation,
+             0,
+             triangulator->fillRule(),
+             paintType,
+             clipID,
+             blendMode);
+    m_maxTriangleVertexCount += triangulator->maxVertexCount();
+    triangulator->setPathID(m_currentPathID);
+    m_lastDraw->triangulator = triangulator;
+}
+
+void PLSRenderContext::pushDraw(DrawType drawType,
+                                size_t baseVertex,
+                                FillRule fillRule,
+                                PaintType paintType,
+                                uint32_t clipID,
+                                PLSBlendMode blendMode)
+{
+    if (!m_lastDraw || m_lastDraw->drawType != drawType)
     {
-        // Merge with the previous draw.
-    }
-    else
-    {
-        DrawList* nextDraw = m_perFlushAllocator.make<DrawList>(drawType, baseVertex);
+        // Can't merge with the previous draw. Push a new one.
+        DrawList* nextDraw = make<DrawList>(drawType, baseVertex);
         if (!m_lastDraw)
         {
             m_drawList = nextDraw;
@@ -739,7 +789,21 @@
         m_lastDraw = nextDraw;
         ++m_drawListCount;
     }
-    return &m_lastDraw->shaderFeatures;
+    ShaderFeatures* shaderFeatures = &m_lastDraw->shaderFeatures;
+    if (blendMode > PLSBlendMode::srcOver)
+    {
+        assert(paintType != PaintType::clipReplace);
+        shaderFeatures->programFeatures.blendTier =
+            std::max(shaderFeatures->programFeatures.blendTier, BlendTierForBlendMode(blendMode));
+    }
+    if (clipID != 0)
+    {
+        shaderFeatures->programFeatures.enablePathClipping = true;
+    }
+    if (fillRule == FillRule::evenOdd)
+    {
+        shaderFeatures->fragmentFeatures.enableEvenOdd = true;
+    }
 }
 
 template <typename T> bool bits_equal(const T* a, const T* b)
@@ -750,16 +814,17 @@
 void PLSRenderContext::flush(FlushType flushType)
 {
     assert(m_didBeginFrame);
+    assert(m_tessVertexCount == m_expectedTessVertexCountAtEndOfPath);
 
     // The first tessellated vertex in every contour gets the kFirstVertexOfContour flag, and when
-    // emitting wedges, this flag is how the GPU detects when it has crossed past the end of the
+    // emitting patches, this flag is how the GPU detects when it has crossed past the end of the
     // current contour. When this happens, the GPU knows to no not connect those two vertices, and
     // instead, either wraps back around to the beginning of the contour to close it, or else
     // handles the endcap if it's an open stroke.
     //
-    // The final wedge of the final contour in our buffer also needs to see that
-    // kFirstVertexOfContour flag in order to render properly, so so we tessellate (and don't draw)
-    // one more empty line at the end of the buffer as an end-of-previous-contour marker.
+    // The final patch of the final contour in our buffer also needs to see that
+    // kFirstVertexOfContour flag in order to render properly, so we tessellate (and don't draw) one
+    // more empty line at the end of the buffer as an end-of-previous-contour marker.
     //
     // TODO: This is a little kludgey. Maybe we can find a cleaner way to accomplish this?
     if (!m_tessSpanBuffer.empty())
@@ -767,50 +832,72 @@
         Vec2D emptyLine[4]{};
         // Make sure this empty line gets the kFirstVertexOfContour flag.
         m_currentContourIDWithFlags = ~0;
+        assert(m_currentContourPaddingVertexCount == 0);
+        RIVE_DEBUG_CODE(size_t startingVertexCount = m_tessVertexCount;)
         this->pushCubic(emptyLine, Vec2D{}, 0, 1, 1, 1);
+        assert(m_tessVertexCount == startingVertexCount + kEndOfTessMarkerVertexCount);
     }
 
-    // Determine how much to draw.
-    size_t gradSpanCount = m_gradSpanBuffer.bytesWritten() / sizeof(GradientSpan);
-    size_t tessVertexSpanCount = m_tessSpanBuffer.bytesWritten() / sizeof(TessVertexSpan);
-    size_t tessDataHeight = resource_texture_height(kTessTextureWidth, m_tessVertexCount);
+    if (m_maxTriangleVertexCount > 0)
+    {
+        // Since we don't generate the triangle buffer until flush time, we can resize it now if it
+        // isn't large enough.
+        // TODO: More resources can be handled this way, e.g., the tessellation texture.
+        if (m_triangleBuffer.capacity() < m_maxTriangleVertexCount)
+        {
+            GPUResourceLimits newLimitsForTriangles{};
+            newLimitsForTriangles.maxTriangleVertices = m_maxTriangleVertexCount;
+            growExceededGPUResources(newLimitsForTriangles, kGPUResourcePadding);
+        }
+        m_triangleBuffer.ensureMapped();
+        assert(m_triangleBuffer.hasRoomFor(m_maxTriangleVertexCount));
+    }
 
-    // Write out our DrawList to the GPU DrawParameters buffer and calculate the vertex count for
-    // each draw.
+    // Finish calculating our DrawList.
     bool needsClipBuffer = false;
     RIVE_DEBUG_CODE(size_t drawIdx = 0;)
-    DrawList* lastPathWedgeDraw = nullptr;
+    size_t writtenTriangleVertexCount = 0;
     for (DrawList* draw = m_drawList; draw; draw = draw->next)
     {
         switch (draw->drawType)
         {
-            case DrawType::pathWedges:
-                if (lastPathWedgeDraw)
-                {
-                    lastPathWedgeDraw->vertexCount =
-                        draw->baseVertex - lastPathWedgeDraw->baseVertex;
-                }
-                lastPathWedgeDraw = draw;
+            case DrawType::midpointFanPatches:
+            case DrawType::outerCurvePatches:
                 break;
+            case DrawType::interiorTriangulation:
+            {
+                size_t maxVertexCount = draw->triangulator->maxVertexCount();
+                assert(writtenTriangleVertexCount + maxVertexCount <= m_maxTriangleVertexCount);
+                size_t actualVertexCount = maxVertexCount;
+                if (maxVertexCount > 0)
+                {
+                    actualVertexCount = draw->triangulator->polysToTriangles(&m_triangleBuffer);
+                }
+                assert(actualVertexCount <= maxVertexCount);
+                draw->baseVertexOrInstance = writtenTriangleVertexCount;
+                draw->vertexOrInstanceCount = actualVertexCount;
+                writtenTriangleVertexCount += actualVertexCount;
+                break;
+            }
         }
         needsClipBuffer =
             needsClipBuffer || draw->shaderFeatures.programFeatures.enablePathClipping;
         RIVE_DEBUG_CODE(++drawIdx;)
     }
-    if (lastPathWedgeDraw)
-    {
-        // Don't draw the empty "end-of-contour" marker's vertices.
-        size_t endVertexToDraw = std::max<size_t>(m_tessVertexCount, 2) - 2;
-        lastPathWedgeDraw->vertexCount = endVertexToDraw - lastPathWedgeDraw->baseVertex;
-    }
     assert(drawIdx == m_drawListCount);
 
+    // Determine how much to draw.
+    size_t gradSpanCount = m_gradSpanBuffer.bytesWritten() / sizeof(GradientSpan);
+    size_t tessVertexSpanCount = m_tessSpanBuffer.bytesWritten() / sizeof(TessVertexSpan);
+    size_t tessDataHeight = resource_texture_height(kTessTextureWidth, m_tessVertexCount);
+
     // Upload all non-empty buffers before flushing.
     m_pathBuffer.submit();
     m_contourBuffer.submit();
     m_gradTexelBuffer.submit();
     m_gradSpanBuffer.submit();
     m_tessSpanBuffer.submit();
+    m_triangleBuffer.submit();
 
     // Update the uniform buffer for drawing if needed.
     FlushUniforms uniformData(m_complexGradients.size(),
@@ -842,8 +929,13 @@
     m_currentFrameResourceUsage.maxComplexGradientSpans += gradSpanCount;
     m_currentFrameResourceUsage.maxTessellationSpans += tessVertexSpanCount;
     m_currentFrameResourceUsage.maxTessellationVertices += m_tessVertexCount;
+    // Since we can defer allocating the triangle buffer until flush time, when we know exactly how
+    // many vertices it will need, we don't need to proactively count all the flushes in the frame.
+    // A simple max() will suffice.
+    m_currentFrameResourceUsage.maxTriangleVertices =
+        std::max(m_currentFrameResourceUsage.maxTriangleVertices, m_maxTriangleVertexCount);
     static_assert(sizeof(m_currentFrameResourceUsage) ==
-                  sizeof(size_t) * 7); // Make sure we got every field.
+                  sizeof(size_t) * 8); // Make sure we got every field.
 
     if (flushType == FlushType::intermediate)
     {
@@ -871,11 +963,16 @@
     m_complexGradients.clear();
 
     m_tessVertexCount = 0;
+    RIVE_DEBUG_CODE(m_expectedTessVertexCountAtEndOfPath = 0);
+
+    m_maxTriangleVertexCount = 0;
 
     m_isFirstFlushOfFrame = false;
 
     m_drawList = m_lastDraw = nullptr;
     m_drawListCount = 0;
-    m_perFlushAllocator.reset();
+
+    // Delete all objects that were allocted for this flush using the TrivialBlockAllocator.
+    m_trivialPerFlushAllocator.reset();
 }
 } // namespace rive::pls
diff --git a/renderer/pls_renderer.cpp b/renderer/pls_renderer.cpp
index 2687c93..cdb39d5 100644
--- a/renderer/pls_renderer.cpp
+++ b/renderer/pls_renderer.cpp
@@ -4,6 +4,7 @@
 
 #include "rive/pls/pls_renderer.hpp"
 
+#include "gr_inner_fan_triangulator.hpp"
 #include "path_utils.hpp"
 #include "pls_paint.hpp"
 #include "pls_path.hpp"
@@ -74,7 +75,11 @@
     if (m_context->getClipContentID() != clip.clipID)
     {
         // The clip buffer does not contain the current clip stack. Update it.
-        m_pathBatch.emplace_back(&clip.matrix, &clip.path, clip.fillRule, clip.clipID);
+        m_pathBatch.emplace_back(&clip.matrix,
+                                 &clip.path,
+                                 clip.pathBounds,
+                                 clip.fillRule,
+                                 clip.clipID);
         m_context->setClipContentID(clip.clipID);
     }
     assert(clip.clipID != 0);
@@ -110,6 +115,7 @@
         }
         m_pathBatch.emplace_back(&m_stack.back().matrix,
                                  &path->getRawPath(),
+                                 path->getBounds(),
                                  path->getFillRule(),
                                  clipID);
         if (!pushInternalPathBatch(paint))
@@ -133,7 +139,8 @@
     {
         m_hasArtboardClipCandidate = IsAABB(path->getRawPath());
     }
-    m_clipStack.push_back({m_stack.back().matrix, path->getRawPath(), path->getFillRule(), 0});
+    m_clipStack.push_back(
+        {m_stack.back().matrix, path->getRawPath(), path->getBounds(), path->getFillRule(), 0});
 }
 
 void PLSRenderer::drawImage(const RenderImage*, BlendMode, float opacity) {}
@@ -200,16 +207,21 @@
 RIVE_ALWAYS_INLINE constexpr uint8_t cusp_chop_key(uint8_t n) { return chop_key(true, n); }
 RIVE_ALWAYS_INLINE constexpr uint8_t simple_chop_key(uint8_t n) { return chop_key(false, n); }
 
-// Produces a cubic equivalent to the given line. Since we will not be running Wang's formula on
-// this cubic, we can just duplicate the endpoints (which produces a flat line whose segmenting by
-// Wang's count is > 1).
-RIVE_ALWAYS_INLINE std::array<Vec2D, 4> convert_line_to_cubic(Vec2D p0, Vec2D p1)
-{
-    return {p0, p0, p1, p1};
-}
+// Produces a cubic equivalent to the given line, for which Wang's formula also returns 1.
 RIVE_ALWAYS_INLINE std::array<Vec2D, 4> convert_line_to_cubic(const Vec2D line[2])
 {
-    return convert_line_to_cubic(line[0], line[1]);
+    float4 endPts = simd::load4f(line);
+    float4 controlPts = simd::mix(endPts, endPts.zwxy, float4(1 / 3.f));
+    std::array<Vec2D, 4> cubic;
+    cubic[0] = line[0];
+    simd::store(&cubic[1], controlPts);
+    cubic[3] = line[1];
+    return cubic;
+}
+RIVE_ALWAYS_INLINE std::array<Vec2D, 4> convert_line_to_cubic(Vec2D p0, Vec2D p1)
+{
+    Vec2D line[2] = {p0, p1};
+    return convert_line_to_cubic(line);
 }
 
 // Finds the tangents of the curve at T=0 and T=1 respectively.
@@ -391,8 +403,236 @@
 {
     return iter.rawVerbsPtr() + 1 == end.rawVerbsPtr();
 }
+
+// Returns the smallest number that can be added to 'value', such that 'value % alignment' == 0.
+RIVE_ALWAYS_INLINE uint32_t padding_to_align_up(uint32_t value, uint32_t alignment)
+{
+    uint32_t maxMultipleOfAlignment = std::numeric_limits<uint32_t>::max() / alignment * alignment;
+    uint32_t padding = (maxMultipleOfAlignment - value) % alignment;
+    assert((value + padding) % alignment == 0);
+    return padding;
+}
 } // namespace
 
+// Helps count required resources for, and submit data to the render context that will be used to
+// render paths with the "interior triangulation" algorithm.
+class PLSRenderer::InteriorTriangulationHelper
+{
+public:
+    size_t contourCount() const { return m_contourCount; }
+    size_t patchCount() const { return m_patchCount; }
+    bool empty() const { return m_patchCount == 0; }
+
+    enum class PathOp : bool
+    {
+        countDataAndTriangulate,
+        submitOuterCubics
+    };
+
+    // For now, we just iterate and subdivide the path twice (once for each enum in PathOp). Since
+    // we only do this for large paths, and since we're triangulating the path interior anyway,
+    // adding complexity to only run Wang's formula and chop once would save about ~5% of the total
+    // CPU time. (And large paths are GPU-bound anyway.)
+    void processPath(PathOp op,
+                     PLSRenderContext* context,
+                     PathDraw* path,
+                     RawPath* scratchPath = nullptr)
+    {
+        Vec2D chops[kMaxCurveSubdivisions * 3 + 1];
+        const RawPath& rawPath = *path->rawPath;
+        assert(!rawPath.empty());
+        wangs_formula::VectorXform vectorXform(*path->matrix);
+        size_t patchCount = 0;
+        size_t contourCount = 0;
+        Vec2D p0 = {0, 0};
+        if (op == PathOp::countDataAndTriangulate)
+        {
+            scratchPath->rewind();
+        }
+        for (const auto [verb, pts] : rawPath)
+        {
+            switch (verb)
+            {
+                case PathVerb::move:
+                    if (contourCount != 0 && pts[-1] != p0)
+                    {
+                        if (op == PathOp::submitOuterCubics)
+                        {
+                            context->pushCubic(convert_line_to_cubic(pts[-1], p0).data(),
+                                               {0, 0},
+                                               flags::kCullExcessTessellationSegments,
+                                               kPatchSegmentCountExcludingJoin,
+                                               1,
+                                               kJoinSegmentCount);
+                        }
+                        ++patchCount;
+                    }
+                    if (op == PathOp::countDataAndTriangulate)
+                    {
+                        scratchPath->move(pts[0]);
+                    }
+                    else
+                    {
+                        context->pushContour({0, 0}, true, 0);
+                    }
+                    p0 = pts[0];
+                    ++contourCount;
+                    break;
+                case PathVerb::line:
+                    if (op == PathOp::countDataAndTriangulate)
+                    {
+                        scratchPath->line(pts[1]);
+                    }
+                    else
+                    {
+                        context->pushCubic(convert_line_to_cubic(pts).data(),
+                                           {0, 0},
+                                           flags::kCullExcessTessellationSegments,
+                                           kPatchSegmentCountExcludingJoin,
+                                           1,
+                                           kJoinSegmentCount);
+                    }
+                    ++patchCount;
+                    break;
+                case PathVerb::quad:
+                    RIVE_UNREACHABLE();
+                case PathVerb::cubic:
+                {
+                    size_t numSubdivisions = FindSubdivisionCount(pts, vectorXform);
+                    if (numSubdivisions == 1)
+                    {
+                        if (op == PathOp::countDataAndTriangulate)
+                        {
+                            scratchPath->line(pts[3]);
+                        }
+                        else
+                        {
+                            context->pushCubic(pts,
+                                               {0, 0},
+                                               flags::kCullExcessTessellationSegments,
+                                               kPatchSegmentCountExcludingJoin,
+                                               1,
+                                               kJoinSegmentCount);
+                        }
+                    }
+                    else
+                    {
+                        // Passing nullptr for the 'tValues' causes it to chop the cubic uniformly
+                        // in T.
+                        pathutils::ChopCubicAt(pts, chops, nullptr, numSubdivisions - 1);
+                        const Vec2D* chop = chops;
+                        for (size_t i = 0; i < numSubdivisions; ++i)
+                        {
+                            if (op == PathOp::countDataAndTriangulate)
+                            {
+                                scratchPath->line(chop[3]);
+                            }
+                            else
+                            {
+                                context->pushCubic(chop,
+                                                   {0, 0},
+                                                   flags::kCullExcessTessellationSegments,
+                                                   kPatchSegmentCountExcludingJoin,
+                                                   1,
+                                                   kJoinSegmentCount);
+                            }
+                            chop += 3;
+                        }
+                    }
+                    patchCount += numSubdivisions;
+                    break;
+                }
+                case PathVerb::close:
+                    break;
+            }
+        }
+        Vec2D lastPt = rawPath.points().back();
+        if (contourCount != 0 && lastPt != p0)
+        {
+            if (op == PathOp::submitOuterCubics)
+            {
+                context->pushCubic(convert_line_to_cubic(lastPt, p0).data(),
+                                   {0, 0},
+                                   flags::kCullExcessTessellationSegments,
+                                   kPatchSegmentCountExcludingJoin,
+                                   1,
+                                   kJoinSegmentCount);
+            }
+            ++patchCount;
+        }
+
+        if (op == PathOp::countDataAndTriangulate)
+        {
+            assert(!path->triangulator);
+            path->triangulator =
+                context->make<GrInnerFanTriangulator>(*scratchPath,
+                                                      path->pathBounds,
+                                                      path->fillRule,
+                                                      context->trivialPerFlushAllocator());
+            // We also draw each "breadcrumb" triangle using an outerCubic patch.
+            patchCount += path->triangulator->breadcrumbList().count();
+            path->tessVertexCount = patchCount * kOuterCurvePatchSegmentSpan;
+            m_contourCount += contourCount;
+            m_patchCount += patchCount;
+        }
+        else
+        {
+            // Submit breadcrumb triangles, emulated by outerCubic patches.
+            for (auto* node = path->triangulator->breadcrumbList().head(); node; node = node->fNext)
+            {
+                Vec2D triangleAsCubic[4] = {node->fPts[0], node->fPts[1], {0, 0}, node->fPts[2]};
+                context->pushCubic(triangleAsCubic,
+                                   {0, 0},
+                                   flags::kRetrofittedTriangle,
+                                   kPatchSegmentCountExcludingJoin,
+                                   1,
+                                   kJoinSegmentCount);
+                ++patchCount;
+            }
+            assert(path->paddingVertexCount + patchCount * kOuterCurvePatchSegmentSpan ==
+                   path->tessVertexCount);
+            RIVE_DEBUG_CODE(m_writtenContourCount += contourCount;)
+            RIVE_DEBUG_CODE(m_writtenPatchCount += patchCount;)
+            RIVE_DEBUG_CODE(m_writtenTessVertexCount +=
+                            patchCount * (kPatchSegmentCountExcludingJoin + kJoinSegmentCount);)
+        }
+    }
+
+#ifdef DEBUG
+    bool didSubmitAllData()
+    {
+        return m_writtenContourCount == m_contourCount && m_writtenPatchCount == m_patchCount &&
+               m_writtenTessVertexCount == m_patchCount * kOuterCurvePatchSegmentSpan;
+    }
+#endif
+
+private:
+    // The final segment in an outerCurve patch is a bowtie join.
+    constexpr static size_t kJoinSegmentCount = 1;
+    constexpr static size_t kPatchSegmentCountExcludingJoin =
+        kOuterCurvePatchSegmentSpan - kJoinSegmentCount;
+
+    // Maximum # of outerCurve patches a curve on the path can be subdivided into.
+    constexpr static size_t kMaxCurveSubdivisions =
+        (kMaxParametricSegments + kPatchSegmentCountExcludingJoin - 1) /
+        kPatchSegmentCountExcludingJoin;
+
+    static size_t FindSubdivisionCount(const Vec2D pts[],
+                                       const wangs_formula::VectorXform& vectorXform)
+    {
+        size_t numSubdivisions =
+            ceilf(wangs_formula::cubic(pts, kParametricPrecision, vectorXform) *
+                  (1.f / kPatchSegmentCountExcludingJoin));
+        return std::clamp<size_t>(numSubdivisions, 1, kMaxCurveSubdivisions);
+    }
+
+    size_t m_contourCount = 0;
+    size_t m_patchCount = 0;
+    RIVE_DEBUG_CODE(size_t m_writtenContourCount = 0;)
+    RIVE_DEBUG_CODE(size_t m_writtenPatchCount = 0;)
+    RIVE_DEBUG_CODE(size_t m_writtenTessVertexCount = 0;)
+};
+
 bool PLSRenderer::pushInternalPathBatch(PLSPaint* finalPathPaint)
 {
     // Only the final path in the batch uses 'finalPathPaint', which may or may not be stroked.
@@ -409,7 +649,7 @@
     PLSPaint clipPaint;
     for (size_t i = 0; i < m_pathBatch.size(); ++i)
     {
-        const auto& [matrix, rawPath, fillRule, clipID] = m_pathBatch[i];
+        const RawPath* rawPath = m_pathBatch[i].rawPath;
         if (rawPath->empty())
         {
             continue;
@@ -465,6 +705,8 @@
             std::max(maxStrokedCurvesAfterChops + 3, m_polarSegmentCounts.capacity()));
     }
 
+    InteriorTriangulationHelper interiorTriHelper;
+
     // Iteration pass 1: Collect information on contour and curves counts for every path in the
     // batch, and begin counting tessellated vertices.
     m_contourBatch.clear();
@@ -473,17 +715,33 @@
     size_t rotationCount = 0; // We measure rotations on both curves and round joins.
     for (size_t i = 0; i < m_pathBatch.size(); ++i)
     {
-        const auto& [matrix, rawPath, fillRule, clipID] = m_pathBatch[i];
-        if (rawPath->empty())
+        PathDraw& path = m_pathBatch[i];
+        if (path.rawPath->empty())
         {
             continue;
         }
 
+        // Draw this path using "interior triangulation" if it is a fill, and is sufficiently large.
+        assert(path.triangulator == nullptr);
+        if (i != strokeIdx) // Never use interior triangulation for strokes.
+        {
+            float screenSpaceArea = FindTransformedArea(path.pathBounds, *path.matrix);
+            if (screenSpaceArea > 512 * 512)
+            {
+                interiorTriHelper.processPath(
+                    InteriorTriangulationHelper::PathOp::countDataAndTriangulate,
+                    m_context,
+                    &path,
+                    &m_scratchPath);
+                continue;
+            }
+        }
+
         bool stroked = i == strokeIdx; // (Will never be true if finalPathPaint is not stroked.)
         bool roundJoinStroked = stroked && finalPathPaint->getJoin() == StrokeJoin::round;
-        wangs_formula::VectorXform vectorXform(*matrix);
-        RawPath::Iter startOfContour = rawPath->begin();
-        RawPath::Iter end = rawPath->end();
+        wangs_formula::VectorXform vectorXform(*path.matrix);
+        RawPath::Iter startOfContour = path.rawPath->begin();
+        RawPath::Iter end = path.rawPath->end();
         int preChopVerbCount = 0; // Original number of lines and curves, before chopping.
         Vec2D endpointsSum{};
         bool closed = !stroked;
@@ -679,7 +937,7 @@
         }
     }
 
-    if (m_contourBatch.empty())
+    if (m_contourBatch.empty() && interiorTriHelper.empty())
     {
         // The entire batch is empty.
         return true;
@@ -688,164 +946,208 @@
     // Iteration pass 2: Finish calculating the numbers of tessellation segments in each contour,
     // using SIMD.
     uint32_t tessVertexCount = 0;
+    uint32_t baseVertex = m_context->currentTessVertexCount();
     size_t contourFirstLineIdx = 0;
     size_t contourFirstCurveIdx = 0;
     size_t contourFirstRotationIdx = 0;
     size_t emptyStrokeCountForCaps = 0;
-    for (ContourData& contour : m_contourBatch)
+    auto contour = m_contourBatch.begin();
+    auto endContour = m_contourBatch.end();
+    for (size_t currentPathIdx = 0; currentPathIdx < m_pathBatch.size(); ++currentPathIdx)
     {
-        size_t contourLineCount = contour.endLineIdx - contourFirstLineIdx;
-        uint32_t contourVertexCount = contourLineCount * 2; // Each line tessellates to 2 vertices.
-        uint4 mergedTessVertexSums4 = 0;
-
-        // Finish calculating and counting parametric segments for each curve.
-        size_t j;
-        for (j = contourFirstCurveIdx; j < contour.endCurveIdx; j += 4)
+        PathDraw& path = m_pathBatch[currentPathIdx];
+        if (path.rawPath->empty())
         {
-            assert(j + 4 <= m_parametricSegmentCounts_pow4.capacity());
-            // Curves recorded their segment counts raised to the 4th power. Now find their
-            // roots and convert to integers in batches of 4.
-            float4 n = simd::load4f(m_parametricSegmentCounts_pow4.get() + j);
-            n = simd::ceil(simd::sqrt(simd::sqrt(n)));
-            n = simd::clamp(n, float4(1), float4(kMaxParametricSegments));
-            uint4 n_ = simd::cast<uint32_t>(n);
-            assert(j + 4 <= m_parametricSegmentCounts.capacity());
-            simd::store(m_parametricSegmentCounts.get() + j, n_);
-            mergedTessVertexSums4 += n_;
-        }
-        // We counted in batches of 4. Undo the values we counted from beyond the end of the path.
-        while (j-- > contour.endCurveIdx)
-        {
-            contourVertexCount -= m_parametricSegmentCounts[j];
+            continue;
         }
 
-        bool stroked = contour.pathIdx == strokeIdx;
-        if (stroked)
+        if (path.triangulator != nullptr)
         {
-            // Finish calculating and counting polar segments for each stroked curve and round join.
-            const float r_ = strokeRadius * strokeMatrixMaxScale;
-            const float polarSegmentsPerRad =
-                pathutils::CalcPolarSegmentsPerRadian<kPolarPrecision>(r_);
-            for (j = contourFirstRotationIdx; j < contour.endRotationIdx; j += 4)
+            // This path will be drawn with interior triangulation. Its tessellation vertex count
+            // (not including padding) has already been written to 'path.tessVertexCount'. Pad it up
+            // so its first vertex falls on a multiple of kOuterCurvePatchSegmentSpan.
+            path.paddingVertexCount =
+                padding_to_align_up(baseVertex + tessVertexCount, kOuterCurvePatchSegmentSpan);
+            assert((baseVertex + tessVertexCount + path.paddingVertexCount) %
+                       kOuterCurvePatchSegmentSpan ==
+                   0);
+            path.tessVertexCount += path.paddingVertexCount;
+            tessVertexCount += path.tessVertexCount;
+            continue;
+        }
+
+        // Align the beginning of the path on a multiple of kMidpointFanPatchSegmentSpan.
+        path.paddingVertexCount =
+            padding_to_align_up(baseVertex + tessVertexCount, kMidpointFanPatchSegmentSpan);
+        path.tessVertexCount = path.paddingVertexCount;
+        tessVertexCount += path.paddingVertexCount;
+        assert((baseVertex + tessVertexCount) % kMidpointFanPatchSegmentSpan == 0);
+
+        assert(contour == endContour || contour->pathIdx >= currentPathIdx);
+        for (; contour != endContour && contour->pathIdx == currentPathIdx; ++contour)
+        {
+            size_t contourLineCount = contour->endLineIdx - contourFirstLineIdx;
+            uint32_t contourVertexCount =
+                contourLineCount * 2; // Each line tessellates to 2 vertices.
+            uint4 mergedTessVertexSums4 = 0;
+
+            // Finish calculating and counting parametric segments for each curve.
+            size_t j;
+            for (j = contourFirstCurveIdx; j < contour->endCurveIdx; j += 4)
             {
-                // Measure the rotations of curves in batches of 4.
-                assert(j + 4 <= m_tangentPairs.capacity());
-                auto [tx0, ty0, tx1, ty1] = simd::load4x4f(&m_tangentPairs[j][0].x);
-                float4 numer = tx0 * tx1 + ty0 * ty1;
-                float4 denom_pow2 = (tx0 * tx0 + ty0 * ty0) * (tx1 * tx1 + ty1 * ty1);
-                float4 cosTheta = numer / simd::sqrt(denom_pow2);
-                cosTheta = simd::clamp(cosTheta, float4(-1), float4(1));
-                float4 theta = simd::fast_acos(cosTheta);
-                // Find polar segment counts from the rotation angles.
-                float4 n = simd::ceil(theta * polarSegmentsPerRad);
-                n = simd::clamp(n, float4(1), float4(kMaxPolarSegments));
+                assert(j + 4 <= m_parametricSegmentCounts_pow4.capacity());
+                // Curves recorded their segment counts raised to the 4th power. Now find their
+                // roots and convert to integers in batches of 4.
+                float4 n = simd::load4f(m_parametricSegmentCounts_pow4.get() + j);
+                n = simd::ceil(simd::sqrt(simd::sqrt(n)));
+                n = simd::clamp(n, float4(1), float4(kMaxParametricSegments));
                 uint4 n_ = simd::cast<uint32_t>(n);
-                assert(j + 4 <= m_polarSegmentCounts.capacity());
-                simd::store(m_polarSegmentCounts.get() + j, n_);
-                // Polar and parametric segments share the first and final vertices. Therefore:
-                //
-                //   parametricVertexCount = parametricSegmentCount + 1
-                //
-                //   polarVertexCount = polarVertexCount + 1
-                //
-                //   mergedVertexCount = parametricVertexCount + polarVertexCount - 2
-                //                     = parametricSegmentCount + 1 + polarSegmentCount + 1 - 2
-                //                     = parametricSegmentCount + polarSegmentCount
-                //
+                assert(j + 4 <= m_parametricSegmentCounts.capacity());
+                simd::store(m_parametricSegmentCounts.get() + j, n_);
                 mergedTessVertexSums4 += n_;
             }
-
             // We counted in batches of 4. Undo the values we counted from beyond the end of the
             // path.
-            while (j-- > contour.endRotationIdx)
+            while (j-- > contour->endCurveIdx)
             {
-                contourVertexCount -= m_polarSegmentCounts[j];
+                contourVertexCount -= m_parametricSegmentCounts[j];
             }
 
-            // Count joins.
-            if (finalPathPaint->getJoin() == StrokeJoin::round)
+            bool stroked = contour->pathIdx == strokeIdx;
+            if (stroked)
             {
-                // Round joins share their beginning and ending vertices with the curve on either
-                // side. Therefore, the number of vertices we need to allocate for a round join is
-                // "joinSegmentCount - 1". Do all the -1's here.
-                contourVertexCount -= contour.strokeJoinCount;
-            }
-            else
-            {
-                // The shader needs 3 segments for each miter and bevel join (which translates to
-                // two interior vertices, since joins share their beginning and ending vertices with
-                // the curve on either side).
-                contourVertexCount +=
-                    contour.strokeJoinCount * (kNumSegmentsInMiterOrBevelJoin - 1);
-            }
-
-            // Count stroke caps, if any.
-            bool empty = contour.endLineIdx == contourFirstLineIdx &&
-                         contour.endCurveIdx == contourFirstCurveIdx;
-            StrokeCap cap;
-            bool needsCaps;
-            if (!empty)
-            {
-                cap = finalPathPaint->getCap();
-                needsCaps = !contour.closed;
-            }
-            else
-            {
-                cap = empty_stroke_cap(finalPathPaint, contour.closed);
-                needsCaps = cap != StrokeCap::butt; // Ignore butt caps when the contour is empty.
-            }
-            if (needsCaps)
-            {
-                // We emulate stroke caps as 180-degree joins.
-                if (cap == StrokeCap::round)
+                // Finish calculating and counting polar segments for each stroked curve and round
+                // join.
+                const float r_ = strokeRadius * strokeMatrixMaxScale;
+                const float polarSegmentsPerRad =
+                    pathutils::CalcPolarSegmentsPerRadian<kPolarPrecision>(r_);
+                for (j = contourFirstRotationIdx; j < contour->endRotationIdx; j += 4)
                 {
-                    // Round caps rotate 180 degrees.
-                    contour.strokeCapSegmentCount = ceilf(polarSegmentsPerRad * math::PI);
-                    // +2 because round caps emulated as joins need to emit vertices at T=0 and T=1,
-                    // unlike normal round joins.
-                    contour.strokeCapSegmentCount += 2;
-                    // Make sure not to exceed kMaxPolarSegments.
-                    contour.strokeCapSegmentCount =
-                        std::min(contour.strokeCapSegmentCount, kMaxPolarSegments);
+                    // Measure the rotations of curves in batches of 4.
+                    assert(j + 4 <= m_tangentPairs.capacity());
+                    auto [tx0, ty0, tx1, ty1] = simd::load4x4f(&m_tangentPairs[j][0].x);
+                    float4 numer = tx0 * tx1 + ty0 * ty1;
+                    float4 denom_pow2 = (tx0 * tx0 + ty0 * ty0) * (tx1 * tx1 + ty1 * ty1);
+                    float4 cosTheta = numer / simd::sqrt(denom_pow2);
+                    cosTheta = simd::clamp(cosTheta, float4(-1), float4(1));
+                    float4 theta = simd::fast_acos(cosTheta);
+                    // Find polar segment counts from the rotation angles.
+                    float4 n = simd::ceil(theta * polarSegmentsPerRad);
+                    n = simd::clamp(n, float4(1), float4(kMaxPolarSegments));
+                    uint4 n_ = simd::cast<uint32_t>(n);
+                    assert(j + 4 <= m_polarSegmentCounts.capacity());
+                    simd::store(m_polarSegmentCounts.get() + j, n_);
+                    // Polar and parametric segments share the first and final vertices. Therefore:
+                    //
+                    //   parametricVertexCount = parametricSegmentCount + 1
+                    //
+                    //   polarVertexCount = polarVertexCount + 1
+                    //
+                    //   mergedVertexCount = parametricVertexCount + polarVertexCount - 2
+                    //                     = parametricSegmentCount + 1 + polarSegmentCount + 1 - 2
+                    //                     = parametricSegmentCount + polarSegmentCount
+                    //
+                    mergedTessVertexSums4 += n_;
+                }
+
+                // We counted in batches of 4. Undo the values we counted from beyond the end of the
+                // path.
+                while (j-- > contour->endRotationIdx)
+                {
+                    contourVertexCount -= m_polarSegmentCounts[j];
+                }
+
+                // Count joins.
+                if (finalPathPaint->getJoin() == StrokeJoin::round)
+                {
+                    // Round joins share their beginning and ending vertices with the curve on
+                    // either side. Therefore, the number of vertices we need to allocate for a
+                    // round join is "joinSegmentCount - 1". Do all the -1's here.
+                    contourVertexCount -= contour->strokeJoinCount;
                 }
                 else
                 {
-                    contour.strokeCapSegmentCount = kNumSegmentsInMiterOrBevelJoin;
+                    // The shader needs 3 segments for each miter and bevel join (which translates
+                    // to two interior vertices, since joins share their beginning and ending
+                    // vertices with the curve on either side).
+                    contourVertexCount +=
+                        contour->strokeJoinCount * (kNumSegmentsInMiterOrBevelJoin - 1);
                 }
-                // pushContour() uses "strokeCapSegmentCount != 0" to tell if it needs stroke caps.
-                assert(contour.strokeCapSegmentCount != 0);
-                // As long as a contour isn't empty, we can tack the end cap onto the join section
-                // of the final curve in the stroke. Otherwise, we need to introduce
-                // 0-tessellation-segment curves with non-empty joins to carry the caps.
-                emptyStrokeCountForCaps += empty ? 2 : 1;
-                contourVertexCount += (contour.strokeCapSegmentCount - 1) * 2;
+
+                // Count stroke caps, if any.
+                bool empty = contour->endLineIdx == contourFirstLineIdx &&
+                             contour->endCurveIdx == contourFirstCurveIdx;
+                StrokeCap cap;
+                bool needsCaps;
+                if (!empty)
+                {
+                    cap = finalPathPaint->getCap();
+                    needsCaps = !contour->closed;
+                }
+                else
+                {
+                    cap = empty_stroke_cap(finalPathPaint, contour->closed);
+                    needsCaps =
+                        cap != StrokeCap::butt; // Ignore butt caps when the contour is empty.
+                }
+                if (needsCaps)
+                {
+                    // We emulate stroke caps as 180-degree joins.
+                    if (cap == StrokeCap::round)
+                    {
+                        // Round caps rotate 180 degrees.
+                        contour->strokeCapSegmentCount = ceilf(polarSegmentsPerRad * math::PI);
+                        // +2 because round caps emulated as joins need to emit vertices at T=0 and
+                        // T=1, unlike normal round joins.
+                        contour->strokeCapSegmentCount += 2;
+                        // Make sure not to exceed kMaxPolarSegments.
+                        contour->strokeCapSegmentCount =
+                            std::min(contour->strokeCapSegmentCount, kMaxPolarSegments);
+                    }
+                    else
+                    {
+                        contour->strokeCapSegmentCount = kNumSegmentsInMiterOrBevelJoin;
+                    }
+                    // pushContour() uses "strokeCapSegmentCount != 0" to tell if it needs stroke
+                    // caps.
+                    assert(contour->strokeCapSegmentCount != 0);
+                    // As long as a contour isn't empty, we can tack the end cap onto the join
+                    // section of the final curve in the stroke. Otherwise, we need to introduce
+                    // 0-tessellation-segment curves with non-empty joins to carry the caps.
+                    emptyStrokeCountForCaps += empty ? 2 : 1;
+                    contourVertexCount += (contour->strokeCapSegmentCount - 1) * 2;
+                }
             }
-        }
-        else
-        {
-            // Fills don't have polar segments:
-            //
-            //   mergedVertexCount = parametricVertexCount = parametricSegmentCount + 1
-            //
-            // Just collect the +1 for each non-stroked curve.
-            size_t contourCurveCount = contour.endCurveIdx - contourFirstCurveIdx;
-            contourVertexCount += contourCurveCount;
-        }
-        contourVertexCount += simd::sum(mergedTessVertexSums4);
+            else
+            {
+                // Fills don't have polar segments:
+                //
+                //   mergedVertexCount = parametricVertexCount = parametricSegmentCount + 1
+                //
+                // Just collect the +1 for each non-stroked curve.
+                size_t contourCurveCount = contour->endCurveIdx - contourFirstCurveIdx;
+                contourVertexCount += contourCurveCount;
+            }
+            contourVertexCount += simd::sum(mergedTessVertexSums4);
 
-        // Add padding vertices until the number of tessellation vertices in the contour is an exact
-        // multiple of kWedgeSize. This ensures that wedge boundaries aligh with contour boundaries.
-        constexpr uint32_t maxMultipleOfWedgeSize =
-            std::numeric_limits<uint32_t>::max() / kWedgeSize * kWedgeSize;
-        contour.paddingVertexCount = (maxMultipleOfWedgeSize - contourVertexCount) % kWedgeSize;
-        contourVertexCount += contour.paddingVertexCount;
-        assert(contourVertexCount % kWedgeSize == 0);
-        RIVE_DEBUG_CODE(contour.tessVertexCount = contourVertexCount;)
+            // Add padding vertices until the number of tessellation vertices in the contour is an
+            // exact multiple of kMidpointFanPatchSegmentSpan. This ensures that patch boundaries
+            // aligh with contour boundaries.
+            constexpr uint32_t maxMultipleOfPatchSpan = std::numeric_limits<uint32_t>::max() /
+                                                        kMidpointFanPatchSegmentSpan *
+                                                        kMidpointFanPatchSegmentSpan;
+            contour->paddingVertexCount =
+                (maxMultipleOfPatchSpan - contourVertexCount) % kMidpointFanPatchSegmentSpan;
+            contourVertexCount += contour->paddingVertexCount;
+            assert(contourVertexCount % kMidpointFanPatchSegmentSpan == 0);
+            RIVE_DEBUG_CODE(contour->tessVertexCount = contourVertexCount;)
 
-        tessVertexCount += contourVertexCount;
-        contourFirstLineIdx = contour.endLineIdx;
-        contourFirstCurveIdx = contour.endCurveIdx;
-        contourFirstRotationIdx = contour.endRotationIdx;
+            path.tessVertexCount += contourVertexCount;
+            tessVertexCount += contourVertexCount;
+            contourFirstLineIdx = contour->endLineIdx;
+            contourFirstCurveIdx = contour->endCurveIdx;
+            contourFirstRotationIdx = contour->endRotationIdx;
+        }
     }
     assert(contourFirstLineIdx == lineCount);
     assert(contourFirstCurveIdx == curveCount);
@@ -853,8 +1155,9 @@
 
     // Attempt to reserve space on the GPU for our entire batch of paths.
     if (!m_context->reservePathData(m_pathBatch.size(),
-                                    m_contourBatch.size(),
-                                    curveCount + lineCount + emptyStrokeCountForCaps,
+                                    m_contourBatch.size() + interiorTriHelper.contourCount(),
+                                    curveCount + lineCount + emptyStrokeCountForCaps +
+                                        interiorTriHelper.patchCount(),
                                     tessVertexCount))
     {
         // The paths don't fit. Give up and let the caller flush and try again.
@@ -876,64 +1179,87 @@
     RIVE_DEBUG_CODE(m_pushedCurveCount = 0;)
     RIVE_DEBUG_CODE(m_pushedRotationCount = 0;)
     RIVE_DEBUG_CODE(m_pushedEmptyStrokeCountForCaps = 0;)
-    RIVE_DEBUG_CODE(m_pushedTessVertexCount = 0;)
+    RIVE_DEBUG_CODE(size_t batchStartingTessVertexCount = m_context->currentTessVertexCount());
     size_t curveIdx = 0;
     size_t rotationIdx = 0;
     RawPath::Iter startOfContour;
-    size_t currentPathIdx = -1;
     size_t finalPathIdx = m_pathBatch.size() - 1; // All paths are clips except the final one.
-    for (const ContourData& contour : m_contourBatch)
+    contour = m_contourBatch.begin();
+    endContour = m_contourBatch.end();
+    for (size_t currentPathIdx = 0; currentPathIdx < m_pathBatch.size(); ++currentPathIdx)
     {
-        if (contour.pathIdx != currentPathIdx)
+        PathDraw& path = m_pathBatch[currentPathIdx];
+        if (path.rawPath->empty())
         {
-            // This is a new path. Push a path record.
-            const auto& [matrix, rawPath, fillRule, clipID] = m_pathBatch[contour.pathIdx];
-            if (contour.pathIdx != finalPathIdx)
-            {
-                // We're drawing a clip path.
-                m_context->pushPath(*matrix,
-                                    0,
-                                    fillRule,
-                                    PaintType::clipReplace,
-                                    clipID,
-                                    PLSBlendMode::srcOver,
-                                    PaintData{});
-            }
-            else
-            {
-                // We're drawing the actual path now.
-                m_context->pushPath(*matrix,
-                                    strokeRadius,
-                                    fillRule,
-                                    finalPathPaint->getType(),
-                                    clipID,
-                                    finalPathPaint->getBlendMode(),
-                                    paintData);
-            }
-            RIVE_DEBUG_CODE(++pushedPathCount);
-            startOfContour = rawPath->begin();
-            currentPathIdx = contour.pathIdx;
+            continue;
         }
-        // Push a contour and curve records.
-        RIVE_DEBUG_CODE(m_pushedStrokeJoinCount = 0;)
-        RIVE_DEBUG_CODE(m_pushedStrokeCapCount = 0;)
-        RIVE_DEBUG_CODE(size_t startingTessVertexCount = m_pushedTessVertexCount;)
-        pushContour(startOfContour,
-                    contour,
-                    curveIdx,
-                    rotationIdx,
-                    strokeMatrixMaxScale,
-                    currentPathIdx == strokeIdx ? finalPathPaint : nullptr);
-        assert(m_pushedCurveCount == contour.endCurveIdx);
-        assert(m_pushedRotationCount == contour.endRotationIdx);
-        assert(m_pushedStrokeJoinCount ==
-               (currentPathIdx == strokeIdx ? contour.strokeJoinCount : 0));
-        assert(m_pushedStrokeCapCount == (contour.strokeCapSegmentCount != 0 ? 2 : 0));
-        assert(m_pushedTessVertexCount == startingTessVertexCount + contour.tessVertexCount);
-        curveIdx = contour.endCurveIdx;
-        rotationIdx = contour.endRotationIdx;
-        startOfContour = contour.endOfContour;
-        RIVE_DEBUG_CODE(++pushedContourCount);
+
+        // Push a path record.
+        bool isClipPath = currentPathIdx != finalPathIdx;
+        PaintType paintType = isClipPath ? PaintType::clipReplace : finalPathPaint->getType();
+        PLSBlendMode blendMode =
+            isClipPath ? PLSBlendMode::srcOver : finalPathPaint->getBlendMode();
+
+        m_context->pushPath(path.triangulator ? PatchType::outerCurves : PatchType::midpointFan,
+                            *path.matrix,
+                            isClipPath ? 0 : strokeRadius,
+                            path.fillRule,
+                            paintType,
+                            path.clipID,
+                            blendMode,
+                            isClipPath ? PaintData{} : paintData,
+                            path.tessVertexCount,
+                            path.paddingVertexCount);
+
+        RIVE_DEBUG_CODE(uint32_t pathStartingTessVertexCount = m_context->currentTessVertexCount();)
+
+        if (path.triangulator != nullptr)
+        {
+            // This path is drawn with the interior triangulation algorithm instead.
+            interiorTriHelper.processPath(InteriorTriangulationHelper::PathOp::submitOuterCubics,
+                                          m_context,
+                                          &path);
+            m_context->pushInteriorTriangulation(path.triangulator,
+                                                 paintType,
+                                                 path.clipID,
+                                                 blendMode);
+            assert(m_context->currentTessVertexCount() ==
+                   pathStartingTessVertexCount + path.tessVertexCount);
+            continue;
+        }
+
+        RIVE_DEBUG_CODE(++pushedPathCount);
+        startOfContour = path.rawPath->begin();
+
+        assert(contour == endContour || contour->pathIdx >= currentPathIdx);
+        RIVE_DEBUG_CODE(uint32_t contourStartingTessVertexCount =
+                            m_context->currentTessVertexCount() + path.paddingVertexCount;)
+        for (; contour != endContour && contour->pathIdx == currentPathIdx; ++contour)
+        {
+            // Push a contour and curve records.
+            RIVE_DEBUG_CODE(m_pushedStrokeJoinCount = 0;)
+            RIVE_DEBUG_CODE(m_pushedStrokeCapCount = 0;)
+            pushContour(startOfContour,
+                        *contour,
+                        curveIdx,
+                        rotationIdx,
+                        strokeMatrixMaxScale,
+                        currentPathIdx == strokeIdx ? finalPathPaint : nullptr);
+            assert(m_pushedCurveCount == contour->endCurveIdx);
+            assert(m_pushedRotationCount == contour->endRotationIdx);
+            assert(m_pushedStrokeJoinCount ==
+                   (currentPathIdx == strokeIdx ? contour->strokeJoinCount : 0));
+            assert(m_pushedStrokeCapCount == (contour->strokeCapSegmentCount != 0 ? 2 : 0));
+            assert(m_context->currentTessVertexCount() ==
+                   contourStartingTessVertexCount + contour->tessVertexCount);
+            curveIdx = contour->endCurveIdx;
+            rotationIdx = contour->endRotationIdx;
+            startOfContour = contour->endOfContour;
+            RIVE_DEBUG_CODE(++pushedContourCount);
+            RIVE_DEBUG_CODE(contourStartingTessVertexCount = m_context->currentTessVertexCount();)
+        }
+        assert(contourStartingTessVertexCount ==
+               pathStartingTessVertexCount + path.tessVertexCount);
     }
 
     // Make sure we only pushed the amount of data we reserved.
@@ -944,7 +1270,8 @@
     assert(m_pushedCurveCount == curveCount);
     assert(m_pushedRotationCount == rotationCount);
     assert(m_pushedEmptyStrokeCountForCaps == emptyStrokeCountForCaps);
-    assert(m_pushedTessVertexCount == tessVertexCount);
+    assert(m_context->currentTessVertexCount() == batchStartingTessVertexCount + tessVertexCount);
+    assert(interiorTriHelper.didSubmitAllData());
     return true;
 }
 
@@ -989,7 +1316,6 @@
 
     // Make a data record for this current contour on the GPU.
     m_context->pushContour(contour.midpoint, contour.closed, contour.paddingVertexCount);
-    RIVE_DEBUG_CODE(m_pushedTessVertexCount += contour.paddingVertexCount;)
 
     // Convert all curves in the contour to cubics and push them to the GPU.
     const int styleFlags = style_flags(strokePaint != nullptr, roundJoinStroked);
@@ -1062,7 +1388,6 @@
                 m_context
                     ->pushCubic(cubic.data(), joinTangent, joinTypeFlags, 1, 1, joinSegmentCount);
                 RIVE_DEBUG_CODE(++m_pushedLineCount;)
-                RIVE_DEBUG_CODE(m_pushedTessVertexCount += 2 + joinSegmentCount - 1;)
                 break;
             }
             case StyledVerb::roundJoinStrokedQuad:
@@ -1131,8 +1456,6 @@
                                          parametricSegmentCount,
                                          polarSegmentCount,
                                          1);
-                    RIVE_DEBUG_CODE(m_pushedTessVertexCount +=
-                                    parametricSegmentCount + polarSegmentCount;)
                 }
                 // Push the final chop, with a join.
                 uint32_t parametricSegmentCount = m_parametricSegmentCounts[curveIdx++];
@@ -1169,15 +1492,12 @@
                                      parametricSegmentCount,
                                      polarSegmentCount,
                                      joinSegmentCount);
-                RIVE_DEBUG_CODE(m_pushedTessVertexCount +=
-                                parametricSegmentCount + polarSegmentCount + joinSegmentCount - 1;)
                 break;
             }
             case StyledVerb::filledCubic:
             {
                 uint32_t parametricSegmentCount = m_parametricSegmentCounts[curveIdx++];
                 m_context->pushCubic(iter.cubicPts(), Vec2D{}, 0, parametricSegmentCount, 1, 1);
-                RIVE_DEBUG_CODE(m_pushedTessVertexCount += parametricSegmentCount + 1;)
                 break;
             }
         }
@@ -1217,7 +1537,6 @@
             }
             m_context->pushCubic(cubic.data(), joinTangent, joinTypeFlags, 1, 1, joinSegmentCount);
             RIVE_DEBUG_CODE(++m_pushedLineCount;)
-            RIVE_DEBUG_CODE(m_pushedTessVertexCount += 2 + joinSegmentCount - 1;)
         }
     }
 
@@ -1240,7 +1559,6 @@
                          strokeCapSegmentCount);
     RIVE_DEBUG_CODE(++m_pushedStrokeCapCount;)
     RIVE_DEBUG_CODE(++m_pushedEmptyStrokeCountForCaps;)
-    RIVE_DEBUG_CODE(m_pushedTessVertexCount += strokeCapSegmentCount - 1;)
 }
 
 void PLSRenderer::intermediateFlush()
diff --git a/renderer/shaders/color_ramp.glsl b/renderer/shaders/color_ramp.glsl
index 4965a55..383161c 100644
--- a/renderer/shaders/color_ramp.glsl
+++ b/renderer/shaders/color_ramp.glsl
@@ -24,21 +24,21 @@
     @colorRampVertexMain,
     Varyings,
     varyings,
-    uint GLSL_VERTEX_ID [[vertex_id]],
-    uint GLSL_INSTANCE_ID [[instance_id]],
+    uint VERTEX_ID [[vertex_id]],
+    uint INSTANCE_ID [[instance_id]],
     constant @Uniforms& uniforms [[buffer(0)]],
     constant Attrs* attrs [[buffer(1)]]
 #endif
 )
 {
-    ATTR_LOAD(uint4, attrs, span, GLSL_INSTANCE_ID);
+    ATTR_LOAD(uint4, attrs, span, INSTANCE_ID);
     uint horizontalSpan = span.x;
     float2 coord = float2(
-        float((GLSL_VERTEX_ID & 1) == 0 ? horizontalSpan & 0xffffu : horizontalSpan >> 16) / 65536.,
-        float(span.y) + ((GLSL_VERTEX_ID & 2) == 0 ? .0 : 1.));
-    FLD(varyings, rampColor) = unpackColorInt((GLSL_VERTEX_ID & 1) == 0 ? span.z : span.w);
-    GLSL_POSITION.xy = coord * float2(2, uniforms.gradInverseViewportY) - 1.;
-    GLSL_POSITION.zw = float2(0, 1);
+        float((VERTEX_ID & 1) == 0 ? horizontalSpan & 0xffffu : horizontalSpan >> 16) / 65536.,
+        float(span.y) + ((VERTEX_ID & 2) == 0 ? .0 : 1.));
+    FLD(varyings, rampColor) = unpackColorInt((VERTEX_ID & 1) == 0 ? span.z : span.w);
+    POSITION.xy = coord * float2(2, uniforms.gradInverseViewportY) - 1.;
+    POSITION.zw = float2(0, 1);
     EMIT_OFFSCREEN_VERTEX(varyings);
 }
 #endif
diff --git a/renderer/shaders/common.glsl b/renderer/shaders/common.glsl
index 6d73ebf..77b310d 100644
--- a/renderer/shaders/common.glsl
+++ b/renderer/shaders/common.glsl
@@ -8,19 +8,49 @@
 #define TESS_TEXTURE_WIDTH_LOG2 11
 
 #define FIRST_VERTEX_OF_CONTOUR_FLAG (1u << 31)
-#define JOIN_TYPE_MASK (3u << 29)
-#define MITER_CLIP_JOIN (3u << 29)
-#define MITER_REVERT_JOIN (2u << 29)
-#define BEVEL_JOIN (1u << 29)
-#define EMULATED_STROKE_CAP_FLAG (1u << 28)
-#define JOIN_TANGENT_0_FLAG (1u << 27)
-#define JOIN_TANGENT_INNER_FLAG (1u << 26)
-#define LEFT_JOIN_FLAG (1u << 25)
-#define RIGHT_JOIN_FLAG (1u << 24)
+#define RETROFITTED_TRIANGLE_FLAG (1u << 30)
+#define CULL_EXCESS_TESSELLATION_SEGMENTS_FLAG (1u << 29)
+#define JOIN_TYPE_MASK (3u << 27)
+#define MITER_CLIP_JOIN (3u << 27)
+#define MITER_REVERT_JOIN (2u << 27)
+#define BEVEL_JOIN (1u << 27)
+#define EMULATED_STROKE_CAP_FLAG (1u << 26)
+#define JOIN_TANGENT_0_FLAG (1u << 25)
+#define JOIN_TANGENT_INNER_FLAG (1u << 24)
+#define LEFT_JOIN_FLAG (1u << 23)
+#define RIGHT_JOIN_FLAG (1u << 22)
 #define CONTOUR_ID_MASK 0xffffu
 
 #define PI 3.141592653589793238
 
+#define GRAD_TEXTURE_WIDTH 512.
+
+#define EVEN_ODD_FLAG (1u << 31)
+#define SOLID_COLOR_PAINT_TYPE 0u
+#define LINEAR_GRADIENT_PAINT_TYPE 1u
+#define RADIAL_GRADIENT_PAINT_TYPE 2u
+#define CLIP_REPLACE_PAINT_TYPE 3u
+
+// acos(1/4), because the miter limit is always 4.
+#define MITER_ANGLE_LIMIT 1.318116071652817965746
+
+// Raw bit representation of the largest denormalized fp16 value. We offset all (1-based) path IDs
+// by this value in order to avoid denorms, which have been empirically unreliable on Android as ID
+// values.
+#define MAX_DENORM_F16 1023u
+
+INLINE int2 contour_texel_coord(uint contourIDWithFlags)
+{
+    uint contourTexelIdx = (contourIDWithFlags & CONTOUR_ID_MASK) - 1u;
+    return int2(contourTexelIdx & 0xffu, contourTexelIdx >> 8);
+}
+
+INLINE int2 path_texel_coord(uint pathIDBits)
+{
+    uint pathIdx = pathIDBits - 1u;
+    return int2((pathIdx & 0x7fu) * 3u, pathIdx >> 7);
+}
+
 INLINE float2 unchecked_mix(float2 a, float2 b, float t) { return (b - a) * t + a; }
 
 INLINE float atan2(float2 v)
@@ -42,7 +72,6 @@
 }
 
 #ifdef @VERTEX
-
 UNIFORM_BLOCK_BEGIN(@Uniforms)
 float gradInverseViewportY;
 float tessInverseViewportY;
@@ -53,5 +82,4 @@
 float vertexDiscardValue;
 uint pad;
 UNIFORM_BLOCK_END(uniforms)
-
 #endif
diff --git a/renderer/shaders/draw.glsl b/renderer/shaders/draw.glsl
index a013a99..b742b84 100644
--- a/renderer/shaders/draw.glsl
+++ b/renderer/shaders/draw.glsl
@@ -8,25 +8,13 @@
 #define FAN_VERTEX 1
 #define FAN_MIDPOINT_VERTEX 2
 
-#define GRAD_TEXTURE_WIDTH 512.
-
-#define EVEN_ODD_FLAG (1u << 31)
-#define SOLID_COLOR_PAINT_TYPE 0u
-#define LINEAR_GRADIENT_PAINT_TYPE 1u
-#define RADIAL_GRADIENT_PAINT_TYPE 2u
-#define CLIP_REPLACE_PAINT_TYPE 3u
-
-// acos(1/4), because the miter limit is always 4.
-#define MITER_ANGLE_LIMIT 1.318116071652817965746
-
-// Raw bit representation of the largest denormalized fp16 value. We offset all (1-based) path IDs
-// by this value in order to avoid denorms, which have been empirically unreliable on Android as ID
-// values.
-#define MAX_DENORM_F16 1023u
-
 VARYING_BLOCK_BEGIN(Varyings)
-NO_PERSPECTIVE VARYING half2 edgeDistance;
 NO_PERSPECTIVE VARYING float4 varying_paint;
+#ifdef @DRAW_INTERIOR_TRIANGLES
+@OPTIONALLY_FLAT VARYING half windingWeight;
+#else
+NO_PERSPECTIVE VARYING half2 edgeDistance;
+#endif
 @OPTIONALLY_FLAT VARYING half pathID;
 #ifdef @ENABLE_PATH_CLIPPING
 @OPTIONALLY_FLAT VARYING half clipID;
@@ -50,7 +38,11 @@
 VERTEX_TEXTURE_BLOCK_END
 
 ATTR_BLOCK_BEGIN(Attrs)
-ATTR(0) float4 wedgeVertexData; // [localVertexID, outset, fillCoverage, vertexType]
+#ifdef @DRAW_INTERIOR_TRIANGLES
+ATTR(0) packed_float3 triangleVertex;
+#else
+ATTR(0) float4 patchVertexData; // [localVertexID, outset, fillCoverage, vertexType]
+#endif
 ATTR_BLOCK_END
 
 int2 tessTexelCoord(int texelIndex)
@@ -71,36 +63,38 @@
     @drawVertexMain,
     Varyings,
     varyings,
-    uint GLSL_VERTEX_ID [[vertex_id]],
-    uint GLSL_INSTANCE_ID [[instance_id]],
-    uint GLSL_BASE_INSTANCE [[base_instance]],
+    uint VERTEX_ID [[vertex_id]],
+    uint INSTANCE_ID [[instance_id]],
+    uint BASE_INSTANCE [[base_instance]],
     constant @Uniforms& uniforms [[buffer(0)]],
     constant Attrs* attrs [[buffer(1)]],
     VertexTextures textures
 #endif
 )
 {
-    // Unpack wedgeVertexData.
-    ATTR_LOAD(float4, attrs, wedgeVertexData, GLSL_VERTEX_ID);
-    int localVertexID = int(wedgeVertexData.x);
-    float outset = wedgeVertexData.y;
-    float fillCoverage = wedgeVertexData.z;
-    int wedgeSize = floatBitsToInt(wedgeVertexData.w) >> 2;
-    int vertexType = floatBitsToInt(wedgeVertexData.w) & 3;
+    bool shouldDiscardVertex = false;
+#ifdef @DRAW_INTERIOR_TRIANGLES
+    ATTR_LOAD(float3, attrs, triangleVertex, VERTEX_ID);
+    uint pathIDBits = floatBitsToUint(triangleVertex.z) & 0xffffu;
+#else
+    // Unpack patchVertexData.
+    ATTR_LOAD(float4, attrs, patchVertexData, VERTEX_ID);
+    int localVertexID = int(patchVertexData.x);
+    float outset = patchVertexData.y;
+    float fillCoverage = patchVertexData.z;
+    int patchSegmentSpan = floatBitsToInt(patchVertexData.w) >> 2;
+    int vertexType = floatBitsToInt(patchVertexData.w) & 3;
 
     // Fetch the tessellation vertex we belong to.
-    int wedgeIdx = GLSL_INSTANCE_ID;
+    int vertexIdx = INSTANCE_ID * patchSegmentSpan + localVertexID;
 #ifdef @BASE_INSTANCE_POLYFILL
-    wedgeIdx += @baseInstancePolyfill;
-#else
-    wedgeIdx += GLSL_BASE_INSTANCE;
+    vertexIdx += baseInstancePolyfill * patchSegmentSpan;
 #endif
-    int vertexIdx = wedgeIdx * wedgeSize + localVertexID;
     int2 tessVertexTexelCoord = tessTexelCoord(vertexIdx);
     uint4 tessVertexData = TEXEL_FETCH(textures, @tessVertexTexture, tessVertexTexelCoord, 0);
     uint contourIDWithFlags = tessVertexData.w;
-    bool isClosingVertexOfContour =
-        localVertexID == wedgeSize && (contourIDWithFlags & FIRST_VERTEX_OF_CONTOUR_FLAG) != 0u;
+    bool isClosingVertexOfContour = localVertexID == patchSegmentSpan &&
+                                    (contourIDWithFlags & FIRST_VERTEX_OF_CONTOUR_FLAG) != 0u;
     if (isClosingVertexOfContour)
     {
         // The right vertex crossed over into a new contour. Fetch the previous vertex, which will
@@ -111,23 +105,31 @@
     }
 
     // Fetch and unpack the contour referenced by the tessellation vertex.
-    uint contourTexelIdx = (contourIDWithFlags & CONTOUR_ID_MASK) - 1u;
-    int2 contourTexelCoord = int2(contourTexelIdx & 0xffu, contourTexelIdx >> 8);
-    uint4 contourData = TEXEL_FETCH(textures, @contourTexture, contourTexelCoord, 0);
+    uint4 contourData =
+        TEXEL_FETCH(textures, @contourTexture, contour_texel_coord(contourIDWithFlags), 0);
     float2 midpoint = uintBitsToFloat(contourData.xy);
     uint pathIDBits = contourData.z;
     uint vertexIndex0 = contourData.w;
+#endif
 
-    // Fetch and unpack the path referenced by the contour.
-    uint pathIdx = pathIDBits - 1u;
-    int2 pathTexelCoord = int2((pathIdx & 0x7fu) * 3u, pathIdx >> 7);
+    // Fetch and unpack the path.
+    int2 pathTexelCoord = path_texel_coord(pathIDBits);
     float2x2 matrix =
         make_float2x2(uintBitsToFloat(TEXEL_FETCH(textures, @pathTexture, pathTexelCoord, 0)));
     uint4 pathData = TEXEL_FETCH(textures, @pathTexture, pathTexelCoord + int2(1, 0), 0);
     float2 translate = uintBitsToFloat(pathData.xy);
-    float strokeRadius = uintBitsToFloat(pathData.z);
     uint pathParams = pathData.w;
 
+#ifdef @DRAW_INTERIOR_TRIANGLES
+    // The vertex position is encoded directly in vertex data when drawing triangles.
+    float2 vertexPosition = matrix * triangleVertex.xy + translate;
+    // When we belong to a non-overlapping interior triangulation, the winding sign and weight are
+    // also encoded directly in vertex data.
+    FLD(varyings, windingWeight) =
+        float(floatBitsToInt(triangleVertex.z) >> 16) * sign(determinant(matrix));
+#else
+    float strokeRadius = uintBitsToFloat(pathData.z);
+
     // Finish unpacking tessVertexData.
     if (isClosingVertexOfContour)
     {
@@ -142,8 +144,6 @@
             contourIDWithFlags = tessVertexData.w;
         }
     }
-    FLD(varyings, pathID) =
-        unpackHalf2x16((pathIDBits + MAX_DENORM_F16) * uniforms.pathIDGranularity).r;
     float theta = uintBitsToFloat(tessVertexData.z);
     float2 norm = float2(sin(theta), -cos(theta));
     float2 origin = uintBitsToFloat(tessVertexData.xy);
@@ -261,11 +261,11 @@
         // Strokes identify themselves by emitting a negative edgeDistance.
         FLD(varyings, edgeDistance) *= -globalCoverage;
 
+        postTransformVertexOffset = matrix * (outset * vertexOffset);
+
         // Throw away the fan triangles since we're a stroke.
         if (vertexType != STROKE_VERTEX)
-            outset = uniforms.vertexDiscardValue;
-
-        postTransformVertexOffset = matrix * (outset * vertexOffset);
+            shouldDiscardVertex = true;
     }
     else // This is a fill.
     {
@@ -273,15 +273,26 @@
         if (vertexType == FAN_MIDPOINT_VERTEX)
             origin = midpoint;
 
-        // Indicate even-odd fill rule by making pathID negative.
-        if ((pathParams & EVEN_ODD_FLAG) != 0u)
-            FLD(varyings, pathID) = -FLD(varyings, pathID);
-
         // Offset the vertex for Manhattan AA.
         postTransformVertexOffset = sign(matrix * (outset * norm)) * AA_RADIUS;
         FLD(varyings, edgeDistance) = make_half2(fillCoverage, 1);
+
+        // If we're actually just drawing a triangle, throw away the entire patch except a single
+        // fan triangle.
+        if ((contourIDWithFlags & RETROFITTED_TRIANGLE_FLAG) != 0u && vertexType != FAN_VERTEX)
+            shouldDiscardVertex = true;
     }
     float2 vertexPosition = matrix * origin + postTransformVertexOffset + translate;
+#endif
+
+    // Encode the integral pathID as a "half" that we know the hardware will see as a unique value
+    // in the fragment shader.
+    FLD(varyings, pathID) =
+        unpackHalf2x16((pathIDBits + MAX_DENORM_F16) * uniforms.pathIDGranularity).r;
+
+    // Indicate even-odd fill rule by making pathID negative.
+    if ((pathParams & EVEN_ODD_FLAG) != 0u)
+        FLD(varyings, pathID) = -FLD(varyings, pathID);
 
     uint paintType = (pathParams >> 20) & 7u;
 #ifdef @ENABLE_PATH_CLIPPING
@@ -342,16 +353,17 @@
     }
     FLD(varyings, varying_paint) = paint;
 
-    GLSL_POSITION.xy = vertexPosition * float2(uniforms.renderTargetInverseViewportX,
-                                               -uniforms.renderTargetInverseViewportY) +
-                       float2(-1, 1);
-    GLSL_POSITION.zw = float2(0, 1);
+    POSITION.xy = vertexPosition * float2(uniforms.renderTargetInverseViewportX,
+                                          -uniforms.renderTargetInverseViewportY) +
+                  float2(-1, 1);
+    POSITION.zw = float2(0, 1);
+    if (shouldDiscardVertex)
+        POSITION = float4(uniforms.vertexDiscardValue);
     EMIT_VERTEX(varyings);
 }
 #endif
 
 #ifdef @FRAGMENT
-
 FRAG_TEXTURE_BLOCK_BEGIN(FragmentTextures)
 TEXTURE_RGBA8(3) @gradTexture;
 FRAG_TEXTURE_BLOCK_END
@@ -363,14 +375,14 @@
 PLS_DECL2F(3) clipBuffer;
 PLS_BLOCK_END
 
-PLS_MAIN(
 #ifdef METAL
-    @drawFragmentMain,
-    Varyings varyings [[stage_in]],
-    FragmentTextures textures,
-    bool GLSL_FRONT_FACING [[front_facing]]
+PLS_MAIN(@drawFragmentMain,
+         Varyings varyings [[stage_in]],
+         FragmentTextures textures,
+         bool FRONT_FACING [[front_facing]])
+#else
+PLS_MAIN()
 #endif
-)
 {
     float4 paint = FLD(varyings, varying_paint);
     half4 color;
@@ -394,7 +406,10 @@
         color = TEXTURE_SAMPLE(textures, @gradTexture, float2(mix(x0, x1, t), row));
     }
 
+#ifndef @DRAW_INTERIOR_TRIANGLES
+    // Interior triangles don't overlap, so don't need raster ordering.
     PLS_INTERLOCK_BEGIN;
+#endif
 
     half2 coverageData = PLS_LOAD2F(coverageCountBuffer);
     half localPathID = coverageData.r;
@@ -406,26 +421,37 @@
         // This is the first fragment from pathID to touch this pixel.
         coverageCount = .0;
         dstColor = PLS_LOAD4F(framebuffer);
+#ifndef @DRAW_INTERIOR_TRIANGLES
+        // We don't need to store coverage when drawing interior triangles because they always go
+        // last and don't overlap, so every fragment is the final one in the path.
         PLS_STORE4F(originalDstColorBuffer, dstColor);
+#endif
     }
     else
     {
         dstColor = PLS_LOAD4F(originalDstColorBuffer);
+#ifndef @DRAW_INTERIOR_TRIANGLES
+        // Since interior triangles are always last, there's no need to preserve this value.
         PLS_PRESERVE_VALUE(originalDstColorBuffer);
+#endif
     }
 
+#ifdef @DRAW_INTERIOR_TRIANGLES
+    coverageCount += FLD(varyings, windingWeight);
+#else
     // TODO: We may need to just send actual flags instead of using sign(edgeDistance) to identify
     // strokes. Since edgeDistance is interpolated, it can sometimes cross signs.
     half d = FLD(varyings, edgeDistance).x;
     if (d < -1e-4 /*stroke with an intentionally negative edgeDistance*/)
         coverageCount = min(max(d, FLD(varyings, edgeDistance).y), coverageCount);
-    else if (GLSL_FRONT_FACING /*clockwise fill*/)
+    else if (FRONT_FACING /*clockwise fill*/)
         coverageCount += d;
     else /*counterclockwise fill*/
         coverageCount -= d;
 
     // Save the updated coverage.
     PLS_STORE2F(coverageCountBuffer, FLD(varyings, pathID), coverageCount);
+#endif
 
     // Convert coverageCount to coverage. (Which is min(-edgeDistance) right now for strokes.)
     half coverage = abs(coverageCount);
@@ -478,7 +504,10 @@
         PLS_STORE4F(framebuffer, color);
     }
 
+#ifndef @DRAW_INTERIOR_TRIANGLES
+    // Interior triangles don't overlap, so don't need raster ordering.
     PLS_INTERLOCK_END;
+#endif
 
     EMIT_PLS;
 }
diff --git a/renderer/shaders/glsl.glsl b/renderer/shaders/glsl.glsl
index a1f05d3..5b6d377 100644
--- a/renderer/shaders/glsl.glsl
+++ b/renderer/shaders/glsl.glsl
@@ -7,6 +7,7 @@
 
 #define float2 vec2
 #define float3 vec3
+#define packed_float3 vec3
 #define float4 vec4
 
 #define half mediump float
@@ -51,11 +52,19 @@
 #define make_half3x4 mat3x4
 
 #define INLINE
-#define GLSL_FRONT_FACING gl_FrontFacing
-#define GLSL_POSITION gl_Position
-#define GLSL_VERTEX_ID gl_VertexID
-#define GLSL_INSTANCE_ID gl_InstanceID
-#define GLSL_BASE_INSTANCE gl_BaseInstance
+#define FRONT_FACING gl_FrontFacing
+#define POSITION gl_Position
+#define VERTEX_ID gl_VertexID
+
+#if defined(GL_ANGLE_base_vertex_base_instance_shader_builtin)
+#extension GL_ANGLE_base_vertex_base_instance_shader_builtin : require
+#endif
+
+#ifndef @BASE_INSTANCE_POLYFILL
+#define INSTANCE_ID (gl_BaseInstance + gl_InstanceID)
+#else
+#define INSTANCE_ID gl_InstanceID
+#endif
 
 #define UNIFORM_BLOCK_BEGIN(N)                                                                     \
     layout(std140) uniform N                                                                       \
@@ -79,7 +88,7 @@
 #define FLAT flat
 #define VARYING_BLOCK_END
 
-#ifdef GL_NV_shader_noperspective_interpolation
+#if defined(GL_NV_shader_noperspective_interpolation)
 #extension GL_NV_shader_noperspective_interpolation : require
 #define NO_PERSPECTIVE noperspective
 #else
@@ -179,18 +188,13 @@
 
 #ifdef @PLS_IMPL_RW_TEXTURE
 
-#extension GL_ARB_fragment_shader_interlock : enable
-#extension GL_INTEL_fragment_shader_ordering : enable
-
 #if defined(GL_ARB_fragment_shader_interlock)
-#define PLS_INTERLOCK_BEGIN                                                                        \
-    highp ivec2 plsCoord = ivec2(floor(gl_FragCoord.xy));                                          \
-    beginInvocationInterlockARB()
+#extension GL_ARB_fragment_shader_interlock : require
+#define PLS_INTERLOCK_BEGIN beginInvocationInterlockARB()
 #define PLS_INTERLOCK_END endInvocationInterlockARB()
 #elif defined(GL_INTEL_fragment_shader_ordering)
-#define PLS_INTERLOCK_BEGIN                                                                        \
-    highp ivec2 plsCoord = ivec2(floor(gl_FragCoord.xy));                                          \
-    beginFragmentShaderOrderingINTEL()
+#extension GL_INTEL_fragment_shader_ordering : require
+#define PLS_INTERLOCK_BEGIN beginFragmentShaderOrderingINTEL()
 #define PLS_INTERLOCK_END
 #endif
 
@@ -216,8 +220,16 @@
 #define FRAG_DATA_MAIN void main
 #define EMIT_FRAG_DATA(f) _fd = f
 
+#ifdef @PLS_IMPL_RW_TEXTURE
+#define PLS_MAIN()                                                                                 \
+    void main()                                                                                    \
+    {                                                                                              \
+        highp ivec2 plsCoord = ivec2(floor(gl_FragCoord.xy));
+#define EMIT_PLS }
+#else
 #define PLS_MAIN void main
 #define EMIT_PLS
+#endif
 
 // "FLD" for "field".
 // In Metal, inputs and outputs are accessed from structs.
diff --git a/renderer/shaders/metal.glsl b/renderer/shaders/metal.glsl
index 1c9cd40..1cd6abb 100644
--- a/renderer/shaders/metal.glsl
+++ b/renderer/shaders/metal.glsl
@@ -22,6 +22,7 @@
 #define ushort4 ushort4
 #define float2 float2
 #define float3 float3
+#define packed_float3 packed_float3
 #define float4 float4
 #define bool2 bool2
 #define bool3 bool3
@@ -121,22 +122,22 @@
 #define PLS_INTERLOCK_BEGIN
 #define PLS_INTERLOCK_END
 
-// A hack since we use GLSL_POSITION inside the following #defines...
-#define GLSL_POSITION GLSL_POSITION
+// A hack since we use POSITION inside the following #defines...
+#define POSITION POSITION
 
 #define VERTEX_MAIN(F, V, v, ...)                                                                  \
     __attribute__((visibility("default"))) V vertex F(__VA_ARGS__)                                 \
     {                                                                                              \
         V v;                                                                                       \
-        float4 GLSL_POSITION;
+        float4 POSITION;
 
 #define EMIT_VERTEX(v)                                                                             \
-    v._pos = GLSL_POSITION;                                                                        \
+    v._pos = POSITION;                                                                             \
     return v;                                                                                      \
     }
 
 #define EMIT_OFFSCREEN_VERTEX(v)                                                                   \
-    v._pos = GLSL_POSITION;                                                                        \
+    v._pos = POSITION;                                                                             \
     v._pos.y = -v._pos.y;                                                                          \
     return v;                                                                                      \
     }
diff --git a/renderer/shaders/metal/draw.metal b/renderer/shaders/metal/draw.metal
index c12656f..89d360e 100644
--- a/renderer/shaders/metal/draw.metal
+++ b/renderer/shaders/metal/draw.metal
@@ -14,11 +14,18 @@
 #undef ENABLE_ADVANCED_BLEND
 
 // Compile all combinations of flags into a single compilation unit.
-// Each combination gets added to a namespace whose name has the following bits set for each flag:
-//     ENABLE_ADVANCED_BLEND:   r0001
-//     ENABLE_PATH_CLIPPING:    r0010
-//     ENABLE_EVEN_ODD:         r0100
-//     ENABLE_HSL_BLEND_MODES:  r1000
+//
+// Namespaces beginning in 'r' indicate the normal Rive renderer.
+//
+// Namespaces beginning in 't' indicate the special case when we draw non-overlapping interior
+// triangles.
+//
+// Each combination of shader features gets added to a namespace whose name has the following bits
+// set for each flag:
+//     ENABLE_ADVANCED_BLEND:   r0001 / t0001
+//     ENABLE_PATH_CLIPPING:    r0010 / t0010
+//     ENABLE_EVEN_ODD:         r0100 / t0100
+//     ENABLE_HSL_BLEND_MODES:  r1000 / t1000
 
 // 4-bit "Gray Code": enumerate all bit combinations by only modifying one single bit each step.
 //     0000
@@ -43,24 +50,48 @@
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t0000
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 #define ENABLE_ADVANCED_BLEND
 namespace r0001
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t0001
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 #define ENABLE_PATH_CLIPPING
 namespace r0011
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t0011
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 #undef ENABLE_ADVANCED_BLEND
 namespace r0010
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t0010
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 // Fragment-only combinations.
 #undef VERTEX
@@ -69,69 +100,109 @@
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t0110
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 #define ENABLE_ADVANCED_BLEND
 namespace r0111
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t0111
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 #undef ENABLE_PATH_CLIPPING
 namespace r0101
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t0101
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 #undef ENABLE_ADVANCED_BLEND
 namespace r0100
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t0100
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 #define ENABLE_HSL_BLEND_MODES
-namespace r1100
-{
+// namespace r1100
 // HSL blend without advanced blend doesn't happen.
-}
 
 #define ENABLE_ADVANCED_BLEND
 namespace r1101
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t1101
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 #define ENABLE_PATH_CLIPPING
 namespace r1111
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t1111
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 #undef ENABLE_ADVANCED_BLEND
-namespace r1110
-{
+// namespace r1110
 // HSL blend without advanced blend doesn't happen.
-}
 
 #undef ENABLE_EVEN_ODD
-namespace r1010
-{
+// namespace r1010
 // HSL blend without advanced blend doesn't happen.
-}
 
 #define ENABLE_ADVANCED_BLEND
 namespace r1011
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t1011
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 #undef ENABLE_PATH_CLIPPING
 namespace r1001
 {
 #include "../../../out/obj/generated/draw.minified.glsl"
 }
+namespace t1001
+{
+#define DRAW_INTERIOR_TRIANGLES
+#include "../../../out/obj/generated/draw.minified.glsl"
+#undef DRAW_INTERIOR_TRIANGLES
+}
 
 #undef ENABLE_ADVANCED_BLEND
-namespace r1000
-{
+// namespace r1000
 // HSL blend without advanced blend doesn't happen.
-}
diff --git a/renderer/shaders/minify.py b/renderer/shaders/minify.py
index 205bdba..df0a039 100644
--- a/renderer/shaders/minify.py
+++ b/renderer/shaders/minify.py
@@ -74,7 +74,8 @@
     return tok
 
 def t_IFDEF(tok):
-    r"\#[ \t]*ifdef[ \t]+(?P<id>\@?[A-Za-z_][A-Za-z0-9_]*)"
+    r"(?P<tag>\#[ \t]*ifn?def)[ \t]+(?P<id>\@?[A-Za-z_][A-Za-z0-9_]*)"
+    tok.ifdef_tag = re.match(t_IFDEF.__doc__, tok.value)['tag']
     tok.ifdef_id = re.match(t_IFDEF.__doc__, tok.value)['id']
     parse_id(tok.ifdef_id, tok.lexer.exports)
     return tok
@@ -401,7 +402,8 @@
                     out.write(tok.define_val)
 
             elif tok.type == "IFDEF":
-                out.write("#ifdef ")
+                out.write(tok.ifdef_tag)
+                out.write(' ')
                 out.write(new_names[tok.ifdef_id]
                           if rename_exported_defines or tok.ifdef_id[0] != '@'
                           else tok.ifdef_id[1:])
diff --git a/renderer/shaders/tessellate.glsl b/renderer/shaders/tessellate.glsl
index fb8b2e4..04c3aef 100644
--- a/renderer/shaders/tessellate.glsl
+++ b/renderer/shaders/tessellate.glsl
@@ -39,6 +39,11 @@
 }
 
 #ifdef @VERTEX
+VERTEX_TEXTURE_BLOCK_BEGIN(VertexTextures)
+TEXTURE_RGBA32UI(1) @pathTexture;
+TEXTURE_RGBA32UI(2) @contourTexture;
+VERTEX_TEXTURE_BLOCK_END
+
 float cosine_between_vectors(float2 a, float2 b)
 {
     // FIXME(crbug.com/800804,skbug.com/11268): This can overflow if we don't normalize exponents.
@@ -52,28 +57,45 @@
     @tessellateVertexMain,
     Varyings,
     varyings,
-    uint GLSL_VERTEX_ID [[vertex_id]],
-    uint GLSL_INSTANCE_ID [[instance_id]],
+    uint VERTEX_ID [[vertex_id]],
+    uint INSTANCE_ID [[instance_id]],
     constant @Uniforms& uniforms [[buffer(0)]],
-    constant Attrs* attrs [[buffer(1)]]
+    constant Attrs* attrs [[buffer(1)]],
+    VertexTextures textures
 #endif
 )
 {
-    ATTR_LOAD(float4, attrs, attr_p0p1, GLSL_INSTANCE_ID);
-    ATTR_LOAD(float4, attrs, attr_p2p3, GLSL_INSTANCE_ID);
-    ATTR_LOAD(uint4, attrs, attr_args, GLSL_INSTANCE_ID);
-    ATTR_LOAD(float4, attrs, attr_joinTangent, GLSL_INSTANCE_ID);
+    ATTR_LOAD(float4, attrs, attr_p0p1, INSTANCE_ID);
+    ATTR_LOAD(float4, attrs, attr_p2p3, INSTANCE_ID);
+    ATTR_LOAD(uint4, attrs, attr_args, INSTANCE_ID);
+    ATTR_LOAD(float4, attrs, attr_joinTangent, INSTANCE_ID);
 
     float4x2 p = float4x2(attr_p0p1.xy, attr_p0p1.zw, attr_p2p3.xy, attr_p2p3.zw);
     float x0 = float(int(attr_args.x << 16) >> 16);
     float x1 = float(int(attr_args.x) >> 16);
     float y = float(attr_args.y);
-    float2 coord =
-        float2((GLSL_VERTEX_ID & 1) == 0 ? x0 : x1, (GLSL_VERTEX_ID & 2) == 0 ? y : y + 1.);
+    float2 coord = float2((VERTEX_ID & 1) == 0 ? x0 : x1, (VERTEX_ID & 2) == 0 ? y : y + 1.);
 
     uint parametricSegmentCount = attr_args.z & 0x3ffu;
     uint polarSegmentCount = (attr_args.z >> 10) & 0x3ffu;
     uint joinSegmentCount = attr_args.z >> 20;
+    uint outContourIDWithFlags = attr_args.w;
+    if ((outContourIDWithFlags & CULL_EXCESS_TESSELLATION_SEGMENTS_FLAG) != 0u)
+    {
+        // This span may have more tessellation vertices allocated to it than necessary (e.g.,
+        // outerCurve patches all have a fixed patch size, regardless of how many segments the curve
+        // actually needs). Re-run Wang's formula to figure out how many segments we actually need,
+        // and make any excess segments degenerate by co-locating their vertices at T=0.
+        uint pathIDBits =
+            TEXEL_FETCH(textures, @contourTexture, contour_texel_coord(outContourIDWithFlags), 0).z;
+        float2x2 matrix = make_float2x2(
+            uintBitsToFloat(TEXEL_FETCH(textures, @pathTexture, path_texel_coord(pathIDBits), 0)));
+        float2 d0 = matrix * (-2. * p[1] + p[2] + p[0]);
+        float2 d1 = matrix * (-2. * p[2] + p[3] + p[1]);
+        float m = max(dot(d0, d0), dot(d1, d1));
+        float n = max(ceil(sqrt(.75 * 4. * sqrt(m))), 1.);
+        parametricSegmentCount = min(uint(n), parametricSegmentCount);
+    }
     // Polar and parametric segments share the same beginning and ending vertices, so the merged
     // *vertex* count is equal to the sum of polar and parametric *segment* counts.
     uint totalVertexCount = parametricSegmentCount + polarSegmentCount + joinSegmentCount - 1u;
@@ -96,7 +118,6 @@
                                  float(totalVertexCount),                // totalVertexCount
                                  (joinSegmentCount << 10) | parametricSegmentCount,
                                  radsPerPolarSegment);
-    uint outContourIDWithFlags = attr_args.w;
     if (joinSegmentCount > 1u)
     {
         float2x2 joinTangents = float2x2(tangents[1], attr_joinTangent.xy);
@@ -119,8 +140,8 @@
     }
     FLD(varyings, contourIDWithFlags) = outContourIDWithFlags;
 
-    GLSL_POSITION.xy = coord * float2(2. / TESS_TEXTURE_WIDTH, uniforms.tessInverseViewportY) - 1.;
-    GLSL_POSITION.zw = float2(0, 1);
+    POSITION.xy = coord * float2(2. / TESS_TEXTURE_WIDTH, uniforms.tessInverseViewportY) - 1.;
+    POSITION.zw = float2(0, 1);
     EMIT_OFFSCREEN_VERTEX(varyings);
 }
 #endif
@@ -194,7 +215,7 @@
         outContourIDWithFlags |= radsPerPolarSegment < .0 ? LEFT_JOIN_FLAG : RIGHT_JOIN_FLAG;
     }
 
-    float2 strokeCoord;
+    float2 tessCoord;
     float theta;
     if (mergedVertexID == .0 || mergedVertexID == mergedSegmentCount ||
         (outContourIDWithFlags & JOIN_TYPE_MASK) != 0u)
@@ -202,9 +223,18 @@
         // Tessellated vertices at the beginning and end of the strip use exact endpoints and
         // tangents. This ensures crack-free seaming between instances.
         bool isTan0 = mergedVertexID < mergedSegmentCount * .5;
-        strokeCoord = isTan0 ? p[0] : p[3];
+        tessCoord = isTan0 ? p[0] : p[3];
         theta = atan2(isTan0 ? tangents[0] : tangents[1]);
     }
+    else if ((outContourIDWithFlags & RETROFITTED_TRIANGLE_FLAG) != 0u)
+    {
+        // This cubic should actually be drawn as the single, non-AA triangle: [p0, p1, p3].
+        // This is used to squeeze in more rare triangles, like "breadcrumb" triangles from
+        // self-intersections on interior triangulation, where it wouldn't be worth it to put them
+        // in their own dedicated draw call.
+        tessCoord = p[1];
+        theta = .0;
+    }
     else
     {
         float T, polarT;
@@ -327,7 +357,7 @@
         float2 cd = unchecked_mix(p[2], p[3], T);
         float2 abc = unchecked_mix(ab, bc, T);
         float2 bcd = unchecked_mix(bc, cd, T);
-        strokeCoord = unchecked_mix(abc, bcd, T);
+        tessCoord = unchecked_mix(abc, bcd, T);
 
         // If we went with T=parametricT, then update theta. Otherwise leave it at the polar theta
         // found previously. (In the event that parametricT == polarT, we keep the polar theta.)
@@ -335,6 +365,6 @@
             theta = atan2(bcd - abc);
     }
 
-    EMIT_FRAG_DATA(uint4(floatBitsToUint(float3(strokeCoord, theta)), outContourIDWithFlags));
+    EMIT_FRAG_DATA(uint4(floatBitsToUint(float3(tessCoord, theta)), outContourIDWithFlags));
 }
 #endif