Liquid Glass UI

The liquid glass system renders UI surfaces that show a refracted, rim-lit glass body in the style of Apple’s Liquid Glass. It lives in Assets/Scripts/Rendering/LiquidGlass/ (Promethist.LiquidGlass).

One look, one draw path, several backdrop sources. Every glass element in the app is drawn by the same shader on the same draw path; the variants differ only in where that shader reads the image inside the glass. That is structural rather than a discipline: if the variants ever drift apart visually, something is broken.

How glass draws

A glass element emits one quad from generateVisualContent, and UI Toolkit draws that quad with the glass material, declared through the -unity-material USS property. BaseElementBuilder.BuildStandardElement wraps an element’s background, border and generated content in PushDefaultMaterial / PopDefaultMaterial, so the quad picks up the material and its per-element property block. In UIRenderDevice.ApplyBatchState that resolves to a SetPass plus a property block — a batch break and nothing more.

So it is an ordinary draw in the element’s own chain position, and z order, ancestor clipping, and the element’s effective opacity all come for free, with no render target switch and no nested render tree. (The system used to draw glass as a USS filter, which forced each element onto its own render texture — two temporary textures and two target switches per element per frame.)

The quad’s vertex UV carries the element-local position in points, so every length the shader works in is in points too — corner radii, bezel width, glass thickness — and antialiasing falls out of the UV’s own screen-space derivatives. No pixels-per-point conversion appears on this path at all.

Why the material is built in C#

-unity-material accepts USS-authored prop() values, but the shader also needs the element’s laid-out size, which USS cannot know. The two cannot be mixed: MaterialDefinition is a struct whose property list is a shared List reference with an internal deep-copy constructor, so reading the USS-resolved value and adding a size to it would mutate the shared computed style of every element resolving the same rule.

LiquidGlassContentSurface therefore builds the whole definition itself, from the --glass-* custom properties plus the size, and writes it inline. Custom properties also cascade one at a time, unlike prop(), where a :hover variant would have to restate the entire declaration at every site.

Transitions still come for free — an inline write is a computed-value change like any other, so transition-property: -unity-material makes UI Toolkit interpolate the properties itself. Two consequences worth knowing:

  • Property order inside the definition is load-bearing. StylePropertyAnimationSystem pairs a transitioning material’s properties by index, so the definition is built in a fixed order with the optional ones last.

  • _GlassSize rides inside the transitioned definition, so where a transition is declared a layout resize eases into place instead of snapping.

The two variants

Both share one look — the same superellipse SDF, bevel refraction, edge reflection and rim specular — and differ only in where the image inside the glass comes from.

Variant USS class Backdrop

Basic

glass

A background you supply: a colour, a texture asset, or the global scene copy — which blurs, off a chain built once for the whole screen. Never samples what is actually drawn beneath the element. Works on every quality preset, though the scene copy and its chain need LiquidGlassFeature running.

Dynamic

glass-dynamic

Everything drawn beneath the element in its own panel, previously rendered UI included, sampled live and blurred. Needs the panel to render into a texture, so it needs LiquidGlassFeature running.

glass is the thing itself — the look, the shader, what every glass element is. glass-dynamic is a modifier on it: the same glass, upgraded to sample a live backdrop where the hardware allows, and falling back to plain glass where it does not. They are authored as peers (glass-dynamic is standalone, not glass glass-dynamic), and neither is called liquid-glass — that is the whole feature: assembly, manager, this page.

With no panel texture to snapshot (the feature absent, a Low or Medium quality preset) glass-dynamic degrades to glass by resolving a different body mode: same shader, same draw, one uniform different. So a dynamic element should always carry a sensible static background as its fallback — the shared theme gives every glass element a --glass-background-color for exactly that.

A glass element with no --glass-background-color and no texture has nothing to refract, so it renders as a tint and a rim over transparency. That is correct behaviour, not a bug: refraction distorts a background lookup, and a constant colour is unaffected by it.

Historically there was a third mechanism — navigation glass, drawn by the renderer feature underneath a second NavigationPanelSettings panel, which is what made cross-layer blur possible before UI Toolkit’s draw chain could be split. It was removed once the in-line approach was proven on every platform; recover it from git history on branch PRP-10385-ui-layout-style if the look ever needs referencing.

Authoring

Give an element the glass (or glass-dynamic) USS class — no C# required, and nothing to add to the panel’s GameObject. The manager scans every panel it recognises on each UI reload and registers the matches. Visual parameters are USS custom properties, so they can vary with state (:hover, :active); surfaces re-read them on style and geometry changes.

.quick-action-card {
    border-radius: 28px;                                /* corner radii (superellipse), picked up automatically */
    --glass-background-color: rgba(20, 24, 31, 0.85);   /* glass body colour; alpha = glass opacity */
    --glass-background: url("/Assets/UI/GlassBackdrop.png"); /* alternative body fill: a texture stretched over
                                                               the element, refracted at the rim. Replaces the
                                                               colour rather than modulating it, and brings its
                                                               own per-pixel alpha. */
    /* --glass-background-global: _LiquidGlassSceneColor;  the scene copy, sampled in SCREEN SPACE
                                                           and refracted in full - see below */
    --glass-tint: rgba(204, 217, 230, 0.15);            /* tint over the glass body; alpha = mix amount */
    --glass-blur: 8;                                    /* backdrop blur radius in points, for both the
                                                           dynamic backdrop and the scene copy */
    --glass-reflection-blur: 2;                         /* the radius the edge reflection samples at.
                                                           Unauthored, it follows --glass-blur. */
}
<ui:VisualElement class="hero-card glass" />

The body colour and the body texture are alternatives, not factors: a texture — authored, global, or set at runtime — is the body whenever one resolves, and it carries the per-pixel opacity that the colour otherwise carries in its alpha. So an element can author a colour as what shows until (or unless) its image arrives, without the colour tinting the image once it does. The tint mixes over whichever of the two ended up filling the body. A dynamic backdrop outranks both.

Defaults, where the shared theme does not override them: tint white at 0.1 alpha, body colour clear, no texture, blur radius 2 points, and a reflection blur following whatever the body blur resolved to. Components.uss gives every glass element the shared --color-glass-tint token and a black body colour, and screens override from there.

The rim’s own shape and lighting are USS too, and authored once for the whole app rather than per screen — see The look is authored in USS, once.

Two authoring rules

These are not style preferences — each one covers a failure that is otherwise silent.

A glass element paints nothing itself

No background-image, no background-color above zero alpha, no border. Our quad is then the only geometry inside the material push range, so the shader’s "solid geometry is glass" test holds by construction. Two independent reasons:

  • DrawVisualElementBackground runs before InvokeGenerateVisualContent, so the element’s own paint ends up buried under the glass body rather than crisp on top of it.

  • A gradient-free SVG is emitted as solid-typed triangles (MeshGenerator.DrawVectorImage copies flags off each VectorImageVertex), so at the fragment it is indistinguishable from our own quad and gets shaded as glass. It disappears without a trace. Every icon in Assets/UI/Icons/ is such an SVG.

Two ways to satisfy the rule, chosen by what the image is:

  • Art that fills the element (a card photo, a gradient) becomes the glass body texture, --glass-background. The bevel refracts it at the rim and the squircle silhouettes it for free.

  • An icon or glyph inset in the element becomes a child element. DrawChildren runs after generated content, so it lands crisp on top of the glass. Every icon on glass in the app is on a circle button, so this is one authored convention — .circle-button__icon in Components.uss:

    <ui:Button class="circle-button glass my-screen__back-button">
        <ui:VisualElement class="circle-button__icon" picking-mode="Ignore" />
    </ui:Button>

    The child is position: absolute on all four edges so the site’s background-size percentages mean what they meant on the button itself, and picking-mode="Ignore" so the press still reaches the button. .circle-button carries padding: 0 for the same reason: Yoga insets an absolutely positioned child from the padding box, not the border box.

A body colour becomes --glass-background-color. A background-color is the same failure with a different shape: it is emitted as solid geometry inside the push range, with the tessellator’s own UVs rather than element-local points, so the glass shader reads it as a second, garbage-addressed glass quad. The only background-color a glass element may carry is transparent, which emits nothing (UI Toolkit skips background geometry below an alpha of 1e-30).

overflow: hidden has to be cleared, not merely avoided. A shared component class can bring it in — .slide-track does — and it clips the glass quad with UI Toolkit’s stencil mask, built from circular arcs, which costs the squircle silhouette. A glass element that inherits it re-authors overflow: visible.

Because the failure is silent, LiquidGlassContentSurface logs an editor-only warning when a glass element resolves paint of its own, naming what it found. Components.uss also authors border-width: 0, background-color: transparent and background-image: none on .glass as a floor — that only out-ranks the runtime theme’s own defaults for a Button or a TabView header, so an authored mistake in a screen stylesheet still reaches the warning.

The floor has to be restated for every state the theme repaints in, not just the base rule. The theme gives Button a background on :hover:enabled, :active:enabled and :focus:enabled, each one a pseudo-class above the bare .glass, so a glass button that is hovered, held or focused would otherwise take it back. Focus is the one that sticks: a tapped button holds it until something else takes it, so the button paints a solid disc under its own glass for as long as it stays focused. Components.uss clears all three; only the :hover one carries the tint lift.

Glass does not shape its content

A glass element silhouettes itself and nothing else. A child that reaches a corner carries its own border-radius. This is how CSS has always worked. The old filter multiplied its entire output by the superellipse coverage, so children were silhouetted by the glass shape as a side effect of the mechanism; nothing can restore that, because the glass draws before children and nothing draws after them. overflow: hidden is not a stand-in either, for the reason above.

Two things that follow:

  • The arc-versus-squircle mismatch between a child’s border-radius and the glass silhouette is a couple of pixels, and does not read on anything but a hard edge.

  • A component that genuinely needs true subtree clipping (an image bled to all four edges, say) puts an inner position: absolute; inset: 0; overflow: hidden wrapper inside the glass element, leaving the glass quad on the unclipped parent. Nothing in the app needs this today.

One thing that is not a regression despite appearances: the rim highlight already could not sit on top of children.

Corner radii

Corner radii come from the resolved border-radius automatically, rendered as superellipse (squircle) corners that degrade to exactly circular where the radius fills the shape — so circles and capsule ends stay round while panel corners get the iOS-like fatter curve. Because the silhouette no longer depends on UI Toolkit’s tessellation, the element may carry whatever radius its content shaping wants.

Author glass corner radii in points, not percentages. VisualElement.resolvedStyle exposes only the Length.value of a border radius, so border-radius: 50% reaches the glass as a bare 50 — the unit is unrecoverable, because the Length itself lives on the internal VisualElement.computedStyle (which is what UI Toolkit’s own mesh generator reads, so an element’s border and background stay correctly rounded while its glass would not).

LiquidGlassCornerRadii scales the radii down to what fits the element, the way CSS resolves overlapping curves, which both keeps a percentage usable on small elements — it lands on the value it means whenever the element’s shorter side is at most twice the authored number, so 50% is exactly right for every circular button — and makes a deliberately oversized radius (border-radius: 9999px for a pill) behave. Without that fit step an over-large radius does not clamp: LiquidGlassRoundedRectSdf uses it as given and its superellipse degenerates into a diamond smaller than the element.

A percentage still cannot be honoured on a non-square element: UI Toolkit resolves it per axis into elliptical corners, and the shader carries one scalar radius per corner.

State changes ease natively

-unity-material transitions, and every glass parameter rides inside it, so a :hover tint eases rather than snapping — provided the element declares the transition:

.pressable {
    transition-property: scale, -unity-material;
    transition-duration: var(--reaction-transition-duration);
    transition-timing-function: var(--reaction-transition-timing-function);
}

That declaration lives on the shared pressable and circle-button classes (see Press feedback), which is the practical reason to give tappable glass one of them: an element that declares no transition changes tint instantly. transition-timing-function is honoured, unlike under the old hand-rolled ramp.

An element that authors --glass-tint in its own screen stylesheet loses the theme’s .glass:hover tint, because a screen sheet outranks the theme whatever the specificity — SelectorMatchRecord.Compare ranks default style sheets (the theme and everything it imports) last before it compares specificity at all. So overriding the resting tint pins it in every state; author the :hover value alongside it, as .home-agent-card does.

opacity is the one property glass does not take at face value — see Fading with opacity.

A few writes deliberately snap instead of easing: those that change what the glass reads rather than how it looks — the dynamic backdrop texture, the pyramid parameters, and the body mode. All three are enumerations or descriptions of a texture’s state with no meaningful half-way point, and ValuesMaterialDefinition.LerpPropertyValues keeps the old texture until t = 0.5, so interpolating one of them reads as a black flash rather than as an in-between.

The scope’s backdrop rect — an element’s window into its pyramid — snaps for a different reason. Easing it slides the backdrop across the element rather than letting the element travel across a backdrop that holds still, which is the one thing the mapping is for. It lags by the transition duration, so the further the element travels the worse it reads: a glass button in a sidebar sliding in from the screen edge drags its backdrop along and catches up afterwards. A jump and a continuous move want the same answer here, so no distinction is drawn between them.

The rect changes on every frame a participant moves, so it is only safe to snap it because SuppressMaterialTransition takes -unity-material alone out of the element’s transitions, filtering the resolved transition-property rather than clearing it. Clear the list outright and a suppression that recurs every frame leaves anything animated — a press scale above all — with no transitions at all, which reads as the transition system being switched off wholesale. It is the single sharpest edge on this path.

transition-property: all cannot be filtered, and does fall back to dropping everything.

What the snap still costs is narrow: a tint or blur ramp beginning on a frame the element also moves arrives at once instead of easing, since both ride the one material. Which gives the authoring rule — settle the position first, animate second. OptionPicker is the worked example: it writes its placement on one layout pass and only adds the class that reveals it on the next, so the ramp starts from a rect that has stopped changing.

Addressing the pyramid in screen space, the way --glass-background-global addresses the scene copy, would take the element’s position out of the material altogether and with it the whole of this — at the cost of extending the unverified SV_POSITION orientation ground to dynamic glass as well.

Fading with opacity

Glass does not dim uniformly when it fades. Dimming a real pane is not what disappearing looks like: what goes is the optics, and what reads is the pane losing its lensing and its rim, and its content clearing off it, before it loses itself. So opacity is reshaped for the whole app — every part of a glass element leaves on its own power curve:

visibility = opacity^k
What k Where

The element’s children

4

FadeChildExponent, applied by LiquidGlassContentSurface. They leave first and come back last.

Tint, edge reflection, rim gleam

2

LIQUID_GLASS_FADE_RIM_EXPONENT. The rim clears next, so the pane flattens before it goes.

The glass body

1

Nothing — k = 1 is the plain effective opacity the vertex colour already carries. It is the last thing left.

Read against the fade’s progress rather than the opacity, that curve is 1 - (1 - t)^k: it leaves at a finite rate — an exponent below 1 would take the children to half gone on the first frame of a fade, which reads as a pop rather than a start — and eases to a stop as it vanishes.

Two things ride along with the body rather than on a curve of their own: the refraction thickness and both blur radii drain linearly to zero, so the glass flattens into its own body colour and lets its backdrop go sharp instead of dissolving as glass; and the silhouette swells by FadeScale (4%), scaled about the element’s centre.

It is the effective opacity — element × ancestors — so fading a container that holds glass does all of this to the glass inside it, which is how popups and dialogs get it.

Why the children are the only part that costs anything

The glass itself is free: the shader reads the effective opacity off IN.color.a, which uie_std_vert folds in whatever the reason for the fade, so the whole of it is a handful of fragment instructions and at full opacity every term is the identity.

Children draw themselves, with their own materials, so the only lever on them is their own opacity — and it has to be written from C#. LiquidGlassContentSurface.TickFades therefore polls each glass element’s effective opacity once a frame from the manager (nothing announces an opacity change: a transition moves it on frames no style or geometry event fires) and writes the remaining factor inline on the direct children, since the chain has already given them the element’s own fade. It writes nothing while nothing is fading, and releases the children back to the cascade at rest.

The element’s own opacity is deliberately left alone. Writing it inline would take the property out of the cascade, and the class-toggled transition that drives the fade in the first place would stop happening — which is also why the children’s authored opacity is captured on the frame the fade starts: once an inline value is written, resolvedStyle answers with that one.

Two consequences of that:

  • A child that transitions its own opacity chases the per-frame writes instead of tracking them, and one whose authored opacity changes mid-fade holds the value captured at the start. Neither is worth machinery; author the state before the fade starts.

  • The fade reaches only the elements a surface can see. A child added mid-fade joins from the next frame at a base of 1.

The swell is the glass body only

Children keep their laid-out position: the shape is scaled inside the shader rather than by a transform, which is what keeps this off scale, where it would fight the press feedback on every pressable. A screen that wants the element and its content to grow together declares scale alongside opacity in its own transition.

The quad is padded by the swell permanently, _GlassSize.z in and out. Generated content is a mesh, so there is nowhere else for the extra silhouette to go, and sizing the quad on the fly would mean regenerating it every frame of a fade. The padding is why FadeScale lives in C# while the rim exponent lives in LiquidGlassCommon.hlsl: a second copy of that number would drift and cut the corners off a fading element.

A glass element parked at a static partial opacity is therefore a half-faded element by definition — faint children, no rim, a sharper backdrop. Author a dimmer resting look with --glass-tint and --glass-background-color instead.

Fading out still costs nothing while invisible. The shader tests the faded coverage in the same clip as the silhouette, before any backdrop sample, so a faded-out element is a rasterized quad and no shading — which is what makes a full-screen glass scrim practical to keep in the tree and fade rather than toggle with display.

Element art or a screen-space backdrop

The two basic-glass texture sources are not interchangeable — they differ in how the texture is addressed and how hard it is refracted.

--glass-background (and SetBackground) is element art: stretched over the element’s own box, moving with it, and refracted at LIQUID_GLASS_IMAGE_REFRACTION_SCALE (10%) of the tuned strength. At full strength the rim lensing of element-sized art throws most of the image off its own edges, which reads as distortion rather than glass.

--glass-background-global is a backdrop the element sits in front of: sampled in screen space through SV_POSITION, so the texture holds still while the element moves across it — under scrolling and transforms alike, with no per-element panel rect and no per-frame position poll — and refracted in full. The shader knows one such name, _LiquidGlassSceneColor; anything else is reported and ignored.

.conversation .glass {
    --glass-background-global: _LiquidGlassSceneColor;   /* published by LiquidGlassSceneColorPass */
}

It blurs, at --glass-blur like everything else. The scene copy carries its own chain, built once per frame by LiquidGlassSceneColorPass and read through the same radius → lod mapping a scope’s pyramid uses — one chain for the whole screen, since every element sampling it wants the same scene. How deep that chain goes is the feature’s Scene Blur Levels, which is therefore the ceiling on what a screen-space body can blur by; at 0 there is no chain and every read lands on the sharp copy. The feature’s Scene Backdrop switch drops the copy and its chain together — see Turning the scene backdrop off.

What that buys, and what it does not: the scene really does bend through the bevel and shift as the element moves, blurred, for the cost of one batch break. But it is the scene only — never other UI — and it is one frame old. Frosted glass over live UI is what glass-dynamic is for; this is the cheap option for elements that only ever wanted the scene behind them.

Frosting the whole screen

A screen that wants its 3D backdrop frosted — the conversation screen’s chat tab does — puts one full-screen element in front of everything: position: absolute on all four edges, the screen-space body, a large --glass-blur, and the rim turned off (--glass-edge-width: 0, and the refraction, reflection and highlight strengths at 0), since at that size a bevel only lenses the screen border. It is one quad and one batch break, because the chain it reads was built for the frame anyway.

Two things it has to get right, both silent:

  • It must sit outside any SafeAreaContainer, not inside one. An absolutely positioned child is inset from its parent’s padding box, so a scrim inside the safe area leaves the screen edges sharp. A screen’s UXML can declare it as a top-level sibling — every top-level element lands in the screen’s own full-screen document root.

  • Its --glass-background-color is the whole low-end look. Where the feature is absent — or its scene backdrop switched off — there is no scene copy, so the screen falls back to that colour across its entire area; the shared theme’s opaque black would black the screen out. Author a dim scrim colour instead.

ConversationUI.uxml’s `scene-scrim is the worked example, faded by a class the tab switch toggles. It authors one radius and fades on opacity alone: the fade drains the blur with it (see Fading with opacity), so the scene comes into frost instead of a frosted sheet appearing over it, and the lod sweeps the chain continuously. Ramping --glass-blur on the same class as well — which it used to — only squares that.

The copy is at sceneColorScale (0.25 by default), so mip 0 is a quarter-resolution scene to begin with. A radius small enough to land inside that first quarter — under about 4 points at 2x DPI — has almost no chain to reach for and reads sharp. Blur on this path is a coarse instrument by construction; glass-dynamic is the precise one.

Runtime elements and runtime images

Elements added from C# after the reload scan (or that gain the class at runtime) are not picked up automatically — register them explicitly, and unregister when they leave:

var surface = LiquidGlassContentSurface.Register(element);
surface.Unregister();

Unregister is safe to call late — after the element has left its panel, or after UI Toolkit has thrown the whole tree away on an editor UI reload. A reload does not merely detach the old tree: it releases every element in it (VisualElement.Clear(RecursiveReleaseResources)), which recycles their layout nodes to the elements of the tree that replaced them. Styling such an element would therefore either throw inside UI Toolkit or land on somebody else’s element, so Unregister skips the material clear for an element that is no longer in a panel — nothing off-panel draws, so there is nothing to clear. A caller holding surfaces across reloads (a controller with a list of runtime cards) can drop them whenever it next rebuilds; it does not have to catch the tree going away.

Which variant you get is the element’s class list, exactly as in UXML — there is one entrance for both. An element carrying glass-dynamic is additionally given a scope of its own, handed to the tracker’s per-frame tick, and Unregister releases that along with the surface. So a runtime overlay that should frost the screen it opened over — a dropdown list, a menu — needs nothing at the call site beyond the class it would carry anyway. `OptionPicker’s list is the worked example.

A scope to itself is the correct grouping for such an element rather than a fallback. A scope places its snapshot in front of its first participant, so an overlay appearing over a finished screen needs a snapshot taken after that screen is drawn — which no scope rooted further up can give it, and which is C2. With one participant C1 holds by construction too. Both clauses come free here; neither does for glass authored in UXML, which shares its panel’s scope with everything else in it.

The tracker could not group these even if it wanted to, and not only because the scan has been and gone: an overlay that has to escape a scrolling ancestor is usually added to panel.visualTree, which is outside every screen root the tracker scans.

A scan never takes an element that already has a surface

Not every runtime element escapes the scanned tree, though — BetterScrollView’s edge arrows are built into a scroll view that sits inside a screen root — and a scan reaching one of those would be the second surface the warning above is about. `ScanRoot therefore never registers a second surface on an element Find already answers for: a surface from the panel’s own previous scan it takes back, and anything else was registered from C# and has an owner. Leaving that one alone is what keeps its own scope, and with it the backdrop its position actually calls for.

The scan is not a one-time event to be reasoned around, which is what makes this load-bearing rather than defensive. RegisterUIReloadCallback fires straight away when the tree is already built, and RefreshPanels runs from the manager’s OnEnable and on every scene load, so a tree that has been up for a while is rescanned routinely. Without the check, a runtime element registered inside it worked until the first rescan and then had two surfaces — both generating a quad, both writing the material, the scan’s one grouped into the panel scope and holding no backdrop of its own. It read as glass once and as its fallback body colour ever after.

Taking a surface back rather than rebuilding it is what keeps the same promise for the panel’s own glass. A surface holds state no element and no stylesheet can hand back — the runtime background a controller set through SetBackground, and the definition it last applied — so a rescan that replaced it dropped the fetched image and fell back to the authored body. That is what the Home screen’s project logo did after every editor UI reload: applied to the surface the reload’s scan had just built, then thrown away by the next scan a re-insertion triggered. A scan therefore re-groups; it does not recreate. It unregisters only the surfaces whose elements the scan no longer finds, and ClearSurfaces — the full release — is left for a root going away for good.

The other order needs more than Find, and this is what SelfManagedClassName is for. A panel root attaches before its descendants — SetPanel gathers the subtree breadth-first and sends AttachToPanelEvent down that list — so a screen toggled back on is scanned a step before the element that would register its own surface gets its own event. In that window the element has no surface, and the scan cannot tell "unclaimed" from "about to be claimed": it adopts the element into the panel’s scope, and the owner’s later Register puts a second surface on top of the first.

So runtime glass built inside a screen root carries glass-self-managed, set once where the element is built and never touched again:

element.AddToClassList(LiquidGlassContentSurface.DynamicClassName);
element.AddToClassList(LiquidGlassContentSurface.SelfManagedClassName);

TakeSurface skips a marked element outright, so ownership is settled by what the element is rather than by which callback ran first — no ordering to reason about, and nothing to keep in step as the element is shown, hidden, detached or re-attached. BetterScrollView.ScrollArrow is the worked example; before the marker the arrows joined the panel scope on the first reload and reported a C1 overlap against the screen’s chrome.

An element outside every scanned root does not need the marker — OptionPicker’s list on `panel.visualTree is never queried in the first place — but carrying it there is harmless, and is the safer habit for anything that might later be moved inside a screen.

Register a dynamic element after putting it in the tree where you can. Its scope places the snapshot against the element’s parent, so a parentless element has nothing to place against; the scope retries on the element’s next AttachToPanelEvent rather than giving up, but until then its glass has no backdrop and reads as basic glass. The retry lands a frame later than the attach: moving elements while UI Toolkit is still mid-attach corrupts the panel’s hierarchy tracking, so the placement goes through the scheduler.

LiquidGlassPanelTracker is the one caller that wants a surface without the scope its class implies — it groups the panel’s dynamic elements itself, once the whole tree is scanned and draw order is known — and uses the internal RegisterGrouped for that. A scope built at registration would be overwritten by that grouping and orphaned: still ticking, still holding a pyramid, still drawing a snapshot every frame, which is a pyramid per element and the one thing scopes exist to avoid.

For content whose image is only known at runtime (a fetched thumbnail), LiquidGlassContentSurface.SetBackground(texture) overrides both USS texture sources. When the element is authored in UXML rather than built from C#, the panel tracker already owns its surface — reach it with LiquidGlassContentSurface.Find(element) instead of registering a second one, which would leave the two fighting over the element’s material. Passing null drops the override and falls back to the USS body, which is how an element returns to its authored colour while the next image is fetched.

Dynamic glass and scopes

Dynamic glass really does refract everything drawn beneath it, previously rendered UI included, which means copying the panel texture mid-draw and blurring it. That copy is the expensive part, so it is paid once per scope rather than once per element: a scope is a group of glass-dynamic elements that share one backdrop snapshot. A screen wants one or two.

A participant’s scope is its nearest ancestor carrying the glass-scope class, or the panel root when it has none — so the common screen (content, then floating chrome that does not overlap itself) authors no scope at all, and splitting into two stays available for the screen that needs it. An element registered from C# at runtime is the one exception: it owns a scope to itself rather than joining one, because it arrives after the grouping has happened — see Runtime elements and runtime images for why that is the right grouping for it and not merely the available one.

ProjectsUI is the worked example of the split. Its sidebar is itself a participant and its add button floats over the sidebar, so the two overlap and cannot share a snapshot (C1). Putting glass-scope on the sidebar separates them with one class and no container: the class is read from a participant’s ancestors only, so the sidebar still falls through to the panel root and refracts the scrim behind it, while the button inside it gets a scope whose snapshot is taken after the sidebar’s body and cards have drawn — which is also the C2 the button wants.

The contract has two clauses, and both are the author’s to keep

C1 — no two participants in a scope overlap. That is what makes it safe for them to share a snapshot: they never need to sample each other.

C2 — everything a participant should refract is drawn before the first participant of its scope. Non-overlap alone does not give this. A header and a tab bar do not overlap, but if the snapshot were taken at the header and the scrolling content drew between them, the tab bar would refract a screen with no content in it. In practice C2 means authoring the layout the natural way — content first, floating chrome last — and it is what the snapshot’s placement guarantees given that.

Glass placed early in the layout is what breaks it, and it breaks the element furthest down. The agent detail screen is the worked example: making its hero version pill dynamic put a participant at the top of the scroll, so the snapshot moved there and the slide over the bottom of the screen refracted a screen drawn no further than the hero. Two ways out — a glass-scope on the floating chrome, so it snapshots after the scroll, or basic glass on the early element. That screen takes the second: a pill on the hero has one image behind it either way, and a scope of its own is a second panel-texture copy and blur per frame to sample it live.

C1 is checked in the editor (an O(n²) rect overlap test per scope, logged through Log.Write on Category.UI) because it is promised silently in markup and violating it produces a subtly wrong blur rather than anything that announces itself.

Where the snapshot goes

The snapshot is a hidden zero-size ImmediateModeElement inserted as the previous sibling of the first participant in draw order. Not a child of the scope container, which would miss content living inside the scope before that participant; and not a first child of the participant either, since generateVisualContent fires after the element’s own background and border, by which point the glass body would already have drawn.

Its callback runs with the panel texture bound and holding exactly everything earlier in z order and nothing later: UIRenderDevice.EvaluateChain flushes every batched draw before entering, and RenderChainCommand.ExecuteNonDrawMesh saves and restores the bound target, the matrices, the scissor and culling around it. (An exception escaping that callback skips every later immediate callback in the frame, so the body is wrapped and a failure disables just its own scope, once, with a log.)

Nothing is deferred or re-ordered. The draw order stays:

snapshot → A body → A children → B body → B children

B samples a snapshot taken before A, so it does not see A or `A’s children — which under C1 is invisible, and is exactly what C1 is for. The glass bodies keep their natural chain positions, which is what makes z order correct by construction rather than by machinery.

"Beneath it" means earlier in the panel’s draw order — not containment, and not overlap. This is the thing that surprises.

The pyramid

LiquidGlassBackdropPyramid builds the blurred mip chain the participants sample. Mip 0 is the scope’s region copied out of the panel texture with the scene composited under it (which leaves it opaque, so a dynamic glass body needs no alpha), and every deeper level is a half-resolution downsample plus a Kawase blur at its own resolution — so the effective radius doubles per level and a participant reads its own --glass-blur radius off a trilinear lod. Its edge reflection reads a second lod off the same chain, at --glass-reflection-blur, so the mirror ring can be softer or sharper than the body without costing another pyramid.

The pyramid’s resolution is derived from the blur radius rather than being a fixed fraction, so that a requested radius always lands on the same pyramid level. This is what keeps the blur DPI-independent: the panel is ConstantPhysicalSize, so a point-authored radius is a different number of pixels at every DPI, and against a fixed fraction it would slide up and down the levels. A side effect worth knowing: a larger radius makes the pyramid smaller, so big blurs are cheaper than small ones.

Sharing one pyramid between radii that differ splits that rule in two. Every radius read off the chain counts here, a participant’s reflection blur as much as its body blur — they are lods on one texture, so neither end can be sized for only half of them:

  • Mip 0’s resolution comes from the smallest radius in the scope, so nothing falls below LiquidGlassBlur.LevelOneRadius, where the lod mapping can only blend toward the sharp mip 0 and reads as a faint double image rather than a blur.

  • The level count comes from the largest, so the deepest read still reaches its radius.

Radii far apart in one scope therefore cost what the smallest asks for in resolution and what the largest asks for in levels. A scope whose participants share a radius — the normal case, including a reflection blur left unauthored — costs exactly what one element would.

This is the price of a sharp mirror ring. --glass-reflection-blur: 0 on an otherwise heavily blurred element makes the smallest radius in its scope zero, which pins mip 0 at full resolution and undoes the "big blurs are cheaper" property for every participant sharing that pyramid. A small radius is nearly as sharp and far cheaper.

The region is the union of the participants' bounds, padded by how far their shading actually reads outside them — the wider of the two blur radii’s tap spread, plus the refraction’s lateral throw, both taken from each participant’s own authored values and maximised over the scope, so the padding follows a screen that tunes its glass rather than a fixed number that under-pads at production settings. It is then clamped to the panel and grown to a 64 px size class. Where the edge reflection samples is not padded for: its outward offset reaches further than either term and is left to clamp deliberately, because it mirrors the backdrop rather than showing it, so a clamped tap reads as a slightly duller rim where a clamped body tap would read as a smear.

A scope re-resolves every frame

The region and the mapping into it ride inside each participant’s material definition rather than being read per draw, so the scope has to keep them in step with where its participants actually are. Style and geometry events are not enough and cannot be made enough: they fire on layout, and the things that move floating chrome are transforms — a sidebar sliding in on translate, a button pressing on scale. Both change worldBound with no layout pass at all.

So LiquidGlassPanelTracker.TickScopes re-resolves every scope once per frame, from the manager’s LateUpdate (and EditorTick). The events are kept only because they land in the same frame as the change rather than on the next one. The per-frame path is written to allocate nothing and rewrite nothing when nothing moved — one worldBound read per participant and a state compare — because rebuilding a material definition allocates a property block.

Do not "optimise" the tick away. Without it a scope latches onto wherever its participants stood at the last relayout and samples the wrong strip of the screen indefinitely.

Rotation is not representable as the offset and scale the mapping carries, and is not handled; nothing rotates glass.

The glass look

Refraction is physically derived rather than a tuned distortion: the rim is modelled as a squircle bevel (superellipse quarter h(u) = (1 - (1 - u)4)(1/4) across the bezel width) whose slope gives the glass surface normal, and the straight-down view ray is Snell-refracted at IOR 1.5 and marched through the local glass depth to the background. --glass-edge-width sets how far the bevel reaches inward; --glass-refraction-strength is the glass thickness at full edge width, so thicker glass bends more. The bezel width is clamped to each element’s min half size (with thickness scaled down alongside), so elements smaller than twice the width curve into a fully lensed dome while everything else shares the same rim. The near-vertical slope at the silhouette is what produces the bright compressed outline — there is no separate outline term.

The rim is lit by two complementary terms with independent strengths, both confined to the same band — the outer --glass-highlight-width of the bevel, falling off quadratically inward. That band is a fraction of the edge width, so it stays proportional to the rim and moves with it, and retuning it moves both terms together. There is deliberately no second width: the two are one ring lit two ways, and letting them drift apart reads as two rims.

An environmental reflection re-samples the background offset outward along the rim normal — the bevel acting as a mirror ring — blended in where the reflected content is bright, the refracted rim beneath is dark, and the band puts it. Its intensity is --glass-reflection-strength (0–1); the reach and luma gating are shader constants. On a sampled body this, not the refraction, is what reads as rim lensing: its reach is 1.5 x (bezel + thickness), some sixty times the refraction’s throw at the shipping tuning — so it reaches far outside the element for its image while showing it in a thin ring. On dynamic glass it samples at its own blur radius, --glass-reflection-blur, so the mirror ring can be crisper than the body it sits on (or softer) — see The pyramid for what a sharp one costs. On the other bodies there is no chain to read a second lod off, so it is sampled as sharp as they are. An artificial specular adds a fixed-light gleam (global top-left light, plus a weaker mirrored back light) over the same band, so glass still reads as glass over a flat or dark background. It is tuned by --glass-highlight-strength / --glass-highlight-width / --glass-highlight-sharpness; the light direction is a shader constant.

A constant-colour body skips the edge reflection entirely: mirroring a colour into itself is a no-op, and only the bevel’s shape survives, through the rim gleam.

The look is authored in USS, once

Every parameter above is a --glass-* custom property resolved per element by LiquidGlassContentSurface, exactly like the tint — the renderer feature carries no visual settings, only the scene copy’s resolution and its shader reference.

Components.uss authors one set on .glass, which is what makes the look global; it is a convention rather than a mechanism, so a screen that needs a different rim overrides the property and gets :hover states and -unity-material transitions on it for free, like every other glass parameter.

.glass, .glass-dynamic {
    --glass-edge-width: 15;             /* how far the bevel reaches inward, in points */
    --glass-refraction-strength: 50;    /* glass thickness at full edge width, in points */
    --glass-reflection-strength: 0.5;   /* environmental edge reflection, 0-1 */
    --glass-highlight-strength: 1.5;    /* fixed-light rim gleam; 0 disables it */
    --glass-highlight-width: 0.15;      /* rim band for BOTH lighting terms, as a fraction of the edge width */
    --glass-highlight-sharpness: 5;     /* angular falloff around the light direction */
}

Lengths are in points, and glass works in points all the way to the fragment, so equal values render the same bevel at every DPI. Where a property is not authored at all, the surface’s own Default* constants apply — the single fallback, matching the values above, with nothing else holding a second copy to drift from. None of this depends on the renderer feature, so the look is identical on the quality presets it is absent from.

Why the UI renders into a texture

BasePanelSettings paints into a render texture owned by LiquidGlassManager rather than straight to the screen. Basic glass does not need that at all — UI Toolkit draws it inline, on the panel’s own target, whatever that is. The texture exists so that dynamic glass has something to sample mid-draw: a backbuffer cannot be sampled, and binding a pyramid level is what makes the panel texture legal to read.

When the feature is absent the texture is detached and the panel becomes a plain screen overlay. Basic glass is unaffected; dynamic glass has nothing to snapshot and degrades to basic glass.

Frame order

Glass is not part of the camera’s frame at all — UI Toolkit paints it, scope snapshots included, into the panel’s render texture before cameras render. The renderer feature contributes three passes (two where its scene backdrop is off):

LiquidGlassSceneHoldPass at AfterRenderingPostProcessing records nothing. Its presence at that event keeps URP’s post-processing from writing directly to the backbuffer, and its requiresIntermediateTexture flag keeps the camera color an intermediate even with post-processing disabled.

LiquidGlassSceneColorPass at the same event copies the post-processed scene into a persistent texture published as the global _LiquidGlassSceneColor, at a fraction of the screen resolution (the feature’s Scene Color Scale; 0 disables the copy). It exists for the glass UI Toolkit draws itself: those draws happen in the player loop, before any camera runs, so the scene they can show is always the previous frame’s — harmless behind a blur, but the reason this texture is owned by the pass instead of created in the render graph, where it could be recycled before it is ever sampled. Both basic glass’s screen-space body and the bottom of every scope’s pyramid read it.

The same pass then blurs that copy into its own mips, as many as the feature’s Scene Blur Levels asks for, so a screen-space body can frost the scene. It is the same construction as a scope’s pyramid — half-resolution downsample, then a Kawase blur at the level’s own resolution, ping-ponged through a scratch so no step samples the level it is writing — off the same shader and the same tap spreads, because both chains are read through the one LiquidGlassBlur.RadiusToLod mapping and that only holds while they blur alike. It runs in an unsafe pass: retargeting a different mip per step is exactly what a raster pass may not do. The chain’s shape is published as _LiquidGlassSceneColorPyramid, the same three numbers a scope hands its participants.

URP’s final blit then upscales the scene to the backbuffer at native resolution.

LiquidGlassComposePass at AfterRendering draws the panel texture over the backbuffer with a single premultiplied blit (composite shader pass 0, One / OneMinusSrcAlpha — how UI Toolkit leaves a transparent-cleared target), so the scene already in the backbuffer shows through wherever the UI is transparent. Target orientation differences are resolved at draw time via GetTextureUVOrigin.

That makes the panel texture’s alpha channel coverage — how much of the frame the UI owns — which constrains every draw into it, glass included. Alpha has to accumulate, aOut = aSrc + aDst(1 - aSrc), so the glass shader carries a separate alpha blend term:

Blend SrcAlpha OneMinusSrcAlpha, One OneMinusSrcAlpha

Blend alpha like a colour instead and it becomes aSrc x aSrc + aDst(1 - aSrc), where a source alpha below 1 drags down coverage that opaque UI beneath had already written — and the compose blit duly shows the 3D scene through it. Any glass drawn at partial effective opacity over an opaque screen hits this; over the scene, where there is no coverage underneath to destroy, it only reads as a fade that takes hold late.

Turning the scene backdrop off

The copy is worth nothing on a screen with nothing behind the UI, so the feature’s Scene Backdrop switch skips LiquidGlassSceneColorPass outright — no copy, no chain, and the texture released rather than left allocated and stale. Both consumers then fall back on their own, since both read the copy off its global name rather than through a setting: a screen-space body resolves to its --glass-background-color (the manager notices the global going away and rebuilds every surface), and a scope’s mip 0 composites the panel over SceneStandIn, a flat near-black, instead of the scene.

So it costs nothing to look at except where something was showing the scene — which is the case it is meant to be turned off in. Dynamic glass keeps working either way; what it loses is the scene showing through wherever the UI beneath a participant is transparent.

QualityManager drives the switch through LiquidGlassFeature.SceneBackdropEnabled, off whenever the app is outside an interaction and on again on entry, alongside the camera’s post-processing and the interaction-only renderer features — outside one there is no 3D scene up to show. It only ever switches off what the preset authored on: the authored value is captured the first time the feature is seen, which is before any write of its own. Because those writes land on a renderer asset, whose runtime value outlives play mode, it restores every feature it touched on teardown in the editor.

A Scene Color Scale of 0 does the same thing, and the switch is the discoverable way to say it — the scale is otherwise a resolution, and reaching a disable by sliding a resolution to zero reads as a mistake.

Render scale

The panel texture is always at native screen resolution, so UI sharpness is independent of the 3D render scale; only the blurred scene content inherits render-scale softness, hidden under the blur. A native-resolution texture also keeps UI Toolkit’s default identity screen-to-panel mapping correct for pointer input.

Runtime behaviour

LiquidGlassManager (on the Screen Controller prefab) owns the render texture — the project asset Assets/UI Toolkit/LiquidGlassBaseUI.renderTexture, kept as an asset so BasePanelSettings.targetTexture never references a non-persistent object. Each LateUpdate the manager checks LiquidGlassFeature.IsRunning:

  • Feature present (High/Ultra renderers): the texture is resized to match the native screen resolution and assigned to BasePanelSettings.targetTexture.

  • Feature absent (Low/Medium renderers, or disabled): the target texture is removed and the panel renders as a plain overlay. The UI stays fully functional and basic glass keeps rendering. Query LiquidGlassManager.IsGlassActive to switch fallback styling.

The manager keeps BasePanelSettings.clearColor in step with the target texture, because UI Toolkit clears whatever the panel renders into — Panel.RenderRootTree honours the panel settings without caring whether the target is a texture or the screen. The texture has to start transparent every frame, so the flag is on while it is attached; a plain screen overlay must not clear at all, or it wipes the already rendered scene to black and leaves nothing but the UI. That was exactly the feature-absent fallback’s failure mode, so the committed asset value is the overlay-safe false and the manager turns it on when it attaches the texture.

Each LateUpdate the manager also publishes the panel target size (which basic glass’s screen-space body needs to normalize SV_POSITION) and ticks every scope. It rebuilds every surface’s material on the two inputs no style or geometry event announces, both of which glass bakes into its material rather than re-reading per draw: whether the scene copy exists and whether the panel texture is attached. The look itself needs no such path — it comes from USS, which announces its own changes.

The manager owns glass registration, through LiquidGlassPanelTracker. On enable and on every scene load it hooks a UI reload callback on every PanelRenderer whose panel settings it recognises (IsGlassPanel), including ones on inactive GameObjects; from there UI Toolkit drives everything. Each reload rescans that panel’s fresh tree for the glass and glass-dynamic classes, registers a surface per element and groups the dynamic ones into scopes in query order, which for UI Toolkit is draw order — that is what lets a scope place its snapshot in front of its first participant. Attach and detach events on the panel root cover a panel or its GameObject being toggled: the detach drops the scopes, whose snapshots must not outlive the layout they were resolved against, and the re-attach scan takes the surfaces back and groups them again. The detach only takes the scopes off the entry — the release itself, which pulls each snapshot out of the tree, is deferred a frame, since an element keeps its panel until the detach event is through and moving one then corrupts the panel’s hierarchy tracking. A scan never registers a second surface on an element that has one, so an element can never end up with two surfaces (and two draws). Renderers created at runtime are the one case nothing notices — call LiquidGlassManager.RefreshPanels() after instantiating one. The manager also supplies both shaders, since glass must work without the renderer feature.

This is deliberately independent of what else sits on the PanelRenderer: glass does not require a UIController (or any other component) on the panel, and edit mode and play mode run the exact same registration code.

Edit mode

The full pipeline also runs in edit mode, so the game view previews real composited glass instead of a glassless UI over the camera clear color. LiquidGlassManager is [ExecuteAlways], but the edit mode loop is driven by LiquidGlassEditorPreview (in Promethist.LiquidGlass.Editor) on EditorApplication.update, because LateUpdate only runs on sporadic editor player-loop ticks. Each editor update the preview:

  • checks the active URP asset’s renderer list for an enabled LiquidGlassFeature — the play mode IsRunning frame stamp is meaningless without a steady player loop,

  • sizes the panel texture from Handles.GetMainGameViewSize(), and

  • rescans panels (RefreshPanels) on hierarchy changes, since GameObjects come and go while editing.

Surfaces themselves are not an edit mode concern: the manager’s panel tracker is [ExecuteAlways] code and registers them the same way in both modes. What the preview does own is repainting — the edit mode game view only redraws when something asks it to, so both the tracker and the texture tick queue an EditorApplication.QueuePlayerLoopUpdate() whenever they change something the game view should show.

The game view size is read from the game view rather than from the last frame the feature rendered. A "size known only after a render" is a static that every domain reload clears, and with no size the manager detaches the texture while nothing queues the repaint that would restore it — glass then stays gone until play mode happens to supply a size again.

In edit mode the panel paints into a temporary render texture — never the LiquidGlassBaseUI asset — so previewing does not modify committed assets. The one transient piece of asset state is BasePanelSettings.targetTexture; LiquidGlassSaveGuard (an AssetModificationProcessor) detaches it right before the settings asset is saved, so the committed value stays null and saves carry no preview noise.

Limitations

  • Glass shapes are axis-aligned rects; element rotation is ignored, by the silhouette and by a scope’s backdrop mapping alike.

  • Basic glass does not show the backdrop at all, and its screen-space body shows the scene only, one frame late, and blurred no finer than a quarter-resolution copy allows.

  • A dynamic element that overlaps another in the same scope violates C1 and refracts a screen the one beneath it had not been drawn into yet.

  • An element whose subtree paints nothing may be skipped by UI Toolkit’s renderer — give it any background if its glass fails to appear.

Ground that is implemented but has never been measured, and is worth checking before trusting:

  • _UIE_FORCE_GAMMA, which the Low and Medium quality presets turn on. The multi-compile is declared and the colour-space handling is written, but neither has been seen working.

  • The screen-space body’s SV_POSITION orientation on Vulkan (Android) and WebGL. Measured on Metal/macOS, where the rasterizer’s row convention and the render texture’s v cancel and no flip is needed. Nothing else reads SV_POSITION: the colour, element-texture and scope-backdrop bodies all address through the point-space UV.

  • The cost of a scope. One snapshot per screen is the whole claim of the design, and nothing has counted one. From the earlier per-element spike’s numbers the target switch dominates and the pixel work is nearly free, so the result should be roughly one target switch per scope regardless of how many participants it has — and if it is not, the scope is being rebuilt more often than once a frame.

A manual test rig lives in Assets/UI/LiquidGlassTest/ (LiquidGlassTestBaseUI.uxml, LiquidGlassTestStyles.uss, driven by LiquidGlassTestBaseUI.cs): basic glass on cards, chips and a pill, a dynamic element placed over them so C2 can be checked by eye, and a side-by-side pair of the two variants at the same size, radius, tint and blur.

Files

LiquidGlassFeature / LiquidGlassSceneHoldPass / LiquidGlassComposePass

URP renderer feature: keeps the post-processed scene sampleable and composites the UI panel texture over the backbuffer premultiplied at native resolution. Carries no visual parameters — the look is USS

LiquidGlassSceneColorPass

Publishes the previous frame’s post-processed scene as _LiquidGlassSceneColor, for the glass UI Toolkit draws inside its own draw loop, and blurs it into its own mip chain (one for the whole screen) so a screen-space body can frost it. Skipped, and its texture released, where the feature’s Scene Backdrop is off

LiquidGlassBlur / LiquidGlassShaderProperties (in LiquidGlassPass.cs)

The shared terms of both blur chains — LevelOneRadius, RadiusToLod, the Kawase tap spreads, GetBlurFormat — so the radius → lod mapping has one definition on both the C# and shader sides, and the two chains it reads cannot blur differently; plus the handful of global shader ids

LiquidGlassManager

Panel render texture lifetime + graceful fallback; IsGlassPanel marks the panel it owns; owns the panel tracker; publishes panel state and ticks scopes; supplies both glass shaders

LiquidGlassPanelTracker

Hooks every panel it recognises, keeps its glass and glass-dynamic elements registered across reloads, attaches and detaches, groups the dynamic ones into scopes in draw order, and ticks them per frame — same code in play mode and edit mode

LiquidGlassContentSurface

A glass element: emits the quad, builds the MaterialDefinition from the --glass-* properties and writes it inline, and fades its children ahead of the glass while its opacity is below 1. ClassName / DynamicClassName are the glass / glass-dynamic markers. Also the editor-only own-paint warning

LiquidGlassScope

One backdrop snapshot shared by a group of glass-dynamic elements: the snapshot ImmediateModeElement, the region, the mapping handed to each participant, and the C1 overlap warning

LiquidGlassBackdropPyramid

One scope’s blur pyramid: resolves its shape from the scope’s radii, allocates it, and blits the chain from inside the snapshot callback

LiquidGlassCornerRadii

Resolves an element’s four corner radii in points, fitted to the element the way CSS resolves overlapping curves

LiquidGlassEditorPreview / LiquidGlassSaveGuard / LiquidGlassFeatureEditor

Editor assembly: ticks the manager in edit mode and rescans panels on hierarchy changes; strips the transient preview texture reference from asset saves; nests the scene copy’s settings under the Scene Backdrop switch in the renderer feature’s inspector

Shaders/LiquidGlassSurface.shader

The glass shader, drawn inline by UI Toolkit via -unity-material: superellipse SDF, bevel refraction and rim lighting over one of four backdrop sources

Shaders/LiquidGlassCommon.hlsl

The look itself — bevel profile, superellipse SDF, Snell refraction, edge reflection, rim specular — unit-agnostic and shared, so nothing can drift from it

Shaders/LiquidGlassBackdropPyramid.shader

One four-tap diagonal Kawase filter serving every step of both chains — copy, downsample or blur depending on its tap offset — behind two vertex paths: a mesh for a scope’s pyramid (pass 0, drawn from the snapshot callback) and a procedural triangle for the scene chain (pass 1, drawn from the render graph)

Shaders/LiquidGlassComposite.shader

Premultiplied composite of the UI panel texture (pass 0) and opaque copy (pass 1)