Unity Prefab Thumbnail Renderer

May 13, 2026 · View on GitHub

Drop-in runtime prefab → Texture2D pipeline for Unity 6. Queue your character / item / vehicle / ship prefabs once, get cached thumbnails — static, yaw-rotating, or animator-clip animated. Async GPU readback (no main-thread stalls), one-frame-per-tick (no spikes), lazy-load tile component for UI Toolkit, built-in spinner + greyscale post-process. Everything you need to power a cosmetic shop, character roster, ship gallery, or item catalog. Open-sourced as part of a small giving-back set of Unity tools — alongside the UI Toolkit design system, the mesh-fracture pipeline, the 3D-to-sprite baker, and the cross-platform build orchestrator.

Leap of Legends Built for and battle-tested in Leap of Legends — a cross-platform multiplayer game in active development on Steam, Google Play (internal testing), TestFlight, and macOS. Every cosmetic thumbnail in the game's store, equip screen, and reward chests is rendered through this pipeline. Wishlist on Steam — public mobile store pages coming soon.

Demo

▶ Live WebGL preview — 25-tile grid spanning all three rendering modes: 4 animated characters (AnimatorClip — run cycle), 10 ships / boats (YawRotation — 360° loop), 11 props (Static). Filter by category, toggle locked-greyscale post-process, switch animated tiles to hover-only playback. Mobile (< 768 CSS-px) reflows to a 2-column grid with a bottom drawer for options + stats.

Unity Prefab Thumbnail Renderer — interactive WebGL preview. 25-tile grid: animated characters run in place, ships spin a full 360°, props render as stills; category filter, locked-greyscale toggle, hover-to-animate toggle, lazy-loading tiles with ds-spinner from the design system.

The repo is a complete Unity project — clone, open in Unity 6, press Play. The demo scene auto-spawns:

  • 4 animated characters (Kenney animated-characters-1 rig + 4 CC0 skins) — AnimatorClip mode, 24 frames sampled across run.fbx. Two are owned (full colour), two are locked (greyscale-dimmed via the bundled ThumbnailPostProcess).
  • 10 ships / boats (Kenney pirate-kit + watercraft-pack) — YawRotation mode, a 24-frame full 360° spin so the player sees every side without dragging.
  • 11 props (chests, barrels, cannons, flags, bottles) — Static mode, the cheap one-frame default.
  • A category filter (All / Chars / Ships / Props) — switching categories cancels in-flight renders for off-tab tiles so the queue prioritises what the user can see.
  • A lazy-load ThumbnailTile element that only queues its render after layout lands the tile in the visible viewport. Scrolling tiles into view triggers their render; tiles never seen pay zero render cost.
  • A ds-spinner from the bundled design system placed inside each tile, fading out the moment the first frame lands.
  • A mobile-friendly UXML/USS layout. Below 768 CSS-px the title / promo / tabs / grid reflow into a single column and the options panel becomes a bottom drawer (ds-drawer--bottom) triggered by a pinned "Options & Stats" pill.

Cloning this demo project

The demo's UI consumes the design system as a git submodule (vendored at Vendor/unity-ui-document-design-system) and links the drop-in folder into Assets/DesignSystem via a per-clone OS link. Pure-runtime consumers of the renderer (the recipe in Installation below) don't need the design system — only this repo's demo scene does.

git clone --recurse-submodules https://github.com/sinanata/unity-prefab-thumbnail-renderer
cd unity-prefab-thumbnail-renderer

Then create the link from Assets/DesignSystem to the vendored copy:

# Windows — directory junction (no admin / Developer Mode required)
cmd /c mklink /J Assets\DesignSystem Vendor\unity-ui-document-design-system\Assets\DesignSystem
# macOS / Linux — symbolic link
ln -s ../Vendor/unity-ui-document-design-system/Assets/DesignSystem Assets/DesignSystem

The junction / symlink itself is gitignored; each contributor re-runs the command after their first clone. Open in Unity 6000.3.8f1 (or compatible) and press Play in Assets/Demo/Scenes/PrefabThumbnailDemo.unity.

If you forgot --recurse-submodules, run git submodule update --init --recursive after the fact, then create the link.

Build the WebGL preview locally

The build flow lives in a shared cross-platform orchestrator vendored as a submodule at Tools/.orchestrator/ — clone with submodules so Build-Demo.ps1's shim resolves:

git submodule update --init --recursive
copy Tools\Build\config.example.json Tools\Build\config.local.json
# Edit unity.windowsEditorPath if Unity isn't in C:\Program Files\Unity\Hub\Editor\6000.3.8f1\
.\Tools\Build\Build-Demo.ps1 -Serve     # build + serve at http://localhost:3000
.\Tools\Build\Build-Demo.ps1 -Deploy    # build + force-push to gh-pages

Build-Demo.ps1 is a thin shim — the heavy lifting (lockfile cleanup, Burst-AOT auto-retry, live progress, deploy worktree) lives in unity-cross-platform-local-build-orchestrator. See Tools/Build/README.md for daily usage and the orchestrator's README for the full reference.


var renderer = new PrefabThumbnailRenderer(thumbnailSize: 256);
renderer.Setup();

// Static — one frame, cached forever. The 99% case.
renderer.Queue(new ThumbnailRequest { Key = item.Id, Prefab = item.Prefab });

// Yaw rotation — 24 frames spinning a full 360° (seamless loop).
renderer.Queue(new ThumbnailRequest {
    Key = ship.Id, Prefab = ship.Prefab,
    AnimationMode = ThumbnailAnimationMode.YawRotation,
    FrameCount = 24, PlaybackFps = 10f,
});

// Animator clip — 24 samples across an idle / run / dance clip.
renderer.Queue(new ThumbnailRequest {
    Key = char.Id, Prefab = char.Prefab,
    AnimationMode = ThumbnailAnimationMode.AnimatorClip,
    AnimationClip = idleClip, FrameCount = 24, PlaybackFps = 14f,
    PreRenderCallback = inst => ApplySkin(inst, char.SkinTexture),
});

// Drive from any MonoBehaviour Update():
void Update() => renderer.Tick();

// Lookup whenever — null until the last frame lands, then cached.
var anim = renderer.GetAnimated(item.Id);
if (anim != null) myImage.style.backgroundImage = anim.GetFrameAt(playbackSeconds);

Why this exists

Every game with a cosmetic shop, character select, vehicle gallery, or item catalog hits the same problem: you have N prefabs and a UI grid. You need 256×256 thumbnails of each. Pre-baking PNGs is fragile (they go stale every time you re-skin a model). Doing it in the editor is fine for fixed catalogs but breaks for user-driven content. Doing it at runtime usually means stuttering frames as each thumbnail renders.

This pipeline fixes that:

  • Queue once, render across many frames. Every Tick call renders at most one frame. 30 still thumbnails → 30 frames (half a second at 60 fps) and the user sees them gradually fill in. No spike.
  • Three rendering modes. Static (one frame, cached forever), YawRotation (N frames spinning 360°, loops seamlessly), AnimatorClip (N frames sampled across an AnimationClip). The pipeline picks the cheapest mode per request — a static prop costs 1 render, an animated character costs FrameCount renders amortised across FrameCount frames.
  • Async GPU readback. AsyncGPUReadback.Request returns immediately; the texture is materialised on a callback when the GPU has the bytes. No Texture2D.ReadPixels stall.
  • Lazy-load tile component. Drop ThumbnailTile (a VisualElement subclass) into your UXML grid; it queues its render only after layout places it in view, so a 300-item grid in a ScrollView only renders the visible window. Tiles scrolled off-screen are cancellable via Unqueue().
  • Spinner integration. Tiles render a ds-spinner from the bundled design system while waiting; it fades out the moment the first frame lands. Zero extra integration — the tile owns the spinner lifecycle.
  • Built-in cache. The same key never renders twice. Evict + re-queue when underlying data changes.
  • PostProcess hook. Greyscale, dim, tint, watermark — all in a single Action<Texture2D> your request carries. Applied to every captured frame of an animated thumbnail, not just the first.
  • PreRender hook. Apply skin materials, attach hats / weapons / badges before the render fires. Bounds calculation runs after, so attachments are framed.

Requirements

RequirementNotes
Unity 6 (6000.x or newer)Tested on 6000.3.8f1. Should work on 2022.3 LTS — AsyncGPUReadback is older than that.
com.unity.render-pipelines.universal (URP)The pipeline auto-detects URP and disables post-FX on the offscreen camera. Built-in / HDRP work, but you'll see your render pipeline's tonemap baked into thumbnails — set BackgroundColor carefully.
com.unity.modules.uielements (UI Toolkit)Only needed for the ThumbnailTile element. The core renderer is UI-agnostic — pure C# Texture2Ds out.

No external dependencies. No editor-time runtime baking. ~700 lines of C# total across the runtime.

Installation

your-unity-project/
└── Assets/
    └── PrefabThumbnails/                 ← drop the whole folder
        └── Runtime/
            ├── PrefabThumbnailRenderer.cs
            ├── ThumbnailRequest.cs
            ├── AnimatedThumbnail.cs
            ├── ThumbnailTile.cs
            └── ThumbnailPostProcess.cs

Option A — copy files:

git clone https://github.com/sinanata/unity-prefab-thumbnail-renderer ../thumb-src
cp -r ../thumb-src/Assets/PrefabThumbnails Assets/PrefabThumbnails

Option B — git submodule:

cd your-unity-project
git submodule add https://github.com/sinanata/unity-prefab-thumbnail-renderer Vendor/unity-prefab-thumbnail-renderer
# Then create a per-clone link from Assets/PrefabThumbnails → vendored Runtime folder.

Quick start

using PrefabThumbnails;
using UnityEngine;

public class CosmeticShopHub : MonoBehaviour
{
    private PrefabThumbnailRenderer renderer;

    private void Start()
    {
        renderer = new PrefabThumbnailRenderer(thumbnailSize: 256);
        renderer.Setup();
        renderer.OnThumbnailReady += (key, anim) =>
        {
            // `anim` is an AnimatedThumbnail — Static thumbnails have FrameCount=1.
            UpdateCardImage(key, anim.FirstFrame);
        };

        foreach (var item in inventory.AllItems)
        {
            renderer.Queue(new ThumbnailRequest
            {
                Key = item.Id,
                Prefab = item.Prefab,
                PreRenderCallback = inst =>
                {
                    var smr = inst.GetComponentInChildren<SkinnedMeshRenderer>();
                    if (smr != null) smr.material = item.SkinMaterial;
                },
                PostProcessCallback = inventory.IsOwned(item.Id)
                    ? null
                    : ThumbnailPostProcess.GreyscaleDim,
            });
        }
    }

    private void Update() => renderer?.Tick();
    private void OnDestroy() => renderer?.Dispose();
}

That's the full integration. Refresh after an inventory change:

public void OnItemPurchased(int itemId)
{
    renderer.Evict(itemId);     // drop the greyscale version
    QueueOne(itemId);           // re-render in full colour
}

Yaw rotation (looping spin)

renderer.Queue(new ThumbnailRequest
{
    Key = ship.Id,
    Prefab = ship.Prefab,
    AnimationMode = ThumbnailAnimationMode.YawRotation,
    FrameCount    = 24,        // 360° / 24 = 15° per frame
    PlaybackFps   = 10f,       // playback hint; ~2.4 s for a full loop
    PrefabRotation = new Vector3(15f, 0f, 0f),  // slight downward tilt
});

The renderer captures 24 frames over 24 Ticks (~400 ms at 60 fps), then GetAnimated(key) returns an AnimatedThumbnail whose GetFrameAt(playbackSeconds) loops cleanly because frame 24 meets frame 0 at 360°.

Animator clip (idle / run / dance)

renderer.Queue(new ThumbnailRequest
{
    Key = character.Id,
    Prefab = character.RigPrefab,
    AnimationMode = ThumbnailAnimationMode.AnimatorClip,
    AnimationClip = idleClip,
    FrameCount    = 24,
    PlaybackFps   = 14f,
    AnimatorSampleTargetPath = "Root",  // Kenney AC2 rigs need the rig child
    PrefabRotation = new Vector3(0f, 170f, 0f),
    PreRenderCallback = inst => ApplyCharacterSkin(inst, character.SkinTex),
});

AnimationClip.SampleAnimation is driven on the prefab's AnimatorSampleTargetPath child (or the prefab root if blank) at (frame / FrameCount) * clip.length for each captured frame.

Lazy-loading tile (UI Toolkit)

// In your screen's UXML grid container:
foreach (var item in catalog)
{
    var tile = new ThumbnailTile();
    tile.Bind(renderer, new ThumbnailRequest { Key = item.Id, Prefab = item.Prefab });
    grid.Add(tile);
}

ThumbnailTile defers its Queue call until layout places it in view — a 300-tile grid in a ScrollView only renders the visible window. Tiles scrolled off-screen call Unqueue() to cancel in-flight work; tiles scrolled back into view re-queue lazily.

Hover-to-animate

tile.PlaybackTrigger = ThumbnailPlaybackTrigger.OnHover;

Hover-mode tiles rest on frame 0 between hovers and only flip frames while the pointer is over them. Useful for dense catalogs where 20 continuously-animating tiles would compete for attention — only the focused tile plays. Default is Continuous (all animated tiles always play).

API

public class PrefabThumbnailRenderer : IDisposable
{
    public PrefabThumbnailRenderer(int thumbnailSize = 256);

    // Visual configuration — set BEFORE calling Setup()
    public Vector3 OffscreenOrigin = new Vector3(2000, 2000, 0);
    public float CameraFOV = 25f;
    public float FramingPadding = 1.5f;
    public Color BackgroundColor = new Color(0, 0, 0, 0);
    public Vector3 DefaultPrefabRotation = new Vector3(0, 160, 0);
    public Vector3 LightRotation = new Vector3(50, -30, 0);
    public float LightIntensity = 1.0f;

    public void Setup();                                    // build offscreen camera + light
    public void Queue(ThumbnailRequest request);            // queue a render
    public void Cancel(int key);                            // cancel pending / in-flight
    public void Tick();                                     // call from MonoBehaviour Update
    public Texture2D GetThumbnail(int key);                 // first-frame helper
    public AnimatedThumbnail GetAnimated(int key);          // full strip
    public bool IsCached(int key);
    public bool IsPending(int key);
    public void Evict(int key);
    public void EvictWhere(Predicate<int> predicate);
    public void Dispose();

    public event Action<int, AnimatedThumbnail> OnThumbnailReady;
}

public struct ThumbnailRequest
{
    public int Key;
    public GameObject Prefab;                         // OR ResourcePath
    public string ResourcePath;
    public Vector3 PrefabRotation;
    public Action<GameObject> PreRenderCallback;      // skin / pose / attach
    public Action<Texture2D> PostProcessCallback;     // greyscale / tint / watermark

    public ThumbnailAnimationMode AnimationMode;      // Static / YawRotation / AnimatorClip
    public int FrameCount;                            // ignored for Static
    public float PlaybackFps;                         // playback hint, default 12
    public AnimationClip AnimationClip;               // AnimatorClip mode
    public string AnimatorSampleTargetPath;           // AnimatorClip mode
}

public class AnimatedThumbnail : IDisposable
{
    public Texture2D[] Frames;
    public float Fps;
    public ThumbnailAnimationMode Mode;
    public int FrameCount;
    public Texture2D FirstFrame;
    public Texture2D GetFrameAt(float playbackSeconds);   // loops modulo strip length
}

public partial class ThumbnailTile : VisualElement
{
    public void Bind(PrefabThumbnailRenderer renderer, ThumbnailRequest request);
    public void Refresh();          // force re-render
    public void Unqueue();          // cancel queued work (off-screen tile)
    public VisualElement Image;     // hook for click handlers / overlays
    public ThumbnailPlaybackTrigger PlaybackTrigger { get; set; }  // Continuous (default) / OnHover
}

public enum ThumbnailPlaybackTrigger { Continuous, OnHover }

public static class ThumbnailPostProcess
{
    public static void Greyscale(Texture2D tex);              // BT.601 luminance
    public static void GreyscaleDim(Texture2D tex);           // luminance × 0.7
    public static Action<Texture2D> Tint(Color tint);         // multiplicative
}

What makes this robust

  • AsyncGPUReadback, not Texture2D.ReadPixels. ReadPixels stalls the main thread waiting for the GPU to finish; on iOS / Adreno mobile that's 8–30 ms per thumbnail. Async readback returns immediately and the texture lands on a callback when ready.
  • SystemInfo.GetCompatibleFormat. RGBA32 maps to formats that some mobile drivers reject for ReadPixels. The pipeline asks the runtime for a compatible alternative — works cleanly on iOS Metal, Android Vulkan, and desktop d3d11 / vulkan / metal.
  • One render per Tick (across modes). A 24-frame yaw rotation costs 24 Ticks, not 1 — perfectly smooth amortisation. Calling code stays simple; just call Tick() from a single hub MonoBehaviour.
  • Yaw-invariant framing. The camera frames the model ONCE at frame 0 using a sqrt(x² + z²) bounding diagonal so a spinning model doesn't visibly pulse in size as its bounding box rotates under the camera.
  • LIFO queue. Last queued = first rendered. Matches how UI typically wants the work prioritised — when the user scrolls to a section, that section's tiles populate before older off-screen ones.
  • markNoLongerReadable: true on Texture2D.Apply. Drops the CPU-side copy after upload — saves ~256 KB per 256×256 frame at scale (an animated 24-frame tile saves ~6 MB).
  • Cancel during sequence. Mid-rotation, calling Cancel(key) finishes the current frame's readback (you can't abort a AsyncGPUReadbackRequest cheaply), then aborts the rest. Partial frames are destroyed, the slot is freed.
  • Idempotent Setup. Calling Setup twice is a no-op. Calling Queue with a key that's already cached or in flight is a no-op. Defensive design for callers integrating with hot-reload, scene transitions, etc.

Tuning notes

KnobDefaultWhen to change
ThumbnailSize256Bump to 384 / 512 for hero card slots. Drop to 128 for ultra-tight grids. Memory cost is size² × 4 bytes × FrameCount per cached thumbnail.
CameraFOV25°Tighter FOV (15–25) flatters character proportions. Wider (40–60) shows more environmental context, looks more "natural".
FramingPadding1.51.2 = tight crop, 1.8 = lots of breathing room. Tune visually against your card layout.
OffscreenOrigin(2000, 2000, 0)Move further if your gameplay world is enormous and elements at origin would be visible to the thumbnail camera.
BackgroundColorclearSet to a solid colour for a baked card background. Useful when your UI's card background is identical for every item.
DefaultPrefabRotation(0, 160, 0)Three-quarter back view — the classic character portrait angle. (0, 0, 0) = front. (0, 90, 0) = side profile.
LightRotation(50, -30, 0)Three-quarter key from upper-right. (90, 0, 0) for top-down. (0, 180, 0) for backlit silhouette.
FrameCount (animated)2412 = chunkier, ~half the memory. 36 = silky. Memory scales linearly.
PlaybackFps (animated)128–14 fps reads as "alive but not frantic". Bump to 24 for cinematic feel. Doesn't affect capture cost.

When NOT to use this

  • You need fully physically-correct lighting. The bundled directional light is a quick "looks good for cards" setup. For Marmoset-grade PBR previews, build your own scene under the offscreen origin (lights, reflection probes, post-FX volumes) and the renderer will use it.
  • You need image post-processing baked in. Greyscale / tint / watermark via PostProcessCallback is Texture2D.GetPixels32 / SetPixels32 — fine for a few hundred frames, expensive for thousands. For heavy post-process, run a compute shader on the RT before readback.
  • Your prefab tree is huge. Each render instantiates the prefab, frames the camera, renders, then DestroyImmediates it. For animated thumbnails the instance is re-used across sub-frames, but a 200-bone character with 30 attachments and a particle system still takes longer to instantiate than the render itself. For massive prefabs, build a simplified "card variant" prefab.
  • You need >100 simultaneously-animated thumbnails on a low-end mobile device. Per-tile flipbook ticks add up at 24 frames × 100 tiles × ~10 fps = 24 000 background-image writes per second. The ThumbnailTile component throttles to one update per scheduled tick, but a ScrollView window of 12–20 visible animated tiles is the comfortable upper bound on mid-range mobile.

Contributing

Issues and PRs welcome. The whole runtime is small enough to read in one sitting.

Areas where help is especially useful:

  • Editor previewer. A Unity menu item that dry-runs the pipeline against a folder of prefabs and writes PNGs (or animated GIFs / sprite strips) to disk. Useful for asset-pipeline use cases.
  • Compute-shader post-process variants. GPU-side greyscale / tint that runs on the RT before readback would scale to thousands of frames.
  • HDRP-aware Setup. Build a Volume with no overrides under the offscreen root so HDRP project's global volume doesn't bleed into thumbnails.
  • Built-in pipeline shim. The current pipeline-detection only handles URP; Built-in users get default Unity rendering which is fine but worth verifying.
  • More animation modes. AnimatorState (Animator + state name → grab the resolved clip), ParticleBurst (sample N frames of a particle system).

See CONTRIBUTING.md for the PR checklist.

Sibling repos

This is one of a family of "extracted from Leap of Legends, given back to the Unity community" tools:

Credits & support

Made for Leap of Legends — a cross-platform physics-heavy multiplayer game in active development, targeting Steam, iOS, Android, and Mac. If this saved you time:

Demo assets are CC0 from Kenney — pirate kit, watercraft pack, animated-characters-1. No attribution required; the credit lines in the demo UI are courtesy.

Licence

MIT — see LICENSE. Free for commercial use. No warranty.


Leap of Legends · physics · multiplayer · cross-platform · in development · the cosmetic store you'll see at launch is built on this pipeline.