# How Telegram-iOS Uses the Metal Rendering Engine for Animated Stickers

> Discover how Telegram-iOS enhances animated stickers with the Metal rendering engine, utilizing a custom GPU pipeline for efficient vector rasterization and pre-serialized frame caches.

- Repository: [TelegramMessenger/Telegram-iOS](https://github.com/TelegramMessenger/Telegram-iOS)
- Tags: internals
- Published: 2026-04-07

---

**Telegram-iOS renders animated stickers through a custom Metal-based pipeline that delegates vector rasterization to the GPU using a subject-layer architecture with pre-serialized frame caches.**

The [TelegramMessenger/Telegram-iOS](https://github.com/TelegramMessenger/Telegram-iOS) repository implements high-performance animated stickers by bypassing Core Animation's CPU-bound limitations in favor of a dedicated **Metal rendering engine**. This system leverages pre-computed vector data, aggressive resource pooling, and direct GPU command encoding to deliver smooth Lottie animations while minimizing power consumption.

## Three-Layer Architecture

The implementation separates concerns across three distinct layers to isolate GPU complexity from the UI codebase.

### Public Interface Layer

The **AnimatedStickerNode** protocol defines the contract used by UI components throughout the app. It exposes playback controls—`play()`, `pause()`, and frame callbacks—without exposing Metal specifics. The concrete implementation `DefaultAnimatedStickerNodeImpl` (located in [`submodules/TelegramUI/Sources/AnimatedStickerNode/DefaultAnimatedStickerNodeImpl.swift`](https://github.com/TelegramMessenger/Telegram-iOS/blob/main/submodules/TelegramUI/Sources/AnimatedStickerNode/DefaultAnimatedStickerNodeImpl.swift)) acts as a thin wrapper that instantiates the Metal-backed renderer.

### Metal Implementation Layer

**LottieMetalAnimatedStickerNode** (in [`submodules/TelegramUI/Components/LottieMetal/Sources/LottieMetalAnimatedStickerNode.swift`](https://github.com/TelegramMessenger/Telegram-iOS/blob/main/submodules/TelegramUI/Components/LottieMetal/Sources/LottieMetalAnimatedStickerNode.swift)) handles the actual animation logic. During `setup(source:width:height:playbackMode:mode:)`, it loads either a pre-serialized Metal frame cache (`.metalframe` files) or falls back to classic Lottie JSON parsing. This layer creates a **LottieContentLayer**, a subclass of `MetalEngineSubjectLayer`, which serves as the render target.

### Engine Layer

The **MetalEngine** ([`submodules/MetalEngine/Sources/MetalEngine.swift`](https://github.com/TelegramMessenger/Telegram-iOS/blob/main/submodules/MetalEngine/Sources/MetalEngine.swift)) manages the GPU lifecycle. It maintains a singleton instance (`MetalEngine.shared`) that coordinates surface allocation, texture pooling, and command buffer submission. The engine implements a subject-layer model where renderable objects conform to the `MetalEngineSubject` protocol and receive `update(context:)` calls via **MetalEngineSubjectContext**.

## Rendering Pipeline Execution

When an animated sticker becomes visible, the system executes a ten-step pipeline to convert vector data into a screen-ready texture.

### Node Initialization and Setup

UI code instantiates `DefaultAnimatedStickerNodeImpl`, which immediately allocates a `LottieMetalAnimatedStickerNode` internally. The wrapper forwards all playback commands to this inner node while presenting a unified `AnimatedStickerNode` interface to the rest of the application.

The `setup(...)` method stores the target dimensions and playback mode, then creates an **AnimatedStickerNodeSource**. If a Metal-cached frame file exists at the specified path, the node reads the pre-serialized binary; otherwise, it prepares to parse the Lottie JSON (though the JSON path is currently disabled in production builds).

### Layer Allocation and the Subject Model

The node creates a **LottieContentLayer**, which inherits from `MetalEngineSubjectLayer`. This layer subclasses `CALayer` but overrides its rendering behavior to participate in the Metal engine's update cycle rather than using Core Animation's default bitmap backing.

When the sticker needs redrawing—either due to visibility changes or frame progression—the layer calls `setNeedsUpdate()`. This method, defined in the `MetalEngineSubject` protocol, inserts the layer into the engine's dirty-subject queue without immediately performing GPU work.

### GPU Command Encoding

On the next run-loop iteration, `MetalEngine.shared.impl.display()` constructs a `MetalEngineSubjectContext` and iterates over all queued subjects. For each sticker, it invokes `LottieContentLayer.update(context:)`, passing pooled resources and command buffer access.

Inside `update(context:)`, the layer calculates the current frame index and retrieves a **LottieRenderNode** from the serialized cache. The geometry data flows through **PathRenderContext**, which encodes vector-to-Metal shader commands into the GPU command buffer. This step converts Bezier curves into rasterized triangle data.

The layer encodes two distinct passes:
- **Compute Pass**: Writes vector vertex data into a texture using compute shaders (lines 749–789 in `LottieContentLayer.update`)
- **Render Pass**: Executes blit vertex/fragment shaders to resolve the multisample texture into the final `outTexture` (lines 800–845)

Both passes record into the same `MTLCommandBuffer` created by the engine.

### Surface Allocation and Texture Pooling

The engine allocates intermediate textures using `MetalEngine.shared.pooledTexture`, which recycles **PooledTexture** objects to avoid expensive `makeTexture` allocations. The system requests three textures typically: a multisample buffer, a temporary scratch texture, and the final output surface.

`MetalEngine` maintains a set of large IOSurface-backed textures (the **Surface** class). Each `MetalEngineSubjectLayer` receives only a slice (sub-rect) of a shared surface, drastically reducing the number of GPU surface switches and memory fragmentation during complex UI scenes with multiple stickers.

### Final Composition and Display

After GPU work completes, `MetalEngineSubjectContext.renderToLayer` selects a matching surface allocation from the engine's pool. The engine sets a scissor rect and draws a full-screen triangle sampling `outTexture`. The resulting IOSurface-backed texture attaches to the layer's `contents` property, making it visible to Core Animation's compositor.

Because `LottieContentLayer` is a standard `CALayer` subclass nested in the view hierarchy, the system composites the GPU-produced texture alongside other UI elements using the normal render server path. The `MetalEngine.rootLayer` property maintains the global coordinate space for all Metal-rendered content.

## Performance Optimizations

The **Metal rendering engine** achieves its efficiency through several architectural optimizations that minimize CPU overhead and GPU memory pressure.

### Pre-Serialized Frame Caches

For production stickers, Telegram performs heavy vector rasterization offline using a build-time tool that generates `*.metalframe` files. At runtime, `LottieContentLayer` copies pre-computed vertex and fragment data directly into GPU buffers, eliminating per-frame Bezier parsing. This makes animated sticker playback **ultra-fast and low-power** compared to traditional Lottie rendering.

### Resource Recycling

The engine implements **PooledTexture** and **PooledBuffer** classes that recycle a fixed set of `MTLTexture` and `MTLBuffer` objects across frames. By reusing these resources through `MetalEngine.shared.pooledTexture` and `pooledBuffer`, the system avoids the allocation latency typically associated with real-time graphics.

## Practical Implementation Example

The following Swift code demonstrates how a view controller creates and displays a Metal-rendered sticker:

```swift
import UIKit
import TelegramUI

class StickerViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 1️⃣ Create the Metal-backed node
        let stickerNode = DefaultAnimatedStickerNodeImpl()
        
        // 2️⃣ Configure with Metal cache source
        stickerNode.setup(
            source: AnimatedStickerNodeLocalFileSource(name: "funny_sticker"),
            width: 128,
            height: 128,
            playbackMode: .loop,
            mode: .direct(cachePathPrefix: nil)
        )
        
        // 3️⃣ Add to view hierarchy
        view.layer.addSublayer(stickerNode.layer)
        stickerNode.frame = CGRect(x: 50, y: 50, width: 128, height: 128)
        
        // 4️⃣ Control visibility
        stickerNode.autoplay = true
        stickerNode.visibility = true
    }
}

```

To render a single static frame (for thumbnails):

```swift
stickerNode.playbackMode = .once
stickerNode.visibility = true
stickerNode.frameUpdated = { currentFrame, totalFrames in
    print("Rendered frame \(currentFrame) of \(totalFrames)")
}

```

Internally, these calls trigger `setNeedsUpdate()` → `MetalEngine.display()` → `LottieContentLayer.update(context:)`, executing the compute and render passes described above.

## Summary

- **Telegram-iOS** implements animated stickers via a custom **Metal rendering engine** built on the `MetalEngine` framework.
- The architecture separates the public **AnimatedStickerNode** API from the Metal-specific **LottieMetalAnimatedStickerNode** implementation.
- **LottieContentLayer** (a `MetalEngineSubjectLayer` subclass) rasterizes vector data using pre-serialized `*.metalframe` caches.
- The engine pools **IOSurface-backed textures** and recycles GPU buffers via `PooledTexture` to eliminate per-frame allocations.
- Rendering occurs through a two-pass GPU pipeline (compute then render) that outputs directly to a `CALayer` contents property for seamless UI integration.

## Frequently Asked Questions

### How does Telegram-iOS achieve 60fps performance with complex Lottie animations?

Telegram pre-renders sticker vectors into serialized `*.metalframe` files during the build process. At runtime, the **Metal rendering engine** copies pre-computed geometry directly into GPU buffers rather than parsing JSON or calculating Bezier curves per frame. This eliminates CPU bottlenecks and allows the GPU to handle all rasterization via compute shaders encoded in `LottieContentLayer.update(context:)`.

### What is the difference between `AnimatedStickerNode` and `LottieMetalAnimatedStickerNode`?

**AnimatedStickerNode** is a protocol defining the public interface for playback control, implemented by `DefaultAnimatedStickerNodeImpl`. **LottieMetalAnimatedStickerNode** is the concrete Metal implementation that conforms to this protocol internally. This abstraction allows UI code to remain agnostic of GPU details while the actual rendering logic resides in [`submodules/TelegramUI/Components/LottieMetal/Sources/LottieMetalAnimatedStickerNode.swift`](https://github.com/TelegramMessenger/Telegram-iOS/blob/main/submodules/TelegramUI/Components/LottieMetal/Sources/LottieMetalAnimatedStickerNode.swift).

### Why does the engine use `PooledTexture` instead of creating textures per frame?

Creating `MTLTexture` objects incurs significant GPU driver overhead and memory allocation latency. The **MetalEngine** maintains a pool of reusable textures accessed via `MetalEngine.shared.pooledTexture`, which eliminates these costs. This pooling strategy is critical for smooth performance when rendering multiple animated stickers simultaneously in chat interfaces.

### How does `LottieContentLayer` integrate with standard UIKit views?

`LottieContentLayer` inherits from `MetalEngineSubjectLayer`, which is a `CALayer` subclass. After the **Metal rendering engine** completes its GPU passes, `renderToLayer` attaches the resulting IOSurface-backed texture to the layer's `contents` property. This allows Core Animation's standard compositor to treat the Metal-rendered sticker like any other layer-backed view, requiring no special container views or Metal-aware view controllers.