Skip to content

Latest commit

 

History

History
119 lines (95 loc) · 10.3 KB

AGENTS.md

File metadata and controls

119 lines (95 loc) · 10.3 KB

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)

  1. PrefabThumbnailRenderer is a plain C# IDisposable, not a MonoBehaviour. Nothing renders unless something drives it. The lifecycle is exact: new PrefabThumbnailRenderer(size) → set config fields → Setup() (once; idempotent) → call Tick() every frame from one hub MonoBehaviour.Update() → Dispose() in OnDestroy(). Each Tick() captures at most one frame, so a 24-frame animated request completes after 24 ticks (~400 ms at 60 fps).
  2. Results are null until ready. GetThumbnail(key) / GetAnimated(key) return null while a render is pending. Prefer subscribing to OnThumbnailReady(int key, AnimatedThumbnail anim) over polling. Static thumbnails are a one-frame AnimatedThumbnail — read .FirstFrame.
  3. The RenderTexture, camera, and light are created in Setup() and freed only in Dispose(). Do not call Setup() per render, and never skip Dispose() — it leaks the RenderTexture and the offscreen scene root. Per-frame Texture2Ds are freed by AnimatedThumbnail.Dispose() / Evict() / renderer Dispose().
  4. 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-Queue on a cached key is a no-op. ThumbnailTile.Refresh() does the evict + re-queue for you.
  5. AnimatorClip mode needs AnimatorSampleTargetPath set 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).
  6. PostProcessCallback runs on EVERY captured frame, not just the first, via GetPixels32 / SetPixels32 (CPU). Cheap for hundreds of frames, not thousands.
  7. URP post-FX auto-disable is behind #if UNITY_RENDER_PIPELINE_UNIVERSAL, which this repo's project does not define. If you copy only Runtime/ into a URP project that has a global post-process Volume, define UNITY_RENDER_PIPELINE_UNIVERSAL (Player → Scripting Define Symbols) or wrap the tool in an .asmdef referencing URP with a versionDefine, or thumbnails bake in bloom / tonemap.
  8. No editor-only behaviour in runtime code (no #if UNITY_EDITOR in PrefabThumbnailRenderer.cs or its helpers) and no using LeapOfLegends.*. Runtime code lives under PrefabThumbnails; demo code under PrefabThumbnailDemo.

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). Namespace PrefabThumbnails.
  • Assets/Demo/ — the WebGL 25-tile showcase host project (all three modes, category filter, lazy tiles, spinners, mobile drawer). Namespace PrefabThumbnailDemo. Not part of the tool.
  • Assets/Editor/, Assets/Settings/, Assets/WebGLTemplates/ — build + URP scaffolding for the demo.
  • Tools/ — the shared build orchestrator (Tools/.orchestrator submodule) + a thin Build-Demo.ps1 shim.
  • Vendor/ — the design-system submodule (its ds-spinner powers 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 .x distance.
  • Skinned meshes need updateWhenOffscreen = true at 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 the markNoLongerReadable flag.

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.unity and press Play (the scene auto-spawns via DemoBootstrap). 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
    -Serve serves locally, -Deploy force-pushes to gh-pages, -ClearCache recovers 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_EDITOR in runtime code.
  • Comments answer why, not what.
  • CHANGELOG.md updated; README updated if the public API or behaviour changed.

Deeper docs