Luna's UI Effect Baker is an editor tool that pre-renders a static effect stack into a PNG + 9-slice sprite, so authors can swap a runtime UI Effect (which costs roughly 3 SetPass per element) for a single background-image that batches like any other sprite. Same authoring DX, same look, one draw call per element.
The whole workflow in four lines:
UIEffectBakePreset, or snapshot a live element's effect stack straight from the Luna Effects window.UIEffectBakeUssCollection and click Export USS; out comes a generated stylesheet with one ready-made class per preset.class="luna-fx luna-fx-shadow-md" becomes a single class="bake-card-elevated". Same pixels, one batched draw call, no hand-written USS.The runtime UI Effects filter is a great fit for elements whose look genuinely needs to change at runtime — hover/active states, dissolves, shines, palette swaps. For everything else — cards, panels, frames, framed portraits, fixed gradients — the filter pays full per-frame cost for pixels that never move.
| Approach | Batches | Element count | Reuse | Memory |
|---|---|---|---|---|
| Runtime filter | High (~3/el) | 1× | High | Low |
Atomic PNGs stacked (shadow.png + border.png + …) | OK | 2–4× | High | Medium |
| Per-element bake | Low | 1× | None | High |
| Style-preset bake | Low | 1× | High | Low |
A baked style-preset PNG is shared across every instance of that style. Multiple cards using the same card-elevated look batch together — often better than the runtime path because UITK can fold them into a single draw call.
| Treatment | Use a bake | Use the runtime filter |
|---|---|---|
| Cards, panels, frames, fixed-color glow / shadow | Yes | No |
| Static gradient fills | Yes | No |
| Inner-overlay rim, fixed outline color | Yes | No |
| Hover / active / disabled state transitions | No | Yes (state tween writes live uniforms) |
| Shine, flash, dissolve, tile scroll, marching dashes, circling outline | No | Yes |
| Per-instance USS variable overrides | No | Yes |
💡 Rule of thumb: bake what never changes. If the visual is the same every frame and never reacts to USS state, it's a bake candidate — one draw call, no per-frame filter cost. Otherwise leave it on the runtime filter.
Don't bake shadow-md.png, glow-lg.png, border-1.png separately and stack three sprites per element. Bake named composites that combine the full effect stack into one PNG per look:
card-elevated → one baked PNG (shadow + border + rounded corners)button-primary → one baked PNG (gradient + inner shadow + border)panel-glass → one baked PNG (gradient + edge highlight + border)One element, one draw call, full effect stack. Composite PNGs reuse across every instance of that style.
Bake each composite at a reference size with padding for the largest glow/outline radius, then mark 9-slice borders so the center stretches without distorting edges. One bake handles every element size as long as the effects are edge or radially uniform — drop shadows, borders, rounded corners, edge glows, inner shadows all qualify.
Shine, Flash, Dissolve, Tile, OutlineCirclingSpeed, OutlineShineSpeed are intentionally not baked — a single-frame snapshot would mislead the author. The dashCount parameter on outline is preserved (static dashes bake correctly) but the per-frame speed offset stays at zero.
If the element needs animation, leave it on the runtime filter.
card-blue, card-green, card-red.
The same preset, baked and dropped onto its live element in the showcase — one batched draw call, no runtime filter:

Create a preset. Right-click in the Project window → Create → CupkekGames → Luna UI → UI Effect Bake Preset. Or click Export in the Luna Effects window (Tools → CupkekGames → Luna Effects) to snapshot a live element straight into a preset.
Select the preset asset. Its inspector has the live preview, validation banners, and the Bake button.
Set the output geometry.
256 × 256) since the 9-slice center stretches to any element aspect at apply-time. The output PNG is textureSize × Resolution Scale.outer.width / outline.width, otherwise the glow/shadow gets clipped at the edge of the texture. The inner drawable element is textureSize − padding on each side.2 for Constant Pixel Size × 2). Layout sizes don't change — the generated USS compensates with -unity-slice-scale.card-elevated → .bake-card-elevated). This one class is both the marker and the texture name — no second class to type.Toggle effects and author parameters. Outer, Outline, Gradient, Inner Overlay. The live preview re-renders on every field change.
Bake. Click Bake to Project — the tool writes the PNG and reimports it with the right sprite settings, including the 9-slice border.
Export the USS. Create a collection (Create → CupkekGames → Luna UI → UI Effect Bake USS Collection), add your presets, click Export USS (or Bake All + Export USS to refresh every PNG first). The collection fully owns its output file — every export is a clean rewrite, and re-baking a preset from its own inspector re-exports any collection that contains it, so the stylesheet can't go stale.
Import the generated file once from your theme .tss:
@import url("/Assets/UI/Baked/LunaBakedEffects.uss");Swap classes on elements.
<!-- before: runtime filter, ~3 SetPass -->
<engine:VisualElement class="luna-fx luna-fx-shadow-md luna-fx-clay sky"/>
<!-- after: baked underlay, batches like any sprite -->
<engine:VisualElement class="bake-card-elevated"/>The preset's Import section stamps the baked PNG's importer settings, so you don't hand-tune every texture after baking:
| Field | Default | Notes |
|---|---|---|
| Max Size | 1024 | Importer cap; only ever downscales — a smaller bake imports at its own size. Raise for large surfaces, lower to hard-cap memory. |
| Texture Compression | Compressed (Normal Quality) | Uncompressed keeps soft gradients / alpha edges crispest; CompressedHQ trades memory for fewer artifacts. |
| Compression Quality | 50 | Materially applies only to Crunched / HQ formats; ignored when Uncompressed. |
| Filter Mode | Bilinear | Point for pixel-exact bakes. |
The baker runs the bake variant of the same Luna uber filter the runtime uses (Hidden/Luna/UberFilter_Bake). The shader includes the same effect math files (LunaCommon, LunaSDF, LunaOuter, LunaOutline, LunaGradient, LunaInnerOverlay), so the bake matches the runtime pixel-for-pixel modulo expected sub-pixel differences from the lack of atlas resampling.
The preset surfaces a static subset of each runtime effect:
| Section | What you author | What's omitted |
|---|---|---|
| Outer | color, strength, edge spread, offset, width, optional gradient stops | — |
| Outline | color, width, softness, opacity, optional gradient stops, optional pattern texture (scale / opacity / tint), dash count + ratio | Marching-ants speed, circling speed, traveling shine |
| Gradient | linear/radial, angle, opacity, reverse, color bias, stops | — |
| Inner Overlay | color, per-edge widths, opacity, softness | — |
Live-export from a UIEffectElement (via the Luna Effects window) writes the resolved uniform values from the on-screen element directly into the preset — capturing whatever the user sees, including --luna-fx-* overrides from tier classes, hover state, and theme tokens.
The baker keeps every decorative band inside the corner region so the center stretches without distorting glow / outline / radii:
ring = max(outer.width, outline.width) // whichever of the two is enabled
// |outer.offset.*| is added only when the Outer effect is enabled
left = (padding.L + max(TL_radius, BL_radius) + ring + |outer.offset.x|) × ResolutionScale
top = (padding.T + max(TL_radius, TR_radius) + ring + |outer.offset.y|) × ResolutionScale
right = (padding.R + max(TR_radius, BR_radius) + ring + |outer.offset.x|) × ResolutionScale
bottom = (padding.B + max(BL_radius, BR_radius) + ring + |outer.offset.y|) × ResolutionScaleIf textureSize minus these borders is ≤ 0 on either axis, the inspector shows a warning. Either grow textureSize (more stretchable middle), or shrink padding / radii / outer / outline widths until a positive center remains.
The output PNG dimensions are exactly textureSize × ResolutionScale. The sprite border — in the order (left, bottom, right, top) — is set on import.
Two GPU gotchas are handled inside the baker so the output is drop-in ready:
SetVectorArray does not auto-linearize colors, so ApplyStops calls Color.linear on every gradient stop before packing. Flat-color uniforms (_OutlineColor, _OuterColor, _InnerOverlayColor) go through Material.SetColor, which Unity linearizes correctly. The output RenderTexture uses RenderTextureReadWrite.sRGB in Linear projects, so the GPU runs linear → sRGB on store and the PNG ends up gamma-encoded — ready to import back as an sRGB sprite.EncodeToPNG. UITK sprites expect straight alpha — without this conversion, RGB values get "burned" (multiplied twice when UITK re-premultiplies during composite).When you assign a straight-alpha PNG as the preset's sourceContent, leave sourceIsStraightAlpha = true. The shader premultiplies on sample so it composites with the rest of the pipeline. Captures from another bake (already premul) should set this to false.
In the Luna Effects editor window, every tracked UIEffectElement has an Export button. Click it to snapshot the currently-rendering state — including all USS-resolved --luna-fx-* values — into a fresh UIEffectBakePreset asset.
The exporter reads each uniform back from a MaterialPropertyBlock after the same ApplyToMPB + ApplyOverrides pipeline the runtime uses, so the preset captures exactly what's on screen. Animated state is intentionally dropped (the preset is a static snapshot).
Use this when iterating in-scene with USS until the look is right, then export → bake → add to a collection. The exporter pre-fills geometry from the live element (size, radii, required padding), suggests Uss Class Name from the element's first tier class (luna-fx-shadow-md → shadow-md), and defaults Resolution Scale from the panel's effective scale. The snapshot geometry is squared to the nearest even side, so the 9-slice center and -unity-slice-scale stay seam-free.
bake underlayThe live filter draws outside the element rect — that's what the preset's padding is for — but a background-image clips to the rect, so a naive class swap visibly shrinks the effect. The generated USS solves this with a one-child indirection:
Every element carrying a bake-<name> class gets one auto-injected underlay child (bake-underlay): absolutely positioned, picking-ignored, painted behind the element's content. LunaUIManager installs the driver on every UIView automatically (the same Auto-Install Fx toggle as the runtime filter); manual control via UIEffectBakedDriver.Install(root). Detection matches the bake- class prefix; there's also a bare bake marker for the rare case where the styling class is added from C# at runtime and the driver needs a static hook to find the element.
Each exported preset becomes one rule targeting that child, re-creating the filter's overhang with negative offsets equal to the bake padding:
.bake-card-elevated > .bake-underlay {
left: -16px;
top: -16px;
right: -16px;
bottom: -16px;
background-image: url("./BakedPNG/card-elevated.png#card-elevated");
-unity-slice-scale: 0.5; /* present only when the asset is a sprite AND Resolution Scale ≠ 1 */
}The url() is stylesheet-relative, resolved against the .uss file's own folder — not a project://database GUID reference — so the generated sheet and its baked textures move together and round-trip identically between the authoring tree and any imported copy. The exact relative prefix (./BakedPNG/…, ../…) depends on where the collection writes its .uss versus its textures. The trailing #card-elevated fragment selects the 9-slice Sprite sub-asset (which carries the border) rather than the plain Texture2D.
The driver never reads custom properties or assigns visuals from C# — the generated USS is the single source of truth, so theme swaps and selector tricks behave exactly like hand-written styles.
Rules that keep it predictable:
bake-<name> is both the marker and the texture name, so you never type a second class. Two bake-<name> classes on one element would fight over the single underlay — bake a composite preset instead (see design philosophy above).luna-fx plus an animated tier (shine, dissolve) alongside a bake-<name> static surface; the two systems are independent.-unity-slice-scale brings slices back to layout size — crisp instead of soft. Live-element exports default this from the panel automatically.bake-<name> class whose generated rule is missing (or whose .uss isn't imported) leaves an invisible zero-size underlay; a bare bake marker with no matching rule shows nothing. Neither throws.Which effect goes on which element. A bake's underlay is injected at child-index 0, so it paints behind the host element's own children. Three rules follow, and every baked surface in the Showcase obeys them:
background-image and text — a baked element's own image/text would be hidden behind it. Icons, labels and sprites go in child elements, which paint after the underlay.bake-coral-shadow on a surface with a separate opaque __fill child.| Baked effect | Put it on | overflow | Why |
|---|---|---|---|
| Drop shadow · outer glow | the root (or the content element it surrounds) | none (must overhang) | the underlay spills past the box; clipping would eat the shadow's / glow's whole point |
| Gradient fill (or a flat background) · inner shadow / rim | an absolute-fill child (…__surface) | overflow: hidden (+ border-radius) | clips the fill to the panel shape and contains the inner effects |
| Outline · ring | a top child, above the fill | none | frames on top, un-clipped; the ring's rounded shape is baked into its own sprite (no border-radius needed) |
| State-swapped fill (control internals) | the target element, via direct background-image | clipped by its parent | swaps by state class — see Direct application below |
Here "fill" means the Gradient effect — a flat, linear, or radial background baked into the panel — or just the element's own background-color showing through; it isn't a separate effect of its own.
DOM order = paint order: root underlay (shadow) → fill child → content → outline child. A separately-baked outline can't sit on the root — it lives at the border (half in, half out), and the opaque fill child would bury its inner half into a thin sunk-in ring, so it belongs on a top child. An outer glow is an outer effect like the shadow: it paints behind whatever it surrounds — the whole root surface, or a single content element such as an icon.
Keeping the root a plain flex container (shadow only) leaves your flexbox layout and any nested controls untouched — the fill rides on a
position: absolutechild that fills the box without joining the layout. Andoverflow: hiddenon an element clips its own underlay's overhang, so never put a shadow / glow bake on a clipped element.
Every clay surface in the sample — StatRow, ItemTile, JourneyTierRow's reward tile — is this shape:
<!-- ROOT · drop shadow · plain flex container · NO overflow so the shadow overhangs -->
<ui:VisualElement class="stat-row bake-coral-shadow">
<!-- FILL CHILD · background · absolute-fill · overflow:hidden + radius rounds the square bake -->
<ui:VisualElement class="stat-row__surface bake-light-bg" picking-mode="Ignore" />
<!-- CONTENT · real children, painted on top of the fill underlay -->
<ui:VisualElement class="stat-row__icon" />
<ui:Label class="stat-row__value" text="128" />
</ui:VisualElement>.stat-row { position: relative; } /* bake-coral-shadow → drop shadow */
.stat-row__surface { position: absolute; top: 0; left: 0; right: 0; bottom: 0;
border-radius: var(--radius-md); overflow: hidden; } /* bake-light-bg → fill */A fill child (the orb, clipping its art) plus a top child (the ring, whose round shape is baked into the sprite):
<ui:VisualElement class="avatar-pic">
<ui:VisualElement class="avatar-pic__bg bake-bg-blue-bake" picking-mode="Ignore"> <!-- fill child -->
<ui:VisualElement class="avatar-pic__art" picking-mode="Ignore" /> <!-- art, clipped by __bg -->
</ui:VisualElement>
<ui:VisualElement class="avatar-pic__ring bake-rarity-ring-epic" picking-mode="Ignore" /> <!-- top child -->
</ui:VisualElement>Two ways to get rounded corners — usually you want the first:
border-radius + overflow: hidden. Reach for this when a flat / square texture has to be reshaped (e.g. a direct-path fill that's stretched rather than 9-sliced) or when one texture is reused at different radii per state. Put the radius on the element itself if the fill is that element (…__surface), or on the parent if the fill is a child swapped by state — milestone nodes do the latter: .journey-tier__node carries the radius + overflow, .journey-tier__node-fill carries the texture.bake markerIf a controller sets the bake-<name> class from C# (per rarity, per state), author the element with the bare bake marker so the driver attaches the underlay before the real class arrives. HeroCard and ItemTile do exactly this:
<ui:VisualElement class="hero-card__surface bake"> … </ui:VisualElement>// HeroCardSlotController, on rarity change:
surface.AddToClassList($"bake-herocard-surface-{rarity}"); // shadow + outline + inner-rim compositeThe fuller worked examples (clay surfaces, avatar orb + ring, per-tier rings) ship in the README.md under Samples~/Showcase/LunaShowcase/UXML/BakePresets/.
For control internals and state-driven fills. The underlay driver is for whole decorated elements. Some targets can't take an injected child — a control's internal parts (a progress-bar fill, a list row) or an element whose fill is swapped by state. For those, skip the driver and set background-image on the target selector yourself. The Showcase does this in two places:
/* Progress-bar fill — ProgressBarBaked.uss */
.progress-bar--baked-coral .ProgressBar__progress-element {
background-image: url("BakedPNG/progress-fill-coral.png");
}
/* Milestone node fill, swapped by row state — JourneyTierRow.uss */
.journey-tier--completed .journey-tier__node-fill {
background-image: url("../../BakePresets/BakedPNG/teal-bg.png");
}
.journey-tier--current .journey-tier__node-fill {
background-image: url("../../BakePresets/BakedPNG/gold-bg.png");
}Direct-path URLs bind the plain Texture2D — note there is no #fragment — which stretches uniformly, exactly what a solid fill wants. Add #name only if a direct-path element genuinely needs the 9-slice sprite. You own the placement: no marker, no injected underlay, no generated USS.
| File | Purpose |
|---|---|
Editor/Shaders/LunaUberFilter_Bake.shader | Blit-compatible variant of the runtime uber filter |
Editor/EffectBaker/UIEffectBakePreset.cs | ScriptableObject + per-effect serializable configs |
Editor/EffectBaker/LunaEffectBaker.cs | Static RenderToRT / RenderToTexture / BakeToAsset API |
Editor/EffectBaker/UIEffectBakePresetEditor.cs | Custom inspector + live preview pane |
Editor/EffectBaker/UIEffectStackExporter.cs | Snapshot a live UIEffectElement into a fresh preset |
Editor/EffectBaker/UIEffectBakeUssCollection.cs | Preset list + output path — owns one generated .uss file |
Editor/EffectBaker/LunaBakedUssWriter.cs | Validation, USS text generation, export |
Editor/EffectBaker/UIEffectBakeUssCollectionEditor.cs | Collection inspector — validation readout + export buttons |
Runtime/Scripts/Effects/UIEffectBakedDriver.cs | bake-<name> / bake class → underlay-child injection |
:hover selectors.--luna-fx-* signature against a baked library at attach time and swap automatically, so even the class edit disappears.The Effect Baker ships with Luna — get it on the Asset Store.
Settings
Theme
Light
Contrast
Material
Dark
Dim
Material Dark
System
Sidebar(Light & Contrast only)
Font Family
DM Sans
Wix
Inclusive Sans
AR One Sans
Direction