Core · guide
Compositing groups
How a translucent subtree is rendered into a surface of its own and blended back once — the offscreen pass behind `opacity`, `filter: blur()`, the seven colour functions, `drop-shadow()` and `mask-image`, why a group is not the same as fading each element, why a colour matrix and a mask cost neither a surface nor a pass where a blur and a drop shadow cost both, why a mask's seam is fixed on both executors where a matrix's is free, why a drop shadow's seam is fixed by arithmetic that does not commute, how a colour matrix with zero coefficients turns a surface into a tinted silhouette, how a list of mask layers is folded into one coverage and what `mask-composite` means for each, when the pass is skipped as an exact identity, what the surfaces cost, how a backdrop filter is a replay of the draw-list prefix rather than a read-back and what that cost the compositor's walk, what gradient text would still need on top of it, how `rotate` and `scale` ride the composite quad's four vertices for the price of no shader at all, and why `mix-blend-mode` is the one group-wide effect that has to read its destination — free on the software rasteriser, and a replayed capture in a second descriptor set on the device.
Edit this page on GitHubDocuments
What it is
A composited group is a subtree of the interface that is drawn into a surface of its own and then
blended back into the frame as a single image. UiLayer is the record that describes one: which
draws belong to it, how big its surface has to be, and what it is faded by.
Seven things open a group: an element whose opacity is below one, an element with a filter, one
with a mask, one with a backdrop-filter, one with a rotate or a scale, one with a
mix-blend-mode, and one with isolation: isolate.
The difference between the first two is worth stating up front, because it is why the second could
not be approximated while the compositor was being built. An opacity can be faded element by
element —
badly, but visibly — whereas a filter is a function of the rasterised subtree. With no surface there
is nothing to convolve, and pushing a colour matrix down onto each command instead would be right on
a bare panel and wrong the moment two of the group's children overlap with partial coverage: CSS
transforms the group's rendered result, which is why Filter Effects 1 § 5 makes any filter other
than none a stacking context.
⚠ The two filters cost wildly different things and the guide separates them throughout. A blur needs a scratch target, two more passes and a wider bound; a colour matrix needs a pipeline switch on a draw that was happening anyway.
The pieces, top to bottom:
| Where | What it does |
|---|---|
DrawListBuilder | Brackets a translucent, filtered, masked, transformed, blended or isolated subtree with LayerPush / LayerPop |
DrawBatcher | Gives each bracket a BatchKind.Layer batch of its own, never merged |
UiGeometryBuilder | Resolves the brackets into UiGeometry.Layers, outsets the bounds by a blur, and emits the compositing quad |
UiRenderer.Compose | Renders each group's pass on the device, sweeps a blur over the ones that ask, and hands each group's colour matrix to its composite draw |
SoftwareUiRasterizer | Executes the plan on the CPU, which is where the visual baselines render |
What it is for
A group is not the same as fading each element, and the difference is visible. CSS Compositing 1 § 3 renders a translucent element's subtree into an isolated surface and blends that surface once. Multiplying each element's alpha instead lets a subtree show through itself: two overlapping children of a half-opaque panel each become translucent, so the lower one is visible through the upper one that is supposed to cover it.
The two answers agree exactly when the subtree draws one thing, and diverge as soon as it draws two
that overlap — which includes the very ordinary case of a box with both a background and a border.
That is why :disabled, which is opacity: 0.55 on every control in ControlTheme, changed
appearance when this landed: a disabled button's border ring and its glyphs had been fading against
the background they sit on, and now the whole button is composed at full strength and faded once.
Using it
Nothing to call. Write opacity in a stylesheet — or opacity-50, the utility — and the group is
formed for you:
.panel { opacity: 0.5; }What is worth understanding is when a surface is actually allocated, because it is not every time:
- A subtree that comes to one draw command is collapsed back to a plain fade. Compositing a single premultiplied fragment
Fthrough a surface and blending it atagivesa·F; foldingainto that one command's alpha before it is premultiplied gives the samea·F. This is an identity, not an approximation. It matters because a tree of a hundred outliner rows, each fading one icon, would otherwise ask for two hundred surfaces and two hundred render passes a frame. - A subtree that draws nothing opens no group, and
opacity: 0skips the subtree entirely rather than compositing it and throwing the result away. - A group that inks nothing the clip allows through is dropped, because a zero-sized surface is a validation error rather than an empty picture.
UiTest.Geometry reports what actually happened, which a screenshot cannot: a group composited when
it did not need to be draws an identical picture and costs a pass.
Assert.Empty(ui.Geometry.Layers); // the fade was folded into the one commandTwo decisions that keep the renderers honest
The GPU renderer and SoftwareUiRasterizer execute the same plan — UiGeometry.Layers, computed
once in Vixen.Ui — rather than each deciding for itself which draws belong to which surface. Two
properties of that plan are load-bearing, and both exist to remove a way the two could disagree.
A layer's surface is the size of the whole viewport, not of the group. A surface sized to the group would need every vertex inside it translated by the group's origin, on both paths, in the same direction, with the same rounding. A disagreement there is a subtree drawn a pixel off — which no unit test would be looking at, and which the committed screenshots would report as a diff somewhere else entirely. At viewport size there is no translation to get wrong. It costs memory on any frame that has a translucent group at all.
A layer's bounds are the ink, not the element's box. Opacity isolates a subtree without bounding it, so a child that overflows its half-opaque parent is still part of the group. The bounds are read back from the vertices the group emitted — the only complete account of what it drew — and rounded outward to whole pixels, because a group's edge is antialiased and a bound rounded to the nearest pixel would clip a hairline off whichever side the fraction fell the wrong way.
filter: blur()
blur-2, or the CSS it assembles into, puts a Gaussian over the group's finished surface:
.panel { filter: blur(4px); }The length is the Gaussian's standard deviation, which is what Filter Effects 1 § 8.4 says and
not what box-shadow's third length means — that one is the total distance an edge fades over and
is halved on the way to the shader. Three things follow from putting the filter on the surface
rather than on each primitive:
- The group's bounds grow by three sigma before the clip narrows them. A blur moves coverage to pixels no vertex of the group ever touched, so the compositing quad has to be wider than the ink or the halo is cut off flush with the unblurred silhouette — a soft edge with a hard line across it. The outset happens first and the clip second, because an ancestor's
overflow: hiddenclips the filtered result. - A blurred group is never collapsed. The single-command peephole above is an identity for opacity and nonsense for a filter, and a
blur-*on a bare panel is exactly one background rectangle — the common case, not a corner of one. - On the device it is two more render passes and one shared scratch target. A separable blur cannot read and write one attachment, so the surface is swept across into a scratch and back down into itself. The scratch is borrowed and handed back between the group's own pass and its
ShaderReadbarrier, so one is enough for the whole frame however many groups are blurred.
UiRenderer.Blurred is what says it happened. A blur has three ways of quietly not happening — no
UiShaders.Blur handed over, no UiLayer.Blur on the geometry, a kernel radius that came out zero —
and all three produce a correct, composited, sharp picture.
The seven colour functions
brightness, contrast, grayscale, invert, saturate, sepia and hue-rotate are read too,
and they are not the same kind of thing as a blur:
.thumbnail:disabled { filter: grayscale(1) brightness(.8); }Each is a per-pixel colour transform, so several of them in one declaration compose into a single
UiColorMatrix — three rows of four floats, the coefficients and an offset per output channel — and
that matrix rides on UiLayer.Filter. What follows from not being a neighbourhood operation is the
whole design:
- No second surface and no extra pass. The matrix is applied in the fragment stage of the composite draw the group was going to make anyway. On the device that is
ui-colour.fragbound instead ofui-image.frag, with the matrix in forty-eight bytes of push constant; inSoftwareUiRasterizerit is a pass over the finished surface at the same seam the blur runs at. The two places differ and the picture does not — see below. - No bounds outset. A colour matrix moves no coverage, so
UiLayer.Boundsis the ink and nothing more.UiColorMatrix.Applymaps transparent black to transparent black, so growing the rectangle would only add pixels that are provably empty. - A filtered group is never collapsed, for the same reason a blurred one is not: a grey rectangle is not a fainter rectangle.
- The order among them is honoured.
invert(1) brightness(2)is notbrightness(2) invert(1), andUiColorMatrix.Thencomposes left to right, which is CSS's order. ⚠ The order between a colour function and ablur()is not carried, because it cannot matter: a Gaussian is a weighted sum whose weights total one and the matrix is affine in premultiplied colour, so the two commute exactly.
UiRenderer.Filtered is what says it happened, and it is worth more than Blurred is. A blur that
did not run leaves a sharp picture — a different picture. A matrix that did not run leaves the
right picture wherever the group's colours happen to sit near the matrix's fixed points, so no
screenshot and no comparison of the two executors can see it.
⚠ The arithmetic is in the engine's linear working space and a browser's is in sRGB. Filter
Effects 1 § 8.5 runs the shorthand functions with color-interpolation-filters: sRGB; Vixen is
linear from the parser down. A grayscale-50 here is therefore slightly darker than the same class
in a browser. Matching exactly would mean an encode and a decode per pixel on both executors, to
reproduce a rule the spec itself calls a legacy default — and it would cost the linearity the
paragraph above spends on commuting with the blur.
⚠ A filter carrying a function the engine cannot run refuses the whole declaration.
filter: drop-shadow(2px 2px 4px black) blur(4px) draws unfiltered rather than
blurred-and-missing-a-shadow, which is the rule box-shadow already keeps. So does
brightness(-1), which is invalid CSS, and hue-rotate(90), which is a bare number where an angle
is required.
mask-image
A gradient can fade a group out instead of dimming it:
.overflowing { mask-image: linear-gradient(to bottom, black 70%, transparent); }<div class="mask-linear-from-70%">…</div>By default only the alpha of the mask gradient is read — mask-mode resolves to alpha for
every image that is not an SVG <mask> — so linear-gradient(to bottom, black, transparent) and
linear-gradient(to bottom, #ff0000, #00ff0000) are the same mask. Linear, radial and conic all
work, with the same geometry background-image uses, so a mask and a background written with the
same gradient line up exactly.
mask-luminance (mask-mode: luminance) reads the stops' brightness instead, which CSS Masking
1 § 7.2 defines as luminance(rgb) × a. ⚠ That is a scalar per stop, so it costs no lane in the
entry and no branch in either executor: DrawListBuilder.MaskAlphas computes it from colours it
has already read and writes it into the same three floats the alpha reading fills. The two modes are
not slightly different numbers — a ramp between two opaque colours is the identity under alpha
and a full ramp under luminance. mask-alpha is the opt-out, and mask-match (match-source) is
the alpha reading, because there is no SVG <mask> element here for it to resolve against.
Like a colour matrix and unlike a blur, a mask is per pixel: it adds no second surface, no extra
pass and no bounds outset, and it rides the composite draw as ui-mask.frag. A masked group is
never collapsed, for the reason a filtered one is not — a faded-out rectangle is not a fainter
rectangle.
⚠ The one thing a mask does that a colour matrix does not is fail to commute. A matrix is the
same affine map at every pixel, so it passes through a Gaussian and through a bilinear tap, which is
why the two executors are free to apply it in different places. A mask is a scalar that varies
with position: m(p)·Σ wᵢsᵢ is not Σ wᵢ·m(pᵢ)·sᵢ wherever the ramp is not flat across the kernel —
which is exactly over a blurred edge. So the seam is fixed rather than free. Both executors apply
the mask at the composite draw, after the blur and after the matrix, reading the same texture
coordinate. Folding it into the surface on either path would draw a ring of the wrong brightness just
inside a blurred edge, and UiCompositingTests is the only thing that would notice.
⚠ The mask box is the element's border box, not UiLayer.Bounds. The bounds are the group's ink
and a blur has already outset them; resolving the ramp against them would slide the gradient sideways
the moment somebody added blur-sm beside the mask.
⚠ A mask the engine cannot resolve masks nothing, which is the opposite of what an unpaintable
background-image does. A missing background leaves the element its own colour; a mask that failed
closed would erase it, and a blank rectangle is indistinguishable from a layout collapse. Masking 1
§ 4.1 says the same.
A list of masks, and mask-composite
mask-image is a list, and so is UiLayer: it carries a range of UiGeometry.Masks rather than
one mask. mask-composite says how each layer meets the layers below it.
.corners { mask-image: linear-gradient(to top, #000 50%, transparent), linear-gradient(to left, #000 50%, transparent); mask-composite: intersect;}<div class="mask-t-from-50% mask-l-from-50%">…</div>The four operators are Porter-Duff on the coverage alone, with the layer as the source and the
already-composed layers under it as the backdrop: add is s + b(1 - s), subtract is s(1 - b),
intersect is s·b, exclude is s(1 - b) + b(1 - s).
⚠ The default is add, which is CSS's initial value and not what generated stylesheets look
like. Every mask-* utility writes intersect explicitly, so a reader who learned the property
from Tailwind's output would guess wrong. The reason the utilities write it is worth knowing, because
it is what makes them compose at all: each class fills one layer of the same three-layer mask-image
and the layers nobody filled resolve to a fully opaque gradient — and an opaque layer is the identity
under intersect and only under intersect. Under add an opaque layer forces full coverage
everywhere, so the mask would do nothing at all.
⚠ The list is folded bottom-up, and only subtract can tell. CSS lists mask layers topmost
first, and Masking 1 § 5.4 gives each layer's operator the composed layers below it as its backdrop —
so the walk starts at the last entry. Three of the four operators are symmetric in their two
arguments, so a fold run the wrong way would produce the identical picture under add, intersect
and exclude; s(1 - b) is the one that is not b(1 - s).
⚠ The bottom entry is taken as itself, which departs from one sentence of the specification. Read
literally, the bottom layer composites against a transparent-black backdrop, which makes intersect
— s·0 — erase everything. That cannot be what browsers do, because mask-composite: intersect is
what Tailwind emits for every one of its edge ramps and those ramps visibly work. Starting from the
bottom entry itself makes all four operators the identity on a list of one, which is the property
that has to hold: adding mask-composite to a single-layer mask must not change the picture.
⚠ Layers that provably say nothing are dropped before the group is opened, not in an executor. A
mask is what makes an element a composited group, so a list that reduces to nothing has to reduce
to nothing while the group is still being decided on — otherwise a mask-t-from-* that happened to be
fully opaque would cost a viewport-sized surface and a composite pass to draw a picture identical to
the one that needed neither. DrawListBuilder.Reduce does it, and it does the two reductions that are
true of every input: an opaque intersect layer is the identity wherever it sits, and an opaque
bottom layer is the identity when the layer above it intersects.
⚠ A list is capped at eight layers and one unreadable layer refuses the whole declaration. Six is
what the utility layer can generate — four edge ramps plus a radial and a conic — and past eight the
list is refused rather than truncated, because truncation drops the layers at one end and produces a
mask that nearly works. Dropping one bad layer out of the middle is worse still: it changes the
arithmetic of every operator around it, and a missing subtract leaves the thing it was meant to
punch out.
⚠ The entries ride a storage buffer, not push constants. One entry is eighty bytes and
ui-mask.frag already pushes a colour matrix at forty-eight; with the vertex stage's sixteen that
came to more than the 128 bytes Vulkan guarantees before the placement lane was added at all. So the
draw pushes an index and a count and the entries come through the descriptor binding UiShape
already uses — see the renderer's README for why that buffer has a fixed capacity.
Placing the mask
mask-size-*, mask-position-* and mask-repeat-* place a tile inside the mask box, through
the same two parsers background-size and background-position use — Masking 1 § 4 defers to
Backgrounds 3 for all three grammars, so a mask and a background written the same way put their
layers in the same place. The tile is one pair of lanes on the entry: AreaCentre, and AreaHalf
with mask-repeat encoded in its sign — positive tiles with a period of twice the component,
negative paints one tile and gives zero coverage outside it, which is what CSS means by not
painting a layer there. All zero means the tile is the mask box, which is what every entry written
before the lane existed meant.
⚠ Nothing is written unless a size or a position was stated, and that guard is load-bearing
twice. With the tile equal to the mask box every keyword of mask-repeat draws the same picture,
so the lane would say nothing; and because a clipping tile is never opaque, a lane written anyway
would stop Reduce dropping the five opaque layers every Tailwind mask emits — turning a one-entry
list into six and opening a group for each.
mask-radial-at-* is a different frame again: it moves the ramp inside the tile rather than the
tile inside the box, and it grows the reach with the centre, because CSS's default ending shape is
farthest-corner. It is per layer, where the tile is per element — mask-image is a list and each
layer carries its own gradient function.
⚠ What is still absent, and each for a stated reason. mask-origin-* and mask-clip-* both
default to border-box, which is the only rectangle this engine has — there is no padding, content,
fill, stroke or view box to resolve the other values against, so every one of them would draw the
same picture. mask-type-* applies to SVG <mask> elements, which this engine has none of.
⚠ The radial ending shapes used to be listed here and no longer belong: mask-circle,
mask-ellipse, mask-radial-closest-side and its three siblings are all read, and
BackgroundGradient.Reach is the closed form for each — the refusal that stood here named a blocker
(a stated pair of radii) that had been satisfied all along. What is still refused is an ending
size stated as a <length>{1,2} pair, which needs two lanes the record does not have; a refused
layer is no mask at all rather than a slightly wrong one, which is why that one stays refused rather
than being approximated. bg-clip-text is a separate matter again — see doc 43, which names the text-coverage surface it is waiting on.
UiRenderer.Masked is what says a mask happened, and it is worth having for Filtered's reason plus
one: ui-mask.frag serves masked groups and carries the colour matrix, so a renderer that picked
the colour pipeline by mistake would draw a correctly filtered, entirely unmasked group — and
Filtered would still count it.
drop-shadow
A drop shadow is a Gaussian over the group's alpha channel, displaced, tinted, and composited
under the group rather than over it. UiLayer.Shadow carries it, as an offset, a standard
deviation and a straight colour.
It is not box-shadow and shares no code with it. DrawListBuilder.EmitShadow draws a rounded
rectangle the shape of the border box and lets ui-box.frag resolve the falloff analytically,
because a box's silhouette is known in closed form. A drop shadow's is not: it is whatever the
subtree rasterised to — text, an icon's path, a partly transparent image, a masked child — so it can
only be had by blurring the coverage that was actually drawn. On a filled panel the two are the same
picture, which is exactly why a fixture made of filled panels cannot tell them apart.
It needed no new shader, and the reason is worth knowing. UiColorMatrix.Apply evaluates
c' = M·c + o·a on premultiplied colour, so a matrix with zero coefficients and the shadow's
colour in its offsets leaves c' = colour·a — the shadow's colour at exactly the coverage the
surface had, which is the tinted silhouette. UiDropShadow.Tint is that matrix, and
ui-colour.frag draws it without being told it is drawing a shadow. What the three-row matrix cannot
do is scale alpha, so a translucent shadow colour rides the shadow quad's own vertex alpha instead,
where UiLayer.Alpha already rides; the two multiply.
The seam is fixed by arithmetic, and it is fixed more tightly than the mask's. A colour matrix
may be applied wherever it is cheap, because it commutes with the Gaussian and the sampler. A mask
may not, because it varies with position. A drop shadow may not either, and for a third reason:
blur(σ) drop-shadow(τ) blurs the alpha channel twice and drop-shadow(τ) blur(σ) blurs a picture
that already has a shadow under it. So both executors cast the shadow from the group's finished
surface, after its own blur — UiRenderer.ShadowSurface and the seam in
SoftwareUiRasterizer.Run. UtilityComposition.Filter writes drop-shadow() last for the same
reason, which is also v4's order.
What it costs is a second viewport-sized surface, two more render passes, and a second quad. The passes reuse the blur's shared scratch: the group's surface sweeps across into the scratch and down into the shadow's, so unlike a blur — which has to land back where it started — nothing is copied. A shadow with no blur at all is one pass rather than two, and that pass is a single-tap convolution.
Bounds are outset by the wider of the group's blur and the shadow's, not their sum: the shadow reads the surface and not the shadow, so the two reaches do not compose. The offset is not in the bounds at all — the group's surface has not moved, only the shadow's quad has, and it carries the displacement in its vertices with texture coordinates taken from where it would have been.
⚠ A mask on the same element cuts the shadow in the shadow's own frame rather than where it
lands, which is a stated divergence. CSS applies filter first and mask to the result, so a
browser cuts the shadow at its real position; both executors here recover a mask's point as
uv × size, and the shadow quad's UV is deliberately the un-displaced one. Exact when the offset is
zero. The alternative — leaving the shadow unmasked — is worse rather than differently wrong: a faded
element casting a hard-edged shadow is ink escaping a mask. Closing it properly needs the
displacement in a push constant so the fragment can evaluate the fold at uv × size + offset.
⚠ One shadow per element. CSS lets filter hold any number of them and each is a surface and two
passes; a second is refused, and refusing takes the whole declaration with it. That is the rule
DrawListBuilder.Filter keeps for every function it cannot execute, and it is what stops a
half-applied filter looking like a working one.
backdrop-filter
backdrop-* is a different feature wearing the same names, and what it reads is the destination the
group is about to composite into. At the moment a group's surface is rendered that destination has
not been drawn: UiRenderer.Compose records every group's pass before the host's frame pass begins
— it has to, because passes do not nest — so nothing painted below the group exists yet. By the time
the composite is submitted the destination is the colour attachment being written, which is not
sampleable without an input attachment or a copy out of the pass, and this renderer's command list has
neither.
None of which turned out to matter, because a read-back is not what this needs. The draw-list prefix behind a group is replayable, and replaying it is what turned this from a capability the backend lacks into a scheduling problem the compositor solves. Four things moved, and the last two are why it was a branch of its own rather than a rider on the drop shadow.
1. Every group here is already a backdrop root, which is the one piece of luck. Filter Effects 2
says an element forms a backdrop root if it has a filter, an opacity below one, a mask or a
clip-path — and a UiLayer exists in this engine for precisely those reasons and no other. So the
backdrop of a group nested in another group is the parent's own surface content so far, and never
an accumulation up the ancestor chain. There is no recursion, and — the part that matters at execution
time — a nested group's backdrop starts from transparent black, because that is what its parent's
surface starts from. Only a top-level group's backdrop has ever seen the host's frame.
2. The capture is a re-render of the prefix, not a read-back. For a group g with parent p, the
backdrop is what Submit(self: p) would draw from p.First up to g.First. Submit already walked
exactly that range and already skipped nested groups in favour of their composites; it needed one extra
stop argument. UiRenderer.Capture is that call, wrapped in a pass of its own, confined to the
group's border box outset by the backdrop's kernel — then the same blur machinery a drop shadow uses,
and a second quad drawn under the group's, which is the arrangement UiLayer.ShadowImage demonstrates.
3. Compose's walk is post-order, where it used to be reverse pre-order. The old loop put a
group's children before it — which is the one ordering it needed, since an outer group's pass samples
its children's surfaces — and a group's later siblings before its earlier ones. A capture needs the
opposite of that second half: everything painted behind g must be finished before g's capture pass
runs. Post-order satisfies both — each child subtree in document order, then the parent —
so UiRenderer.Forest replaced the loop rather than extending it.
⚠ Nothing else in that loop depended on the old order, and it was checked by running the fixture
without a backdrop in it rather than by reading the code. Each group's turn is self-contained: one
barrier in, a pass, an optional blur and shadow that borrow the shared scratch and hand it back, one
barrier out. The only state two turns share is that scratch, whose borrow begins and ends inside one of
them, and each surface's own ResourceState. Post-order is therefore a strict tightening of the
constraint the reverse walk met, not a different one.
4. The host's content is not the compositor's, and that is the public API change. Record draws
into a pass the host has already begun; the UI's own draw list is all Compose can re-render. A
capture built from it alone is the interface behind the element and nothing else — so a glass panel
over a 3D scene would blur nothing, which is the single commonest reason to reach for the feature. It
is worse than incomplete: the captured backdrop is then not opaque, and compositing a blurred
translucent copy over the sharp original is a double image along every edge, where CSS replaces
the backdrop within the element's bounds. So the host hands over what it has already painted, as
Compose(commands, geometry, surface, scale, UiBackdropSource beneath).
⚠ A colour and a texture, where the design said a texture. All three call sites in this
repository begin the interface's pass with a LoadAction.Clear and have painted nothing else:
Vixen.Editor.Host, the app template and UiCompositingTests. What they have "already painted" is a
colour, and a colour is also the only thing that can reach SoftwareUiRasterizer — whose capture is a
clone of a buffer that already holds the background, so the two executors agree only if the device's
capture starts from the same ground. UiBackdropSource.Image is the other half, for a host that has
genuinely rendered a scene into a texture of the interface's size; it is drawn full-screen into the
capture before the prefix is replayed over it. A host that passes nothing gets the degraded reading
and UiRenderer.Backdropped is what says the capture happened at all.
⚠ UiRenderer.Backdropped matters more than any other counter on the class, because a backdrop
filter over a flat field is the identity. Blurring a uniform colour returns it; greying an
already-grey one returns it. So a fixture whose panels sit on plain background cannot tell a working
capture from no capture — and neither can UiCompositingTests, because the software path would be
reproducing the same nothing. That is a second vacuity on top of the one every filter has, and it is
worse: the colour matrix's picture is at least a function of the group's own contents.
⚠ The two blurs are convolved through different quads and one of them is not a quad at all. A
group's own blur sweeps through its composite quad, which UiGeometryBuilder.Layer has already outset
by the kernel — so the second axis never reads outside itself for a pixel that matters. A backdrop's
quad is the border box with no outset, because that is what the result is clipped to, so sweeping
through it makes the second axis read the cleared scratch just outside the box and darkens the panel's
whole rim. Measured before it was fixed: twenty pixels along the top edge of the compositing fixture's
glass panel, up to ten levels dark. The backdrop's sweeps therefore run over the whole confined region,
through four vertices of the renderer's own — UiRenderer.Fullscreen, the only geometry in that file
that is not the frame's.
⚠ opacity() is a backdrop function and drop-shadow() is not, which is the mirror of filter.
backdrop-opacity-* is one of Tailwind's ten roots and UiColorMatrix has three rows and cannot scale
alpha, so it lands on UiBackdrop.Alpha and rides the backdrop quad's own vertex alpha — exactly where
a drop shadow's colour alpha rides. drop-shadow() inside a backdrop-filter is refused, and takes
the whole declaration with it: a shadow of the backdrop is a silhouette composited under a picture that
is already behind everything.
⚠ A backdrop costs a second pass over everything behind the element, and the counters say so out
loud. The capture replays the prefix, so a composite in that prefix carrying a colour matrix is
submitted twice and UiRenderer.Filtered counts it twice; the same goes for Masked. Neither is
bounded by the layer count any more. That is the price of the feature rather than a bookkeeping
artefact, and it is what makes the price visible.
⚠ The filtered backdrop is clipped to the border box, and to its corner radius, on both
executors. UiLayer.Bounds is the group's ink — a child overflowing the element makes it bigger —
so a rectangle taken from there would put blurred scene outside the panel that asked for it;
UiLayer.BackdropBounds carries the border box instead, which closes that half.
UiLayer.BackdropRadius and UiLayer.BackdropBox carry the rounding;
SoftwareUiRasterizer.Composite multiplies its coverage into the mask's, and UiRenderer pushes the
box to ui-colour.frag or ui-mask.frag, which do the same multiply.
UiCompositingTests.ARoundedBackdropIsClippedToItsCurveOnBothExecutors compares the two frames on a
device; it asserted the opposite until 2026-09-09, having been written to be inverted.
⚠ The channel from the document to the layer was a literal 0f, and four audits of the divergence
priced only the half below it. Measured 2026-09-08: DrawListBuilder passed a hard zero for the
LayerPush's own Radius — the slot that carries a box's rounding for every other command kind — so
the radius was discarded one hop after it was resolved and four hops before any fragment. Everything
in the paragraphs below is about a fragment; none of it was reachable, because there was nothing for
a fragment to be told. ⚠ It is uniform-or-zero, which is DrawCommand.Radius's own rule: a
LayerPush's side-buffer range is spent on its mask list, so an element whose four corners differ
keeps its square backdrop.
⚠ The third cost the device half was priced at — "the push constants are at the ceiling" — was
false, and it is the one the expensive answer was derived from. It stood in five places until
2026-09-09 and it conflates two different blocks. Measured off the committed .reflect.json before
the box landed: UiBlur's push block was 32 bytes, UiColour's 64, UiMask's 80 — the
widest — and UiImage declares none at all, against the 128 Vulkan guarantees everywhere. The box
went into those 48 free bytes as a centre and a half plus a uniform-or-zero radius, two float4,
taking UiColour to 96 and UiMask to 112. The pipeline layout was already one
Vertex | Fragment range over the whole 128, so no host change attended it. What is at the ceiling
is a mask list: an entry is sixty-four bytes, so sixteen reserved plus a forty-eight-byte matrix
plus one entry is exactly 128 and a second will not fit — which is what MaskEntry's remark in
Ui.rvn says, correctly, and why those entries went to a storage buffer. Four audits read a true
sentence about a sixty-four-byte record as a statement about a spare float4.
So the channel was two push constants, and not the fourth MaskEntry shape this paragraph used to
recommend — which would have routed every rounded backdrop through the mask pipeline whether or not
the element has a mask-image, to buy a record that has room where there was already room.
ShaderReflectionTests.TheBackdropBoxIsWhereTheHostPushesIt pins where the bytes went.
⚠ UiImage is the module that did not get the box, and that is what the closure cost. It draws
every viewport, thumbnail and video frame in the interface, and a push block there would make each of
them write a range once a frame — the cost ui-colour.frag exists to avoid. So a rounded backdrop
composites through colourPipeline with an identity matrix, on the precedence maskPipeline already
sets, and a host that handed over no colour stage draws its backdrop square rather than not at all.
That is the only thing UiRenderer.SquareBackdrops counts now.
⚠ An element that paints nothing of its own used to get no backdrop, and the reason recorded for it
was a claim about these two executors that was not true of either. The claim was that both walk the
layer list by matching a draw index, so a group with Count == 0 would match its own start and never
advance. SoftwareUiRasterizer advances its next cursor as it enters a group, so a zero-width
range leaves the draw index where it was and the next turn of the loop executes the composite quad
standing at it; UiRenderer.Forest takes a group's descendants as the entries whose First is
strictly inside its range, which an empty range has none of, and hands the index straight on.
Confine already falls back to the whole attachment for a degenerate rectangle, and Submit over an
empty range is a pass that clears and draws nothing.
What was really dropping the group is the guard in UiGeometryBuilder.Layer that refuses a layer
whose ink is empty — and that one is real: a zero-sized surface is a validation error rather than an
empty picture. A backdrop is the one thing a group carries that is not a function of its own ink, and
the rectangle it wants is BackdropBounds, the border box it was going to be clipped to anyway. So an
inkless group keeps its layer when a backdrop survived, bounded by that box, and every other inkless
group is still discarded — a blur, a colour matrix, a mask and a drop shadow are each a function of
ink there is none of. BackdropFilterTests pins both halves.
⚠ The trap this feature sets is that SoftwareUiRasterizer can do it in three lines, because its
recursion already holds the parent's buffer while it runs the group's. A backdrop filter written there
alone would look implemented and would surface as a compositing divergence in UiCompositingTests
rather than as a missing feature. Both halves landed together, and the fixture there carries a fourth
group whose backdrop straddles the busiest part of the frame — which is also the only thing in the
repository that can see the post-order change, since the other three groups are correct in either
order.
What the surfaces cost
Measured rather than argued, because nothing had measured it. A UiRenderer at 1920 × 1080 on an
Apple M-series GPU through MoltenVK, timed with GpuProfiler around Compose alone, best of ten
frames:
| Frame | Extra passes | Compose |
|---|---|---|
| 12 groups, none blurred | 12 | 1.10 ms |
| 12 groups, one blurred at σ = 4 | 14 | 1.27 ms |
| 12 groups, one blurred at σ = 16 | 14 | 1.64 ms |
| 12 groups, all blurred at σ = 4 | 36 | 2.30 ms |
| 12 groups, all blurred at σ = 16 | 36 | 5.88 ms |
Twelve groups is the editor's opening frame. ⚠ The first row is the number worth looking at: a
seventh of a 60 Hz frame, spent almost entirely clearing and storing twelve viewport-sized targets
that each hold one panel — and it was there before blur was. Memory is the same story: twelve
Rgba8UNorm targets at this size are 95 MiB, kept between frames.
What the blur adds on top of that is a shared scratch target — one, 7.9 MiB — and roughly 0.17 ms per blurred group at σ = 4, 0.40 ms at σ = 16. The one-per-frame scratch is the part that was expected to be worse: the obvious reading of "a separable blur needs a second surface" is a second surface per blurred group, which at twelve would have been 190 MiB rather than 103 MiB.
The honest summary is that blur is not the expensive part of this design and the surfaces are.
Confining the passes to the group's bounds
Half of that first row has since been taken back, and not by shrinking the surfaces. Each
group's pass is given a render area of UiLayer.Bounds — UiRenderer.Confine — so the clear and
the store touch the three hundred by four hundred pixels the panel occupies instead of the whole
attachment. The allocation is untouched and still viewport-sized, so every correctness argument in
UiLayer's remarks survives word for word: there is still no origin to translate, and both executors
still composite through the same rectangle.
⚠ A render area and not a scissor. A scissor confines draws; the clear happens when the pass begins, before any draw, and obeys the render area alone. The same distinction cost the virtual shadow atlas every page but the last drawn.
Measured the same way as the table above — GpuProfiler around Compose alone at 1920 × 1080,
MoltenVK on an Apple M-series GPU — but reported as the 10th percentile of forty-eight frames
rather than the best of ten, because a scope absorbs a queue stall and the upper half of the
distribution is machine state rather than work. Two runs of each build, and the first configuration
in a process is discarded: it pays the clock ramp and reads fifty to a hundred per cent high.
| Frame | Before | After | |
|---|---|---|---|
| 6 groups, none blurred | 0.45 ms | 0.25 ms | −45 % |
| 12 groups, none blurred | 1.00 ms | 0.53 ms | −47 % |
| 24 groups, none blurred | 2.07 ms | 1.09 ms | −47 % |
| 12 groups, all blurred at σ = 4 | 5.45 ms | 4.90 ms | −10 % |
⚠ "Almost all of it is clear-and-store bandwidth" was the premise, and it is an overstatement — the lever is worth about half, not about all. The same twelve passes over a 480 × 270 attachment cost 0.42 ms before the change and 0.42 ms after it: at that size there is no bandwidth left to save and what remains is fixed per-pass cost — the encoder, the two barriers, the bindings — at roughly 34 µs a pass. Twelve of those is 0.41 ms, which is the floor the 0.53 ms above is sitting on. Content makes almost no difference either way: twenty-four rows per panel instead of one costs 0.10 ms more, before and after alike, which is what says the cost is per-target and not per-draw.
A blurred group gains far less because its two sweeps were never viewport-wide to begin with — the composite quad already bounded them — so all the render area removes there is the group's own content pass, about 0.04 ms out of 0.45.
The 95 MiB is untouched and is the harder half. It needs bounds-sized surfaces, which is the
change UiLayer argues against, and it cannot be pooled away: every group's surface is still live
when the frame's own pass samples it. What has changed is the case for making it — the time
argument is now mostly spent, so what is left to buy is memory alone.
transform, rotate and scale
A transform is the fifth thing that opens a group, and the only one where the group is not an isolation but a change of coordinates:
.badge { rotate: -12deg; scale: 110%; }.card { transform: translate(20px) rotate(-6deg) skewX(4deg); }CSS Transforms 2 splits the same idea two ways: three independent properties — translate, rotate,
scale — and one transform taking a list of functions. Both are read, and both end up in the same
UiTransform. A list may hold every function Transforms 1 and 2 define, the 3D family and
perspective() included — the whole set is under "transform itself is read" below. The independent
properties reach the same depth: rotate: x 45deg is rotateX(45deg), rotate: 1 1 0 45deg is
rotate3d(1, 1, 0, 45deg) and translate: 0 0 40px is translateZ(40px), each composed in four
dimensions and projected by the parent's perspective exactly as the function would be (#1473).
⚠ Three orderings decide the picture, and none of them is the one a reader guesses.
- The last function in a list is applied to a point first.
transform: A Bis the matrix productA · B, sorotate(90deg) translate(40px)moves the element along its own turned axis whiletranslate(40px) rotate(90deg)moves it across the screen. Invisible on every one-function declaration, which is most of them. transformis applied before the three independent properties, per Transforms 2 § 3, which builds the matrix as translate, then rotate, then scale, thentransform— again as multiplications, so the list is the innermost factor.- A list shares one
transform-origin. It is composed first and re-centred once; re-centring each function separately is a different picture as soon as a translation is among them.
⚠ A list this engine cannot read is dropped whole, and only the list. An argument that does not
resolve — a min(), max() or clamp(), which fold to nothing — or a function CSS itself calls
invalid, such as rotate3d(0, 0, 0, 45deg)'s zero-length axis, makes the list unreadable, and
reading the functions that happen to work while skipping the rest turns a card flip into a card that
never moves, which is a different picture rather than a degraded one. The rotate or scale beside
it still applies, because CSS drops an invalid declaration and leaves its neighbours alone.
⚠ A percentage inside translate() is of the element's own border box, per Transforms 1 § 8 —
the opposite of every percentage in the box model, and the same rule the translate property follows.
A DrawCommand is an axis-aligned rectangle, and none of them was rotated. That is worth stating
first because it was the standing reason this could not be done. The subtree rasterises into the
group's surface exactly as it always did — upright, every command a rectangle, every clip a rectangle
— and the matrix is spent on the four vertex positions of the composite quad. CSS agrees that this
is the seam: Transforms 1 § 3 makes any transform other than none a stacking context, in the
sentence shape Filter Effects uses for filter.
⚠ An affine costs no shader arithmetic, and a perspective costs one float per vertex. Both
executors interpolate a quad's texture coordinate across its two triangles, and an affine map is
exactly the class for which a linear interpolation is exact rather than approximate — so the two
triangles agree along the shared diagonal and no seam appears. Moving four positions and leaving four
coordinates alone is the whole of it. A projective map (UiTransform is a 3×3 homography since #547)
does not have that property: the four corners land right and a coordinate interpolated linearly
between them is wrong everywhere in the middle, with a seam down the diagonal — 30 surface pixels at
a 400×300 group's own centre under a mild perspective, measured before #548. So UiVertex carries the
w each corner was projected by, UiGeometryBuilder.Quad writes it from UiTransform.Project, the
vertex stage hands the rasteriser float4(xy · w, 0, w) so the hardware divides per fragment for
free, and SoftwareUiRasterizer weights its barycentrics by 1/w and cuts a triangle against the
eye plane before it takes a bound. Every vertex that is not a projected composite quad carries a one,
where the multiply and the divide are both the identity, which is what keeps every affine picture
bit-identical. ⚠ Nothing parses a perspective yet — perspective, perspective-origin and the 3D
functions are #550 — so today the only way to reach this path is a UiTransform built by hand.
Layout never sees it, and neither does the subtree's own geometry. UiDocument.Accumulate
composes the matrix per element and deliberately does not pass it down — children accumulate from the
untransformed position and are carried along by the group. A scaled element keeps the space layout
gave it, so scale: 150% overflows its row rather than widening it, which is what Transforms 1 § 3
requires. It is also what keeps glyphs out of it: text is shaped once at its layout size and the
surface is scaled, never re-shaped.
⚠ The pointer is transformed too, and in one line. UiDocument.HitTest maps the point through
the inverse at the top of the walk, before anything looks at it, so Contains and Cut go on
comparing the absolute rectangles they always compared. Nested transforms compose because the
recursion does. A transformed element whose hit test was untransformed is a control you can see and
cannot click, which is worse than an unimplemented one — Vixen.Ui.Tests.TransformTests is what holds
the two together.
A degenerate transform paints nothing. scale: 0 has no inverse, so the subtree is skipped
outright — the same treatment opacity: 0 gets, and the hit test refuses it through the same
singular matrix rather than leaving an invisible control taking clicks.
What a transform costs, and the two places it is not free
⚠ A viewport-sized surface and a render pass per transformed element. This is the real price and
it is the same one opacity pays. It is fine for a panel and expensive for a list of rotated
chevrons; there is no per-command fast path, because there is no rotated DrawCommand to fast-path
to. An identity — rotate: 0deg, scale: 100% — is collapsed back to nothing before a group is
opened, which matters because those are written constantly.
⚠ A blur or a drop shadow on a transformed group takes a slower sweep. UiRenderer.BlurSurface
and ShadowSurface convolve a group's surface by drawing through its composite quad, which is
correct only while the quad and the surface share a space — under a transform they do not. Both fall
back to the full-region sweep already written for backdrops: correct at any transform, and it costs
the whole target rather than the group's rectangle. SoftwareUiRasterizer convolves the whole buffer
either way, so this is a divergence only UiCompositingTests can see, and it is in that fixture.
⚠ backdrop-filter is refused on a transformed group rather than approximated. It is the one
surface holding something the group did not draw, so a rotated backdrop quad would have to sample a
rotated window of a captured picture — four texture coordinates that are no longer an axis-aligned
rectangle, and a capture region that is no longer the border box. Sampling the untransformed patch
instead would show the scene from where the element was. UiGeometryBuilder.Layer drops the
backdrop and the group composites normally.
⚠ A transform cannot bring on-screen what was never rasterised. The surface is the viewport's size and holds the group at the coordinates it always had, so an element mostly outside the viewport and scaled down to fit shows only the part that was already visible. An ancestor's clip does not have this problem — the clip is pulled back through the transform before it narrows the group's bounds, so a rotated panel near a clipped edge keeps the corner the rotation swings into view.
transform itself is read, and so is the 3D family. TransformReader.Functions parses the list
— matrix(), matrix3d(), translate()/translateX/translateY/translateZ/translate3d,
scale()/scaleX/scaleY/scaleZ/scale3d, rotate()/rotateX/rotateY/rotateZ/rotate3d,
skew()/skewX/skewY and perspective() — composing the whole of it in four dimensions and
reducing once at the end, which is what lets a perspective() be observed through a rotateX()
beside it. A calc() argument folds through StyleValueParser; min(), max() and clamp() do
not, and an argument that cannot be read drops the whole declaration, deliberately, because a
card flip drawn as the two flat halves of a rotateX rotateY pair is a wrong picture rather than a
missing one. The perspective and perspective-origin properties are read on the parent, per
Transforms 2 § 6, and backface-visibility: hidden removes a turned-away element from both the paint
walk and the hit test — decided from the 4×4 before the reduction, because the reduced homography
cannot tell a rotateY(180deg) from a scale3d(-1, 1, 1).
transform transitions and animates. transition: transform 300ms and a @keyframes block
over transform both run, interpolated the way CSS Transforms 2 § 12 says: two lists are padded
with identity functions to the same length (none counting as empty), a pair of functions sharing a
primitive interpolates its arguments — so rotate(0deg) → rotate(360deg) is a whole turn,
not the standing still an interpolation of two identical matrices would give — and from the first
pair that does not, the rest of each list is composed into one matrix, decomposed into translation,
scale, shear, perspective and a quaternion, and interpolated part by part. Arguments are resolved
against the element's box and font first, so translateX(50%) → translateX(2em) interpolates
too. backface-visibility is re-decided at every step, so a card turning under backface-hidden
disappears at 90° rather than at either end. A transition reversed part way comes back over the
fraction of the duration it had travelled.
The animator times it and writes the value in flight as CSS Values 5's
transform-mix(<progress>, <from>, <to>) — which is also accepted from a stylesheet — and
TransformReader resolves the mix, because only it holds the box, the font and the matrices. So a
value read off the computed style mid-transition is a transform-mix(…), and nothing that consumes a
transform had to learn that time passes.
Not implemented: transform-style: preserve-3d, which would need the descendants to share this
element's 3D space rather than be composited into its plane.
Blending a group with what is under it
mix-blend-mode is the sixth reason to open a group and the only one whose answer is a function of
two pictures. Everything above it — the fade, the Gaussian, the colour matrix, the mask, the
transform — is computable from the group's own surface, which is why each of them can be applied at
the seam where that surface is finished and no executor has to know where the composite quad will
land. A blend cannot: its second operand is the backdrop.
⚠ It is nevertheless not a second blend state, and that is what makes it cheap where it is implemented at all. CSS Compositing 1 § 5.1 defines the whole feature as a change of source colour followed by an ordinary source-over:
Cs' = (1 - αb)·Cs + αb·B(Cb, Cs)So UiBlend.Apply takes the group's premultiplied fragment and the backdrop's, and returns the
premultiplied colour to composite exactly as an unblended group's would be. SoftwareUiRasterizer
owns its destination buffer, so the entire cost there is one read per pixel of the composite quad.
⚠ The blend is done on the values the surface holds, which in this engine are linear, and a browser
blends in sRGB. That is a stated divergence rather than an oversight — multiply of two mid-greys
is perceptibly darker here than in a browser. Closing it means an encode and a decode per pixel in
both composites and a decision about what the interface's compositing space is, which is a
colour-management question rather than a blending one.
⚠ § 5.1's functions are defined on the unit interval and this engine's interface colours are not,
so Apply normalises both operands by the frame's white and re-lights the answer. The divisor is
UiGeometry.WhiteLevel — one on a display-referred pass, BT.2408's 203 cd/m² on a scene-referred one
— and until 2026-09-10 it was not there: both operands were clamped to [0, 1] outright, so a
premultiplied result could never exceed its own alpha and a mix-blend-mode: multiply panel in an
HDR HUD came out at one candela whatever it was authored at, two orders of magnitude below the
same panel with no declaration at all. That is the same photometric trap UiGeometryBuilder.WhiteLevel
exists for, one door along, and nothing could see it: every blend fixture built at a white level of
one, where the divisor is the identity, and both rasterisers then stored eight bits.
The one divisor is enough because § 5 falls into three groups. The four non-separable modes are
homogeneous of degree one — § 5.3's SetLum, SetSat and ClipColor are written in the operands'
own luma — as are darken, lighten and difference, so normalising and re-lighting reproduces the
unbounded answer exactly. multiply, screen and exclusion are quadratic in the units and have
no meaning without a stated one: Cb·Cs of two 203s is 41 209 rather than 203. And color-dodge,
color-burn, soft-light, hard-light and overlay genuinely have no definition above one, so the
clamp is right there and what was missing was only what it clamps against. Every_mode_scales_with_the_frames_white
holds all sixteen to that, and A_white_multiply_panel_leaves_the_wall_it_covers_alone_rather_than_at_one_candela
is the picture: white is multiply's identity, so a white panel over a wall at 100 cd/m² must leave
it at 100, and it read 1 before. The device path inherits this convention rather than choosing its
own — it is the divisor the fragment stage transcribes.
⚠ A composited group was lit twice on the device above a white of one, and the colour matrix was
clamped as if the white were one on both executors (#1418). The three quads that draw a group's
finished surface — the composite, the drop shadow and the filtered backdrop — carry a white tint whose
only job is the group's alpha, and they used to send it through UiGeometryBuilder.Show like any
colour, so it arrived as the white level in each channel and every stage multiplied an already-lit
surface by 203 again. They are emitted unlit now. The colour matrix — UiComposite.Filter on the
device, UiColorMatrix.Apply(Color4, float) in software — normalises by the white and re-lights,
clamp(M·c + o·a·w, 0, a·w), because clamped to the bare alpha it capped every filtered pixel at one
candela and left invert and a drop shadow's colour, which are offsets, a factor of the white too dark.
⚠ The drop shadow was the one composite the double lighting got right on the device, by accident, which
is why fixing the tint alone would have been a regression. UiCompositeWhiteLevelDeviceTests holds an
opacity, blended, filtered, shadowed, backdrop-filtered and masked group at 203 to the same frame at
one times 203, on the device and in software. ⚠ The golden suite's GLSL twins (ui-colour.frag,
ui-mask.frag) were said here not to read the white lane; they have since #1458, and
ui-blend.frag — the twin UiBlend had none of until #783's agreement case — reads it in
operation.y as the Raven does. UiRavenAgreementTests.TheGlslCopyAndTheRavenBlendTheSameFrame
draws all fifteen modes through both, one rotated over a striped ground so the gl_FragCoord read is
exercised, and compares the two exactly.
⚠ The sixteen functions have an oracle now, and it is what the two transcriptions have to agree
with. Core/Vixen.Ui.Tests/UiBlendTests.cs holds UiBlend.Blend to § 5.1's and § 5.3's own
arithmetic on two operand triples chosen so that no two modes agree on either — and to the four
cases a table of general values never reaches: color-dodge's and color-burn's three-case order,
soft-light's knee (the cubic below a quarter, not the root), ClipColor pulling towards the luma
rather than towards the cube, and SetSat on a grey. ⚠ Nine of the sixteen were covered by
nothing before it: with soft-light transcribed as the square root everywhere, all twenty-eight
MixBlendMode* tests stay green. Whoever writes ui-blend.frag and Ui.rvn's stage should compare
against those numbers and not against the C# the transcription was read from, which is the parity
trap this repository already names.
✅ UiRenderer implements it (#783), and the design is the one the paragraphs below argued for.
UiBlend in Ui.rvn samples the group's surface in descriptor set 0 like every composite, and the
backdrop in set 1; UiRenderer.Compose fills that backdrop by replaying the parent's draws up to
the group's composite into a capture surface — backdrop-filter's replay with a later stop, so it
includes the group's own filtered-backdrop and drop-shadow quads, which are painted before the
composite — and the fragment applies UiBlend.Apply's arithmetic, white level included, before an
ordinary source-over. The blend pipeline's layout repeats set 0 and the [0, 128] push range
verbatim, which is what keeps the atlas bound across a switch to it and back. UiRenderer.Blended
counts the draws that blended; UiBlendDeviceTests holds all fifteen non-normal modes to the closed
form and to SoftwareUiRasterizer on a device, plus overlapping siblings (the second one's backdrop
replays the first one's blended composite) and a blend nested in a translucent group.
⚠ Two arrangements still composite source-over on the device, and UiRenderer.Unblended counts
the first — it needs to, because a blend over a flat backdrop is often the identity (multiply
against white, screen against black), so neither a screenshot nor a comparison of the two
executors can tell. The second is invisible to it, for the reason given against it, and is counted
by UiRenderFeature.Sceneless instead (#1378). ⚠ A group's own filter and mask-image are
not among them any more (#783): UiBlend applies the colour matrix and then the mask list before it
mixes — the order CSS gives and SoftwareUiRasterizer takes — the matrix behind a flag rather than
an identity matrix because UiComposite.Filter clamps — to the alpha times the frame's white since
#1418, which is the identity on every colour an interface can author, and not on one authored above
the white, and the mask through UiMaskList, the per-entry coverage UiMask now shares
with it. UiBlendDeviceTests holds both to § 5.1 on the filtered or masked paint.
- a blended group's
drop-shadow()quad, which the software path blends separately from the group (its own word-for-word approximation) and the device composites plainly — which of the two is right is still to be settled rather than reproduced. It is counted as a draw of its own, so a blended shadowed group readsBlendedfor its composite andUnblendedfor its shadow; - a top-level panel of a HUD drawn inside a world renderer:
UiRenderFeature.Composehas no backdrop to hand over because the scene is not drawn when these passes are recorded, so the panel blends with the interface beneath it and composites source-over onto the world — the same limitbackdrop-filterhas there, for the same reason. ⚠ This one is counted inBlended, notUnblended: the panel does go throughUiBlend, against the interface's own prefix over transparent black, which is right wherever the interface painted under it and is source-over wherever only the scene did. The renderer cannot tell those apart — a defaultUiBackdropSourceis also what a host that painted nothing would pass — and declining the blend would lose it in the case that works, a badge over a plain HUD panel. ⚠ A frame can now opt out of this one — see A HUD over the world below.
⚠ A group under rotate, scale or perspective was a fourth until #1379, declined for a reason
that was false. Its composite quad carries the untransformed surface coordinate — right for the
group's own texels, wrong for what lies under the pixel — and Raven was said to have no
fragment-position input to recover the target texel from. It has had one since 289b50247 (Foliage,
Grass and WaterMesh read it), so UiBlend reads the capture at SV_Position for such a group,
the host pushes the capture's reciprocal size in texels for it, and its capture is taken over the
whole surface because UiLayer.Bounds stays untransformed. UiBlendDeviceTests holds a scaled group
to a closed form at a pixel its surface coordinate would read wrongly — at density one and two — and
a rotated one to SoftwareUiRasterizer. ⚠ The untransformed path still reads the texture coordinate:
the window-position read assumes the target's origin is the interface's, which every host arranges,
and the untransformed path has no reason to take on that assumption.
⚠ The price written here until 2026-09-06 — "a fourth binding on the shared ui atlas layout" —
was not a price, it was an impossibility. Raven's BindingPlan.Of numbers a descriptor set by
kind: textures in declaration order, then samplers, then storage buffers. A module that declares a
second Texture2D therefore takes binding 1 for the backdrop and pushes its own sampler to 2, which
is the shared layout's sampler slot and its shape buffer's — one declaration in one shader renumbers
the set every UI pipeline shares. Every shipped reflection shows it: UiMask is
texture/sampler/storage at 0/1/2 and the library's Underwater is
block/texture/texture/texture/sampler/sampler at 0–5. The backdrop belongs in a second descriptor
set, which is also the cheaper answer — a module that does not declare set 1 does not statically
use it, so leaving it unbound for every other pipeline is valid and set 0 is still never disturbed by
a pipeline change.
⚠ The first question anyone with a Vulkan background asks about this is whether it is a shader
change at all, and six audits of Rikarin/Vixen#783 never wrote the answer down. Vulkan's
VK_EXT_blend_operation_advanced provides the CSS blend set as fixed-function blend operations —
the twelve separable ones and all four non-separable ones, named after the same PDF modes — so on a
device that exposes it a blended group would need no second texture, no capture, no second descriptor
set and no fragment arithmetic: only a different BlendState on the composite draw it already makes.
It is nevertheless not the path here, for two reasons that are checkable rather than aesthetic.
Vixen.Graphics.BlendOperation (Core/Vixen.Graphics/GraphicsEnums.cs:331) has five members —
Add, Subtract, ReverseSubtract, Min, Max, which are core Vulkan's — so the abstraction
cannot spell one, and widening it means an optional-extension capability query threaded to every
backend. And the extension is optional: MoltenVK does not expose it, so the machine this engine's
golden images are recorded on could not run the path, and a fallback shader would have to exist
anyway. A second implementation of the same sixteen modes, reachable only on some adapters and never
on the one the references come from, is the shape this repository keeps finding bugs in. ⚠ Recorded
as considered and rejected with evidence rather than left for a seventh audit to rediscover: Min
and Max are a real temptation, because darken and lighten look like them and are not — those
two are defined on un-premultiplied colour and a composite surface holds premultiplied.
A HUD over the world: !UiCompose
A world renderer composes a mounted interface in WorldRenderer.Draw's prologue, before any pass of
the frame has run — which is the only place a host that declares nothing can do it, and the reason a
HUD's top-level mix-blend-mode and backdrop-filter read transparent black for the world (#1378).
A frame document that names a !UiCompose node moves that work to where the node sits and hands
the node's source over as UiBackdropSource.Image:
beforeUi: - !UiCompose name: Compose source: SceneColour # the target the interface pass draws into — it must be Sampled, # which in a game is the window (see below) - !RenderPass name: Interface colourTargets: [SceneColour] loaded: [SceneColour] children: - !SingleStage { name: Hud, view: Camera, stage: Ui }The node is one render-graph pass with no attachments — the graph runs such a pass's body outside
any render pass, which is where UiRenderer.Compose has to be recorded — that reads source as a
shader resource. That read is what places it after whatever last wrote the scene; it is marked as a
side effect, because it writes only surfaces the graph has never heard of and would otherwise be
culled. WorldRenderer.Draw then skips its own compose, for as long as the node and every node above
it are enabled. A multiplied HUD panel over the tier goldens' scene is then grey · scene at every
pixel and a glass panel with backdrop-filter: invert(1) is 1 − scene, where the prologue's
compose gave a flat grey slab and the scene untouched; InterfaceOverASceneDeviceTests holds both
to those closed forms on a device, and UiRenderFeature.Sceneless reads zero.
⚠ source has to be Sampled, and the node refuses it at build time otherwise — with a
CompositorBindingException naming source, rather than binding a target a driver may or may not
sample. What decides it is the usage the resource is declared or imported with, and an import wins
over a declaration of the same name (GraphicsCompositor's resource loop).
⚠ In a game SceneColour is the window, and the host declares what the window can do (#1419).
AppGraphics.Lend imports the acquired swapchain image under GraphicsOptions.Output, which
defaults to SceneColour as !StandardFrame's output does, with the usage the backend reports in
ISwapChain.Usage. Until #1419 it declared TextureUsage.ColourTarget and nothing else on every
backend, so the document above was refused by the node and a copy out of a target of the frame's own
was refused by !Copy, although the Vulkan image had been created with TRANSFER_DST all along.
What each backend reports:
| Backend | ISwapChain.Usage | !UiCompose over the window |
|---|---|---|
| Vulkan, windowed | colour target, copy destination, and sampled where the surface's supportedUsageFlags list it and vkGetPhysicalDeviceImageFormatProperties takes it for the chosen format (VulkanSwapChain.SamplesAs, VUID-VkSwapchainCreateInfoKHR-imageFormat-01778) — ⚠ untested: the windowed chain cannot be created on the machine the tests ran on, which has no VK_EXT_headless_surface | builds where the surface and the format both allow sampled |
Vulkan, offscreen (--vixen-offscreen, --vixen-capture) | colour target, copy source, copy destination, sampled | builds |
| OpenGL | colour target, sampled, copy source | builds |
| WebGPU, browser canvas | colour target, copy source, sampled — GPUCanvasContext.configure takes TEXTURE_BINDING for every canvas format (BrowserWebGpuBinding.SurfaceUsages, #1462) — ⚠ read off the specification: no browser runs in this repository's tests | builds |
| WebGPU, native (Dawn, wgpu-native) | colour target, copy source | refused — the surface cannot be asked: WGPUSurfaceCapabilities has no usages in Silk.NET 2.23.0 or in wgpu-native v0.19.4.1, and configuring an unlisted usage is an uncaptured validation error (NativeWebGpuBinding.SurfaceUsages) |
| Null | the Vulkan windowed chain's, and deliberately no more | builds |
Tools/Vixen.App.Tests/HostedInterfaceComposeTests is the stock host — VixenApp, a published frame
document and RunFrame — building a frame that names the node over the window on the Null device,
and drawing a multiplied HUD panel through it on a device: every panel pixel is grey · world in
linear light, and the same frame without the node lays the grey down flat.
It is the same frame's scene, not last frame's: the other way to give a HUD its world is the previous
colour target, which lags every blended panel by a frame and still needs a copy taken at this same
seam. A source of another size than the interface is resampled under it, and the node says so in
its Degraded — the interface's size being UiRenderer.Pixels, the ceiling of its surface times its
density, which is the size its group surfaces are allocated at. Name one node per frame: a second one
composes every group again and the later one wins. Nothing refuses it, because the picture is right,
but UiRenderFeature.Recomposed counts it (#1461) — and counts the two arrangements that compose
twice with only one node: a node a parent builds through BuildChild without listing it in Nested,
which UiComposeRenderer.PathTo cannot see, and any editor document that names the node, because the
editor's prologue always composes (WorldRenderer.ComposesInFrame reads only Host.Compositor).
Isolating which backdrop a blend reaches
isolation: isolate is the seventh reason to open a group and it changes no pixel of the group it
opens. Its only defined effect is on a descendant's mix-blend-mode, and it bounds it by being a
boundary: a nested group's draws are executed into its parent's surface, so a blended descendant of
an isolated ancestor mixes with that ancestor's accumulation and can never reach the page behind it.
⚠ Where the ancestor has painted nothing, that accumulation is transparent black — and § 5.1 weights
the blend by the backdrop's alpha, so a blend against nothing is normal. That is why an isolated
wrapper makes a mix-blend-multiply child come out unblended rather than come out black.
⚠ The peephole in DrawList.Collapse deliberately does not exclude an isolated group, where it
does exclude a blended one. Isolation is only observable through a descendant that opened a group of
its own, and such a descendant contributes at least three commands — so the collapse's own
drawn == 1 test is already the statement that there is nothing to isolate.
What a group does not change
Positions. UiDocument.Accumulate is where the draw list, hit testing and arrow navigation agree
about where an element is. A composited subtree keeps its document coordinates — that is a direct
consequence of the viewport-sized surface above — so a click still lands on the element it looks like
it landed on. There is a test for it rather than only an argument, because the argument stops being
true the moment somebody shrinks a surface to its group to save memory.
⚠ rotate and scale are the exception and prove the rule: they do move where a click lands, and
they are the only group cause that does. What keeps them honest is not that positions are untouched
but that one matrix is applied in both places — see the section above.
The clip stack. A group's compositing quad carries the scissor that was in force where the group
opened, so a group inside overflow: hidden composites inside it. A group's own overflow clip is
pushed and popped entirely within the bracket, so the stack is balanced across a group and the clip
at the pop is the same one as at the push.
Examples
A half-opaque panel with two overlapping children — the case that separates the two models:
.group { opacity: 0.5; }.lower { position: absolute; inset: 0 0 auto 0; height: 20px; background-color: #ff0000; }.upper { position: absolute; inset: 0 0 auto 0; height: 10px; background-color: #00ff00; }Where the two children overlap, the red is covered — it is opaque green over opaque red inside the surface — and only the result is faded. Fading each child separately would leave a visible red component of about 64 showing through the green.
Nested groups multiply, because each is its own surface and each is blended once:
.outer { opacity: 0.5; }.inner { opacity: 0.5; } /* composited at 0.25 of the frame */See also
- Gradients —
BoxStyleand the shader record a box carries. - Utility composition — where
opacity-50comes from. - The three features this pass was built for. <s>None of which is implemented yet.</s> One of them is.
filter: blur()<s>needs</s> needed a second pass over a finished layer, with a separable kernel and a bounds expansion by the blur radius — and that is what it now is, above. The scoping was right except in one detail, which is worth recording because it was the detail that decided the memory cost: a blurred group needs a second target, not a second surface per group. The two sweeps finish inside one group's turn inCompose, so a single scratch serves the whole frame.
bg-clip-text ⚠ is further away than "use the layer as a mask" makes it sound, and the layer is
the wrong thing to sample. A layer surface holds the group's rendered colour — glyphs already
multiplied by their run colour — and Tailwind draws gradient text as bg-clip-text plus
text-transparent, so the surface a mask path would sample is the one where the glyphs were drawn
invisible. What the feature needs is the glyph coverage rendered to a target of its own, which
is a second way of drawing text rather than a second way of reading a layer. There is also no
entry point to it: bg-clip is not a registered family, background-clip is not a property
anything parses, and bg-clip-text is this repository's standing example of a class that does
not resolve — ShadowedFamilyTests, StyleGenTests and docs/guide/ui/stylesheet-diagnostics.md
all assert it as a refusal. It is not on InertProperties.txt and could not be: nothing emits it.
scale-* and rotate-* remain refused: a scaled subtree needs re-shaping because glyph advances
are shaped at layout time, and a rotated clip cannot be the axis-aligned rectangle the clip stack
requires — a surface makes the raster transformable but changes neither of those two facts, and
a second surface existing changes neither of them either.