This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Air Sticker is CyberAgent's open-source Unity decal library (MIT). It creates decals by generating a mesh at runtime that conforms to the receiver model, instead of projection-based URP/DBuffer decals. This makes it work on both URP and the built-in render pipeline, supports skinned (animated) meshes, and allows arbitrary user materials — at the cost of mesh generation taking several frames.
- Committed editor version: Unity 2020.3.40f1 with URP 10.10.1 (
ProjectSettings/ProjectVersion.txt,Packages/manifest.json). The package declares"unity": "2020.3"as its minimum. - The distributable UPM package is only
Assets/AirSticker(jp.co.cyberagent.air-sticker), installed by users via git URL with?path=/Assets/AirSticker. Everything else underAssets/(Demo, OtherAssets, Tests, Settings, Polytope Studio) is the development/demo project and is not shipped. - Releases are consumed via git tags (e.g.
#1.0.0); bumpversioninAssets/AirSticker/package.jsonwhen releasing. - Documentation comes in EN/JA pairs:
README.md/README_JA.md(usage) andREADME_DEVELOPERS.md/README_DEVELOPERS_JA.md(algorithm details, references "Mathematics for 3D Game Programming & Computer Graphics" §9.2). Keep both languages in sync when editing docs. Commit messages and PRs are often written in Japanese.
There is no CI, lint config, or build script in this repo; everything runs through the Unity editor.
-
Open the project (repo root) in Unity 2020.3.40f1. Compilation happens on editor focus; demo scenes are
Assets/Demo/Demo_01..03/*.unity. -
Tests are EditMode NUnit tests in
Assets/Tests(assemblyTests, editor-only). Run via Window > General > Test Runner in the editor, or headless:Unity.exe -batchmode -projectPath . -runTests -testPlatform EditMode -testResults results.xml -logFile -To run a single test, use the Test Runner window or add
-testFilter <Namespace.Class.Method>to the CLI call. -
Assets/Tests/PlayMode(assemblyTests.PlayMode) holds a PlayMode regression harness for the async launch pipeline: launch-cancellation races (TestLaunchCancellation.cs) and GC.Alloc regressions (TestGCAllocRegression.cs). It must be PlayMode becauseAirStickerSystem.Update()only runs in Play mode. Run via the Test Runner's PlayMode tab, or headless with-testPlatform PlayMode. Headless runs fail silently while the editor is already open on this project — close it first, or use the Test Runner window instead.
All runtime code is in Assets/AirSticker/Runtime/Scripts (single asmdef AirSticker, no dependencies, no Editor assembly — ~2200 lines total). Two public MonoBehaviours form the API; everything in Scripts/Core supports them.
AirStickerSystem— singleton facade. Exactly one must exist in the scene. Owns the three shared services and pumps them everyUpdate():DecalMeshPool— cachesDecalMeshinstances keyed by hash of (receiver object, renderer, decal material). OneDecalMesh= one draw call, so identical (receiver, renderer, material) combinations share a mesh and successive decals append to it.ReceiverObjectTrianglePolygonsPool— caches the receiver's triangle-polygon soup (ConvexPolygonInfolists) keyed by receiver GameObject, so repeat projections skip mesh extraction. Both pools garbage-collect entries whose receivers died, each frame.DecalProjectorLauncher— FIFO queue that runs one projector launch at a time; the next request starts only when the current projector reachesLaunchingCompletedor dies.
AirStickerProjector— one per decal. Configured in the inspector or created in code viaCreateAndLaunch(). State machine:NotLaunch → Launching → LaunchingCompleted/LaunchingCanceled, observable viaNowStateor theonFinishedLaunchcallback.Launch()may only be called once per instance.
AirStickerProjector.ExecuteLaunchAsync() is an async Awaitable method (started fire-and-forget by DecalProjectorLauncher, not a coroutine) that, per receiver object:
- Collects/creates target
DecalMeshes from the pool (AirStickerSystem.CollectEditDecalMeshes). Decal renderers already hanging under the receiver are temporarily disabled so they aren't picked up as receivers themselves. - Builds the triangle-polygon list via
TrianglePolygonsFactoryif not pooled — frame-sliced (MaxGeneratedPolygonPerFramepolygons per frame,await Awaitable.NextFrameAsync()between chunks) to avoid spikes. HandlesMeshFilter,SkinnedMeshRenderer, andTerrainsources. - Hands off to a ThreadPool worker thread: skinning matrices applied, broad-phase cull (
BroadPhaseConvexPolygonsDetection— face-normal + distance rejection), six clip planes built from the decal box (width/height/depth in decal space), convex polygons split against them (ConvexPolygon.SplitAndRemoveByPlane), and resulting triangle fans appended to the decal meshes. The launch body awaits the next frame until the jobs finish. - Back on the main thread,
DecalMesh.ExecutePostProcessingAfterWorkerThread()uploads the results to UnityMeshobjects.
DecalMesh spawns a child GameObject named "AirStickerRenderer" under the receiver (DecalMeshRenderer), with a MeshRenderer or SkinnedMeshRenderer (bones copied from the receiver) matching the source. It also carries an internal DecalMeshRendererMarker component, which is how ExecuteLaunchAsync() filters those renderers out when gathering receiver geometry (the name itself is only for hierarchy readability — Renderer.name allocates, so it is not used for the check). Assets/Demo/Demo_Benchmark still matches on the literal name, so keep the name stable.
- Anything touched inside the worker-thread action must not call the Unity API — gather Unity-side data (
PrepareToRunOnWorkerThread, bone matrices, transforms) before queueing the work item. - The frame-sliced work uses
UnityEngine.Awaitable, deliberately not UniTask (the package must stay dependency-free) and no longer coroutines. Three rules follow from that, all of them load-bearing:- Never pass a
CancellationTokentoAwaitable.NextFrameAsync(). A cancelable await registers a callback on the token's source, so it allocates on every frame the launch waits. PolldestroyCancellationTokenyourself instead. - An async method is not stopped by the projector's destruction, unlike the coroutine it replaced. Every resume point must be followed immediately by a cancellation check, before touching anything shared between launches (
TrianglePolygonsFactory's working buffers and write cursor,DecalMeshJobPipeline's buffers) or anythingOnDestroyhas already released. - Nothing awaits the launch body, so an escaping exception would be swallowed and
DecalProjectorLauncher's FIFO would wait forever for a state that never arrives. Keep the whole body in try/catch/finally.
- Never pass a
- Receiver models must have Read/Write enabled in import settings; the code paths that read mesh data error out otherwise (see commit history for the error-message handling).
- Z-fighting is inherent to the technique;
zOffsetInDecalSpace(default 0.005) is the mitigation knob.
Assets/Tests/PlayMode exercises exactly the hazards the constraints above describe, on procedurally-built receivers (no dependency on Assets/Demo assets): destroying the projector/receiver/AirStickerSystem at each of the resume points named above and asserting the state machine still reaches a terminal state and DecalProjectorLauncher isn't left stuck, plus GC.Alloc checks that lock in the already-fixed zero-alloc idle-poll/launch-start frames and watch steady-state per-launch allocation for regressions. AirStickerProjector.IsWorkerThreadRunning/IsExecutingLaunch are visible to it via [assembly: InternalsVisibleTo("Tests.PlayMode")] in AssemblyInfo.cs. TestGCAllocRegression's absolute per-launch byte ceiling is a placeholder pending a real calibration run in the editor.
- The working tree accumulates noisy diffs (ProjectSettings, material assets,
packages-lock.json) when the project is opened in a newer Unity editor than 2020.3.40f1. Don't commit editor-generated churn unrelated to your change; checkgit diffbefore staging. Assets/OtherAssets/SD_unitychan(© Unity Technologies Japan/UC) andAssets/Polytope Studioare third-party demo assets — don't modify them as part of library changes.