Bringing 3D Cards into Rive: What GPU Canvas Actually Changes

Back to blog
Mehran Mozaffari·

youtube.com

What Rive's GPU Canvas Actually Adds Under the Hood

The first thing to understand is what GPU Canvas is not. It is not a 3D engine, and it doesn't pretend to be one. It's a low-level GPU abstraction layer that sits directly on top of the Rive Renderer — the thing that already draws every vector path, gradient, and blend mode in your .riv files. What it adds is the ability to import meshes, write custom vertex and fragment shaders, and run those passes from within the same artboard that's rendering your 2D text and buttons.

That distinction matters more than it sounds. When I see teams evaluate this, the mental model that breaks them is assuming they're getting "3D support in Rive" the way Unity would give you a scene. You're not. You're getting a shader pass and a mesh transform that shares a coordinate space and a render target with your vector artwork.

Under the hood, the shader passes and 3D geometry get scheduled through the exact same draw-order and batching pipeline as your vector layers. Nothing lives on a separate canvas or an overlaid <canvas> element. Your text, your gradient foil mask, your imported mesh — all of it gets composited into one render target, then compiled down to whatever graphics backend the runtime has available: Metal, Vulkan, Direct3D 11/12, WebGPU, or WebGL.

That's why the footprint is the headline. The research I've seen puts the incremental cost at roughly 25KB added to the core runtime binary. I want to be careful not to overstate this — that's a delta, not a total size, and it doesn't include your art assets. But even with that caveat, 25KB is the difference between "we can ship this in a card component" and "we need to discuss app size budgets with the mobile team." Compare that to embedding a Unity instance, which the same research pegs at 30–100MB+, and you start to see the real decision surface.

The tradeoff is equally concrete: you get meshes, shaders, and 3D transforms inside a 2D artboard. You do not get a physics engine, complex global illumination, cascaded shadow maps, or even basic spatial acceleration structures. There's no octree, no BVH, no occlusion culling. You're not building a game level — you're building a card that tilts and catches light.

The practical boundary I'd draw: GPU Canvas is for the "hero micro-interaction" tier — Pokémon Pocket-style card reveals, interactive product cards, dynamic badges. It is not for anything that needs real scene depth, intersecting geometry at scale, or multi-pass rendering. If you're stacking five meshes that occlude each other and you need the draw order to behave, you'll be fighting the renderer. That's a genuinely different problem class, and the tool doesn't claim to solve it.

flowchart TD
    A[Rive Artboard] --> B[2D Vector Layers]
    A --> C[GPU Canvas Pass]
    B --> D[Text Layer]
    B --> E[UI Button Layer]
    B --> F[Vector Foil Mask Layer]
    C --> G[Imported Mesh]
    G --> H[Custom Fragment Shader]
    D --> I[Shared Draw-Order & Batching Pipeline]
    E --> I
    F --> I
    H --> I
    I --> J[Single Render Target]
    J --> K[Metal]
    J --> L[Vulkan]
    J --> M[Direct3D 11/12]
    J --> N[WebGPU]
    J --> O[WebGL]

How a 3D Card Actually Gets Built: From Import to State Machine

The pipeline for a Pokémon Pocket-style card is worth walking through, because a lot of teams assume it's more complicated than it is. Start in the Rive Editor. You import a mesh — for a card, this is going to be a simple extruded plane or a box with subtle bevels, not a high-poly sculpt. You assign a custom material to it: holographic foil, iridescent play, chromatic aberration around the edges. The shader is the thing that makes the card feel like foil rather than just a tilted rectangle. The mesh is almost incidental for something like this.

Once the mesh has a material, the state machine steps in. This is the piece I think people undervalue when they first see GPU Canvas. Rive has a native visual state machine that ships with the runtime — the same one you'd use to drive button pressed states or hover transitions. With GPU Canvas active, that state machine can drive 3D transforms and uniform values. Pointer position becomes an input. Gyro data becomes an input. The state machine evaluates those inputs and binds deltas to mesh rotation and shader uniforms.

The runtime setup for this is deliberately minimal: you initialize the Rive worker with enableGPUCanvas: true at worker creation. Once that flag is set, any .riv file processed by that worker instance can execute GPU passes and evaluate 3D geometry. I've seen the flag described as worker-level rather than file-level, which matters if you're managing multiple Rive instances — it's a global decision about what that worker is capable of, not a per-file switch.

What I don't want to do here is invent shader syntax. The research I have doesn't confirm the exact shading language — whether it's a proprietary dialect, WGSL, or a custom GLSL-to-IR transpiler seems to vary between runtimes. What I can tell you with confidence is that the mechanism works through uniforms and transforms bound by the state machine. You don't need a snippet in this article to understand the architecture.

Here's why the state machine framing matters: when you build this with GPU Canvas, the tilt response and foil behavior aren't hand-coded interpolation loops in JavaScript or Swift. They're designer-editable logic. A designer can adjust the range of motion, the speed of response, the intensity of the holographic shift — all in the visual editor, without touching a line of runtime code. That's the actual product differentiator compared to Three.js or React Three Fiber, where every animation curve and shader uniform is code.

The card doesn't need a full 3D scene. It needs a mesh, a material, and a state machine that knows how to map input to transform. That's the whole recipe — and it's small enough that it actually fits inside a UI component.

sequenceDiagram
    participant Device as Device Input (Gyroscope / Pointer Move)
    participant Worker as Rive Worker
    participant SM as State Machine
    participant GPU as GPU Canvas Pass
    participant Artboard as 2D Artboard
    
    Device->>Worker: enableGPUCanvas: true (worker init)
    Device->>SM: Raw gyro delta / pointer position
    SM->>SM: Evaluate input against state machine logic
    SM->>GPU: Bind delta values to mesh transform & shader uniform
    GPU->>GPU: Render holofoil fragment shader across tilted mesh
    GPU->>Artboard: Composite into same draw order as 2D vector layers
    Artboard-->>Device: Presented at frame rate

Where This Sits Against the Alternatives You'd Actually Compare It To

The comparison set is smaller than people expect. You're not choosing between "Rive with GPU Canvas" and "Unreal Engine." You're choosing between a handful of approaches that each solve a different slice of the problem — and the choice is mostly about who's editing the visuals and how heavy the runtime can be.

Spline is the closest direct competitor, and the difference is philosophical. Spline is fundamentally 3D-first: you build a scene, and it tries to behave like a UI component. That makes complex lighting and material setup easier in the editor — it's a real 3D design tool. But the runtime cost is significant, often hundreds of KB to several megabytes of bundled Three.js or equivalent runtime, and it lacks Rive's deterministic state-machine logic and sub-pixel vector rendering fidelity. If your card needs to sit in a feed alongside a dozen other cards, per-card runtime size matters. Spline's footprint is a real constraint there.

Three.js and React Three Fiber give you full control over shaders and render passes. I've used this approach enough to respect it — it's the most flexible option, and there's no vendor lock-in. Every animation curve, every interpolation, every transition has to be hand-coded. Designers can't iterate visually. It's code-first developer tooling, and it requires an engineer to translate any visual intent into executable logic. If you're working without a designer in the loop, or the design is fully specified and never changes, this is fine. If you're iterating on a card's feel — and cards are all about feel — the friction becomes the bottleneck.

Embedded Unity or Godot is the overkill option. Full PBR, physics, animation blending, real asset pipelines. The binary bloat is 30–100MB+, cold-start latency is real, and UI layout integration is painful. I've never seen a card component justify this, and the research I have makes the cost explicit. There's a version of this that works if you're building an entire 3D minigame alongside the card, but for the card itself it's the wrong tool.

Flutter and React Native Skia shaders are interesting because they're already in the UI framework — zero incremental footprint. But they're fragment-only. You can apply iridescent foil to a widget, but you can't easily ingest a mesh with UVs and skeletal hierarchies, and everything is code-orchestrated without a visual timeline. It's a shader filter, not a 3D scene.

Here's the table, and I want you to read it as a decision tool, not a summary:

Feature / Dimension Rive (GPU Canvas) Spline Three.js / React Three Fiber Embedded Unity / Godot Flutter / RN Skia Shaders
Runtime Footprint Overhead Minimal (~25KB delta) Moderate (~500KB–2MB+) Moderate (~150KB–600KB) Massive (30–100MB+) Zero (built into framework)
Primary Workflow Visual Editor + State Machine Visual 3D Editor Code-only Visual Editor (Unity/Godot) Code-only
3D Complexity Low-to-Medium (meshes, card tilt, shaders) Medium (scenes, materials, lights) High (arbitrary 3D, post-processing) Maximum (full engine features) Fragment effects only
State Machine Support Native visual state machines, designer-editable Basic event triggers / actions Hand-coded in JS/TS C# / GDScript Hand-coded in app logic
Cross-Platform Parity Web, iOS, Android, Flutter, C++, Rust Web, React, iOS Web primarily (or through WebView) Native iOS/Android/Web/Desktop Native (Flutter/RN ecosystems)

The reason ~25KB is the headline is that it changes what's possible to deploy. If your card is one of fifty in a collection view, you're not shipping it per-card — you're shipping one runtime. That's the difference between "we can add this to our production app tomorrow" and "we need a separate app." The tradeoff is exactly what the table shows: no physics, no complex global illumination, no cascaded shadow maps, and a 3D tooling ecosystem that's still maturing compared to Blender or Unreal's shader graph.

I'd reach for Rive GPU Canvas when the visual is a hero element that needs to feel alive but the surrounding UI is standard 2D. I'd reach for Three.js when I need full shader control and there's no designer in the loop. I'd reconsider Spline if the card needs to be a true 3D scene with objects genuinely moving in depth. And I'd use embedded Unity only if the card is part of a larger game — never for the card alone.

The Failure Modes That Bit Me: Occlusion, Draw Order, and Z-Fighting

The most dangerous assumption you can make with GPU Canvas is that because it renders 3D, it behaves like a 3D engine. It doesn't. Rive is fundamentally a 2D vector and 2.5D state-machine engine, and that means there are no spatial acceleration structures under the hood. No octrees. No BVH hierarchies. No dynamic occlusion culling. No screen-space ambient occlusion. No unified shadow map cascades. The renderer sorts things by draw order and batching, not by depth.

The practical consequence: if you stack two cards that cross each other during a reveal animation, you're going to get sorting glitches. The renderer has to decide which mesh wins, and without a proper depth buffer to resolve interpenetration, it will make the wrong call — sometimes flickering between frames. The card that should be occluding the other slides in front, then behind, then in front again. It's the kind of artifact that's maddening because it only happens during the exact moment the animation is at its most visually important.

The same problem appears when a 3D mesh passes through a 2D clipping path or a blend mode. You'd think a vector clip would just cut the mesh cleanly, but the 3D pass and the 2D vector compositor don't share a unified depth representation. I've seen this manifest as Z-fighting — the surface oscillates between which side of the clip it appears on — or as segments that pop out entirely, as if the clip path is being ignored for a handful of frames. If your card reveal animation involves a mesh sweeping past a UI panel that has a mask or a blur, budget time for testing this edge case specifically.

The heavy-mesh trap is equally real. GPU Canvas is optimized for UI-scale 3D elements — stylized cards, badges, product displays, avatars. Import a 50k–100k+ vertex mesh with multi-light calculations, subsurface scattering, or complex displacement, and you're in a different performance regime entirely. On a flagship iPhone it might hold 60fps; on a mid-tier Android GPU it'll collapse to single digits. The limit is not the mesh itself but the number of vertex and fragment operations per frame on a device that's already drawing your entire UI.

I'd set a hard budget: under 15k vertices per card, ideally under 5k. Bake everything you can into textures — occlusion, roughness, metallic into a single channel-packed ORM map. Pre-compute lighting where the visual permits it. You are not building a game level. You are building a card that tilts and catches light. Design for that.

The Backend Roulette: Cross-Platform Shader Reality, Context Loss, and Thermal Traps

Shaders are where the cross-platform promises get their real test. The abstraction layer compiles your custom vertex and fragment shaders down to Metal, Vulkan, Direct3D 11/12, WebGPU, or WebGL — but you will find that the exact same shader source does not produce the exact same result across those backends. The first thing to check is float precision. Metal on Apple Silicon and desktop GPUs are happy with highp; low-tier Mali and Adreno chips may default to mediump in fragment shaders, which causes banding on gradient-heavy effects like holographic foil. The difference is subtle in a static test and glaring in a moving card.

Derivative functions — dFdx and dFdy — are another trap. They're commonly used in refraction and edge-detect effects, and their behavior varies across backends and driver generations. What looks like a crisp specular highlight on Metal can become a blocky, aliased smear on a software rasterizer like SwiftShader or on an older Adreno that doesn't implement them as accurately. Add in texture coordinate orientation differences and non-power-of-two texture handling, and you have a recipe for "it works on my machine and nowhere else."

The failure isn't always visual, either. Shaders can outright fail to compile on specific backends, and the failure mode is a blank artboard — no error, no fallback, just white space where your card used to be.

Context loss is the nastiest operational problem. When GPU Canvas runs in a web worker on an OffscreenCanvas, the device can lose the webglcontextlost event at any time: tab backgrounding, memory pressure, a driver reset. When that happens, all your GPU pipelines, buffers, and shader states are invalidated. You need lifecycle handlers that tear down and re-instantiate the Rive worker and rebuild shader state. If you don't wire that, every user who switches away from your app and back gets a black canvas. I'd make that handler a release-blocker during QA, not a nice-to-have.

Then there's the battery trap. Interactive foil and tilt cards bind gyro and pointer deltas into uniforms, and if the state machine keeps the canvas repainting at 60 or 120Hz even when no one is touching the card, you're draining battery on a screen that the user might be looking at but not interacting with. The fix is an idle threshold: below a certain delta epsilon, stop updating uniforms and let the state machine sleep. Only repaint when there's an actual transition or when input exceeds the threshold. I've seen production teams ship cards that look gorgeous and drain 20% of a phone's battery in an hour because nobody thought about frame throttling.

The operational refrain is: GPU Canvas is not a magic layer that makes cross-platform GPU work free. It shortens the distance, but the discipline — testing on real devices, handling context loss, throttling idle frames — is yours to enforce.

Asset Pipeline and Memory: Where the Real Inefficiency Lives

The 3D asset pipeline is where the production decision gets made, and it's the part most teams underestimate. A 3D artist exporting from Blender or Maya will naturally produce a mesh with complex node graphs, multiple material slots, and unoptimized geometry — the same thing they'd hand to Unity's importer. GPU Canvas will accept that mesh, and it will render it. What it won't do is tell you that you've just shipped a card with 80k vertices and four separate materials, each pulling its own texture set, into a feed that instantiates twenty of them at once.

The budgets I'd set are rigid: 5k–15k vertices per card, ideally at the lower end. Unified texture atlasing — one packed texture per card, not four. Bake lighting and specular information into channel-packed maps; an ORM (occlusion/roughness/metallic) texture that packs three channels into one image is standard practice and should be non-negotiable here. The visual difference between a properly baked 10k-vertex card and a 50k-vertex one is negligible on a mobile screen; the performance difference is not.

The memory trap compounds in list views. Baking uncompressed 2K or 4K textures directly into .riv files can make a card asset itself weigh hundreds of KB, and when a collection grid instantiates a dozen cards, each holding its own VRAM-resident textures, you hit the memory wall fast. This is exactly why a list-view-optimized card reveal component needs a virtualization strategy. The pattern I'd build: a Rive-based card grid where each cell uses the same mesh and shader — a shared asset loaded once — but only visible cells hold active GPU Canvas workers. A unified rendering manager in the host app (Flutter, iOS, or Web) decides which cards are actually on screen and only those get active worker instances. Frame-throttling kicks in when no pointer or gyro movement occurs: the state machine sleeps, no repaints, no GPU cost.

The danger is creating a separate Rive Worker per card. That's the naive reading of the API — one component, one worker — and it's the surest way to blow memory and tank frame rate. The worker is the expensive resource, not the artboard. Virtualize the instances, share the worker, and treat the mesh and shader as a single reference that all cards point to.

Dynamic content injection is the other memory-adjacent problem. If you want a user's face on the card — the product-configurator use case — you bind a bitmap to a shader sampler. That means keeping a RenderTexture or canvas bitmap alive in runtime memory and passing it to the GPU as a uniform. The question that matters is not whether the API supports it (it does, conceptually) but how you upload it without stalling the main thread. Binding a large bitmap synchronously during a frame can block GPU uploads and cause visible hitches; use async uploads or pre-allocated texture handles that you update in place.

The fallback pattern is non-negotiable. If GPU Canvas initialization fails on a low-tier Android device, or the foil shader can't compile on a particular backend, the card must degrade gracefully to a 2D vector state in the same Rive file. You design that fallback state from day one, not when QA finds the blank card on a Galaxy A12.

Resources

(no official sources were available to link)

Updated 2026-09-07 by Mehran Mozaffari.

Related posts