Skip to content

Metal Drawable Accumulation after extended time leading to total degradation of performance #413

@ivg-design

Description

@ivg-design

Rive Runtime Bug Report: Metal Drawable Accumulation (Not Recycled)

Note: This is NOT a traditional memory leak (Instruments "Leaks" shows no leaks).
This is drawable pool exhaustion - drawables are retained but never returned to the pool for reuse.

Summary

The Rive iOS/macOS runtime accumulates Metal drawables (CAMetalDrawable) that are never released back to the drawable pool, causing GPU memory exhaustion and frame drops after extended runtime.

Environment

Component Version
Platform macOS (Apple Silicon)
Rive Runtime 6.15.1 (rive-ios)
Rive Commit 388cebf
Xcode 26.2 (Build 17C52)
macOS 26.2 (Build 25C56)
Renderer Rive Renderer (experimental, @_spi(RiveExperimental))

Symptoms

  1. Application runs smoothly initially
  2. After several hours of runtime, frame rate gradually degrades
  3. Eventually animation becomes extremely choppy (dropping most frames)
  4. Pause/unpause does NOT fix the issue
  5. Waiting 20+ seconds (longer than 8s auto-trim interval) does NOT fix the issue
  6. Only full application restart resolves the issue

Evidence

Memory Analysis (vmmap)

After ~15 hours of runtime:

IOSurface    2.8G     2.8G     2.8G    0K    0K    2.8G    0K    81
TOTAL        6.8G     4.4G     4.0G   16K    0K    2.8G    0K  6768

2.8 GB of IOSurface memory accumulated (81 IOSurface allocations)

Instruments Profiling (Allocations - Call Tree)

The Instruments Allocations tool with Call Tree view shows the complete call path from app entry to IOSurface creation:

Bytes Used    %     Count  Symbol                                          Library
──────────────────────────────────────────────────────────────────────────────────────
644.95 MB  100.0%  62111   __debug_main_executable_dylib_entry_point       RWPP
644.95 MB  100.0%  62111   └─ static App.main()                            SwiftUI
643.70 MB   99.8%  57061   └─ NSApplicationMain                            AppKit
643.75 MB   99.8%  57622   └─ -[NSApplication run]                         AppKit
    ...                       (event loop)
626.78 MB   97.1%  31416   └─ -[MTKView draw]                              MetalKit
626.78 MB   97.1%  31414     └─ @objc RiveView.draw(_:)                    RiveRuntime ← RIVE
626.78 MB   97.1%  31410       └─ -[RiveRendererView drawRect:]            RiveRuntime ← RIVE
626.77 MB   97.1%  31407         └─ -[RiveRendererView drawInRect:withCompletion:]  RiveRuntime ← RIVE
606.00 MB   93.9%    715           └─ -[MTKView currentDrawable]           MetalKit
606.00 MB   93.9%    715             └─ -[CAMetalLayer nextDrawable]       QuartzCore
605.87 MB   93.9%    656               └─ CAMetalLayerPrivateNextDrawableLocked  QuartzCore
605.83 MB   93.9%     95                 └─ get_unused_drawable            QuartzCore
605.81 MB   93.9%     17                   └─ CA::Render::create_iosurface_with_pixel_format  QuartzCore
605.81 MB   93.9%     13                     └─ CA::SurfaceUtil::CAIOSurfaceCreate  QuartzCore
605.81 MB   93.9%     13                       └─ IOSurfaceCreate          IOSurface
605.81 MB   93.9%      9                         └─ -[IOSurface initWithProperties:]  IOSurface
605.81 MB   93.9%      9                           └─ IOSurfaceClientCreateChild  IOSurface

The bug is in RiveRendererView's drawInRect:withCompletion: method - it obtains drawables via currentDrawable but they are never returned to the pool.

Key Finding

Metric Expected Actual
Drawables created 2-3 (triple buffering) 715
IOSurface memory ~2.5 MB (3 × ~850KB) 2.8 GB
Drawable recycling Yes No

715 Metal drawables were created but NONE were recycled back to the pool.

No Traditional Leaks Detected

Instruments "Leaks" instrument shows green checkmarks (no leaks detected). This confirms:

  • The drawables are still referenced (not orphaned pointers)
  • They are simply never released back to the drawable pool
  • This is a drawable lifecycle management issue, not a missing free()

Root Cause Analysis

Based on the call stack, the bug is in RiveRendererView (RiveRuntime):

Call Chain

-[MTKView draw]                                   // MetalKit calls this each frame
  └─ @objc RiveView.draw(_:)                      // Rive's RiveView
    └─ -[RiveRendererView drawRect:]              // Rive's renderer view
      └─ -[RiveRendererView drawInRect:withCompletion:]  // ← BUG IS HERE
        └─ -[MTKView currentDrawable]             // Gets drawable (715 times!)

What's Happening

  1. Each frame, RiveRendererView drawInRect:withCompletion: calls currentDrawable
  2. The drawable is used for rendering
  3. After rendering, the drawable is NOT returned to the CAMetalLayer's drawable pool
  4. On the next frame, nextDrawable finds no available drawables in the pool
  5. get_unused_drawable creates a NEW drawable backed by a new IOSurface
  6. This repeats every frame, accumulating drawables indefinitely

Likely Bug Location

The bug is likely in one of these Rive files:

  • RiveRendererView.mm or RiveRendererView.swift
  • Specifically in the drawInRect:withCompletion: method
  • Or in how the completion handler manages the drawable

Possible Causes

  1. Missing presentDrawable: - The drawable may not be presented to the display
  2. Command buffer not committed - The MTLCommandBuffer may not be committed properly
  3. Drawable retained in completion block - The completion handler may be holding the drawable
  4. Strong reference cycle - withCompletion: block may capture the drawable strongly
  5. Drawable stored but never released - Some internal tracking may retain drawables

Expected Metal Drawable Lifecycle

1. drawable = [metalLayer nextDrawable]     // Get from pool (or create if empty)
2. texture = drawable.texture               // Use for rendering
3. [commandBuffer presentDrawable:drawable] // Schedule presentation
4. [commandBuffer commit]                   // Submit to GPU
5. // On completion: drawable automatically returns to pool
6. // Next frame: step 1 reuses the same drawable

Actual Behavior (Bug)

1. drawable = [metalLayer nextDrawable]     // Pool empty, creates new
2. texture = drawable.texture               // Use for rendering
3. [commandBuffer presentDrawable:drawable] // Maybe not called?
4. [commandBuffer commit]                   // Maybe not called?
5. // Drawable NEVER returns to pool
6. // Next frame: pool still empty, creates ANOTHER new drawable
7. // Repeat 715+ times until GPU memory exhausted

Reproduction Steps

  1. Create an app using RiveRuntime with @_spi(RiveExperimental) renderer
  2. Load a Rive animation (especially one with Luau scripting that draws paths)
  3. Let it run continuously for several hours
  4. Monitor memory: watch -n 60 'vmmap -summary $(pgrep AppName) | grep IOSurface'
  5. Observe IOSurface memory growing continuously (~1-2 MB per minute)
  6. After several hours, animation becomes choppy due to drawable starvation

Workaround

The only effective workaround is to periodically destroy and recreate all RiveView instances:

// 1. Capture screenshot for visual continuity
let screenshot = captureWindow()

// 2. Destroy all Rive objects
riveView.removeFromSuperview()
riveViewModel = nil
riveView = nil

// 3. Wait for deallocation (releases all drawables)
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
    // 4. Recreate views
    riveView = RiveView(...)
    riveViewModel = RiveViewModel(...)
}

This forces deallocation of the CAMetalLayer, which releases all accumulated drawables.

Impact

  • Severity: High
  • User Impact: Application becomes unusable after extended runtime
  • Use Case Affected: Any long-running Rive animation (kiosk displays, wallpapers, dashboards)

Additional Notes

  • The issue does NOT appear to be related to Luau scripting specifically
  • The issue is in the core Metal rendering pipeline
  • RenderContext's 8-second auto-trim does not affect drawable pool
  • releaseResources() is only called on RenderContext dealloc, not periodically

Image

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions