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.
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.
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-1rig + 4 CC0 skins) —AnimatorClipmode, 24 frames sampled acrossrun.fbx. Two are owned (full colour), two are locked (greyscale-dimmed via the bundledThumbnailPostProcess). - 10 ships / boats (Kenney
pirate-kit+watercraft-pack) —YawRotationmode, a 24-frame full 360° spin so the player sees every side without dragging. - 11 props (chests, barrels, cannons, flags, bottles) —
Staticmode, 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
ThumbnailTileelement 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-spinnerfrom 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
Tickcall 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 anAnimationClip). The pipeline picks the cheapest mode per request — a static prop costs 1 render, an animated character costsFrameCountrenders amortised acrossFrameCountframes. - Async GPU readback.
AsyncGPUReadback.Requestreturns immediately; the texture is materialised on a callback when the GPU has the bytes. NoTexture2D.ReadPixelsstall. - Lazy-load tile component. Drop
ThumbnailTile(aVisualElementsubclass) into your UXML grid; it queues its render only after layout places it in view, so a 300-item grid in aScrollViewonly renders the visible window. Tiles scrolled off-screen are cancellable viaUnqueue(). - Spinner integration. Tiles render a
ds-spinnerfrom 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
| Requirement | Notes |
|---|---|
| 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, notTexture2D.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: trueonTexture2D.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 aAsyncGPUReadbackRequestcheaply), 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
| Knob | Default | When to change |
|---|---|---|
ThumbnailSize | 256 | Bump to 384 / 512 for hero card slots. Drop to 128 for ultra-tight grids. Memory cost is size² × 4 bytes × FrameCount per cached thumbnail. |
CameraFOV | 25° | Tighter FOV (15–25) flatters character proportions. Wider (40–60) shows more environmental context, looks more "natural". |
FramingPadding | 1.5 | 1.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. |
BackgroundColor | clear | Set 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) | 24 | 12 = chunkier, ~half the memory. 36 = silky. Memory scales linearly. |
PlaybackFps (animated) | 12 | 8–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
PostProcessCallbackisTexture2D.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
ThumbnailTilecomponent throttles to one update per scheduled tick, but aScrollViewwindow 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
Volumewith 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:
- unity-ui-document-design-system — UI Toolkit
ds-*token + component set used by this demo. - unity-mesh-fracture — Runtime Voronoi mesh fracturing for destruction effects.
- unity-3d-to-sprite-baker — Bake 3D animated characters into sprite atlases at game start.
- unity-cross-platform-local-build-orchestrator —
Build-Demo.ps1orchestration for WebGL / Standalone / mobile.
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:
- ⭐ Star the repo
- 🎮 Wishlist Leap of Legends on Steam — mobile store pages coming soon
- 🐦 Shout out @sinanata
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.