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

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 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) acts as a thin wrapper that instantiates the Metal-backed renderer.

Metal Implementation Layer

LottieMetalAnimatedStickerNode (in 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) 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:

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):

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.

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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →