Imported from sinanata/unity-prefab-thumbnail-renderer (
AGENTS.md). Install upstream withnpx skills add sinanata/unity-prefab-thumbnail-renderer. Copyright stays with the author.
AGENTS.md
Guidance for AI coding agents (Codex, Cursor, GitHub Copilot, Claude Code, Windsurf, Aider, Zed, and others) working in this repository or wiring this tool into another project. Humans: this is a fast, accurate map. Deeper docs are linked at the bottom.
What this project is
A drop-in runtime prefab → Texture2D thumbnail pipeline for Unity 6 + URP. Queue character / item / vehicle / ship prefabs once and get cached thumbnails — static, yaw-rotating, or animator-clip animated — rendered by an offscreen camera with async GPU readback, one frame per tick (no spikes), plus a lazy-load UI Toolkit tile and a built-in spinner + greyscale post-process. The runtime is five C# files under Assets/PrefabThumbnails/Runtime/. Namespace: PrefabThumbnails. License: MIT. Battle-tested in Leap of Legends, where every cosmetic thumbnail in the store, equip screen, and reward chests is rendered through this pipeline.
Golden rules (do not violate)
PrefabThumbnailRendereris a plain C#IDisposable, not aMonoBehaviour. Nothing renders unless something drives it. The lifecycle is exact:new PrefabThumbnailRenderer(size)→ set config fields →Setup()(once; idempotent) → callTick()every frame from one hubMonoBehaviour.Update()→Dispose()inOnDestroy(). EachTick()captures at most one frame, so a 24-frame animated request completes after 24 ticks (~400 ms at 60 fps).- Results are null until ready.
GetThumbnail(key)/GetAnimated(key)returnnullwhile a render is pending. Prefer subscribing toOnThumbnailReady(int key, AnimatedThumbnail anim)over polling.Staticthumbnails are a one-frameAnimatedThumbnail— read.FirstFrame. - The RenderTexture, camera, and light are created in
Setup()and freed only inDispose(). Do not callSetup()per render, and never skipDispose()— it leaks the RenderTexture and the offscreen scene root. Per-frameTexture2Ds are freed byAnimatedThumbnail.Dispose()/Evict()/ rendererDispose(). - To change a cached thumbnail,
Evict(key)then re-Queue. The cache dedupes by key and a post-process mutates textures in place, so a plain re-Queueon a cached key is a no-op.ThumbnailTile.Refresh()does the evict + re-queue for you. AnimatorClipmode needsAnimatorSampleTargetPathset to the rig root (e.g."Root"for Kenney AC2 rigs), or every animation curve binds against the wrong transform and silently no-ops (frames come out at bind pose).PostProcessCallbackruns on EVERY captured frame, not just the first, viaGetPixels32/SetPixels32(CPU). Cheap for hundreds of frames, not thousands.- URP post-FX auto-disable is behind
#if UNITY_RENDER_PIPELINE_UNIVERSAL, which this repo's project does not define. If you copy onlyRuntime/into a URP project that has a global post-processVolume, defineUNITY_RENDER_PIPELINE_UNIVERSAL(Player → Scripting Define Symbols) or wrap the tool in an.asmdefreferencing URP with aversionDefine, or thumbnails bake in bloom / tonemap. - No editor-only behaviour in runtime code (no
#if UNITY_EDITORinPrefabThumbnailRenderer.csor its helpers) and nousing LeapOfLegends.*. Runtime code lives underPrefabThumbnails; demo code underPrefabThumbnailDemo.
Wire it into your project
The runtime is Assets/PrefabThumbnails/ dropped into your Assets/. using PrefabThumbnails; then:
var renderer = new PrefabThumbnailRenderer(thumbnailSize: 256);
renderer.Setup();
renderer.OnThumbnailReady += (key, anim) => UpdateCard(key, anim.FirstFrame);
renderer.Queue(new ThumbnailRequest { // Static — one frame, cached forever (the 99% case)
Key = item.Id, Prefab = item.Prefab,
PreRenderCallback = inst => ApplySkin(inst, item.SkinMaterial),
PostProcessCallback = item.Owned ? null : ThumbnailPostProcess.GreyscaleDim,
});
void Update() => renderer.Tick(); // drive it — at most one render per frame
void OnDestroy() => renderer.Dispose(); // release the RenderTexture + offscreen root
YawRotation and AnimatorClip add AnimationMode, FrameCount, PlaybackFps (and, for clips, AnimationClip + AnimatorSampleTargetPath). For UI Toolkit, drop a ThumbnailTile into a grid and call tile.Bind(renderer, request) — it lazy-queues only once layout puts it on screen. The canonical examples are the README "Quick start" and Assets/Demo/Runtime/DemoUI.cs.
Public API
PrefabThumbnailRenderer : IDisposable (Assets/PrefabThumbnails/Runtime/PrefabThumbnailRenderer.cs):
PrefabThumbnailRenderer(int thumbnailSize = 256);
// config — set before Setup(): OffscreenOrigin, CameraFOV, FramingPadding, BackgroundColor,
// DefaultPrefabRotation, LightRotation, LightIntensity
void Setup(); // idempotent; builds offscreen camera + light + RT
void Queue(ThumbnailRequest request);
void Cancel(int key);
void Tick(); // call every frame; renders <= 1 frame
Texture2D GetThumbnail(int key); // first frame, null until ready
AnimatedThumbnail GetAnimated(int key); // full strip, null until ready
bool IsCached(int key); bool IsPending(int key);
void Evict(int key); void EvictWhere(Predicate<int> predicate);
void Dispose();
event Action<int, AnimatedThumbnail> OnThumbnailReady;
ThumbnailRequest (struct — ThumbnailRequest.cs):
int Key; GameObject Prefab; string ResourcePath; // Prefab OR ResourcePath (Prefab wins)
Vector3 PrefabRotation; // zero = DefaultPrefabRotation
Action<GameObject> PreRenderCallback; // skin / attach, before framing
Action<Texture2D> PostProcessCallback; // runs on every captured frame
ThumbnailAnimationMode AnimationMode; // Static / YawRotation / AnimatorClip
int FrameCount; // default 24, ignored for Static
float PlaybackFps; // playback hint, default 12
AnimationClip AnimationClip; // AnimatorClip mode
string AnimatorSampleTargetPath; // AnimatorClip mode — the rig root, e.g. "Root"
AnimatedThumbnail : IDisposable (AnimatedThumbnail.cs): Texture2D[] Frames; float Fps; ThumbnailAnimationMode Mode; int FrameCount; Texture2D FirstFrame; Texture2D GetFrameAt(float playbackSeconds); (loops modulo strip length).
ThumbnailPostProcess (static — ThumbnailPostProcess.cs): void Greyscale(Texture2D), void GreyscaleDim(Texture2D), Action<Texture2D> Tint(Color).
ThumbnailTile : VisualElement ([UxmlElement], ThumbnailTile.cs): void Bind(PrefabThumbnailRenderer, ThumbnailRequest), void Refresh(), void Unqueue(), VisualElement Image, ThumbnailPlaybackTrigger PlaybackTrigger { get; set; }; enum ThumbnailPlaybackTrigger { Continuous, OnHover }. Also enum ThumbnailAnimationMode { Static, YawRotation, AnimatorClip }.
Repository layout
Assets/PrefabThumbnails/— the shippable tool, and the only folder a consumer copies.Runtime/= 5 files (renderer, request, animated-thumbnail, tile, post-process). NamespacePrefabThumbnails.Assets/Demo/— the WebGL 25-tile showcase host project (all three modes, category filter, lazy tiles, spinners, mobile drawer). NamespacePrefabThumbnailDemo. Not part of the tool.Assets/Editor/,Assets/Settings/,Assets/WebGLTemplates/— build + URP scaffolding for the demo.Tools/— the shared build orchestrator (Tools/.orchestratorsubmodule) + a thinBuild-Demo.ps1shim.Vendor/— the design-system submodule (itsds-spinnerpowers the tile), used only by the demo.
Conventions when editing
- Comments answer "why", not "what". e.g. why the offscreen camera renders at depth −20 (before main cameras, so it can't pick up their post-FX).
- Framing is yaw-invariant. The camera frames the model once at frame 0 using a
sqrt(x² + z²)diagonal, so a spinning model doesn't pulse in size — don't switch it to a per-frame.xdistance. - Skinned meshes need
updateWhenOffscreen = trueat the far origin, or Unity culls them by pre-deform bounds and captures the bind pose. The renderer sets this on every SMR; keep it. Texture2D.Apply(false, true)marks frames non-readable to free the CPU copy — keep themarkNoLongerReadableflag.
Build, preview, validate
Windows-first Unity 6 project (host editor 6000.3.8f1). There is no unit-test suite; validation is visual, through the demo (CONTRIBUTING: "the demo is the test suite").
- Editor preview: open
Assets/Demo/Scenes/PrefabThumbnailDemo.unityand press Play (the scene auto-spawns viaDemoBootstrap). Tiles fill in with spinners, then frames. - WebGL build, from the repo root in PowerShell:
git submodule update --init --recursive copy Tools\Build\config.example.json Tools\Build\config.local.json .\Tools\Build\Build-Demo.ps1 -Serve # builds to build/WebGL/ and serves http://localhost:3000-Serveserves locally,-Deployforce-pushes togh-pages,-ClearCacherecovers from a stale Burst-AOT cache. - Verify all three modes (characters animate, ships spin, props are static) and the mobile reflow at 360×640 after any change.
Pull request checklist (summary; full list in CONTRIBUTING.md)
- Demo still works end-to-end (open
PrefabThumbnailDemo.unity, Play, tiles fill with spinners then frames). - All three modes still render (Characters animate, Ships spin, Props are still).
- Mobile reflow still works (Game view 360×640 — title / promo / tabs / panel stack in one column).
- No
using LeapOfLegends.*or product-specific imports; no#if UNITY_EDITORin runtime code. - Comments answer why, not what.
CHANGELOG.mdupdated; README updated if the public API or behaviour changed.
Deeper docs
- Full walkthrough, three-mode examples, API, tuning:
README.md - Contribution rules and PR checklist:
CONTRIBUTING.md - Version history:
CHANGELOG.md - Machine-readable index:
llms.txt - Sibling tools: design system · mesh fracturer · 3D-to-sprite baker · build orchestrator