chore: release v0.24.0 - #139
Open
github-actions[bot] wants to merge 1 commit into
Open
Conversation
Contributor
Author
github-actions
Bot
force-pushed
the
tegami/version-packages
branch
8 times, most recently
from
September 7, 2026 09:16
5fed134 to
d69db47
Compare
github-actions
Bot
force-pushed
the
tegami/version-packages
branch
from
September 7, 2026 21:55
d69db47 to
28ac7fc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
All bumped packages.
@reactunity/create0.23.20.24.0@reactunity/material0.23.20.24.0@reactunity/renderer0.23.20.24.0@reactunity/scripts0.23.20.24.0Changelogs
2026-09-03-1f9ad3.mdShow Bumped Packages (1)
npm:@reactunity/rendererTailwind's Preflight, rewritten for ReactUnity
@reactunity/renderer/tailwind.cssis what a ReactUnity app imports in place of Tailwind'sPreflight. Preflight resets what a browser does to a document, and here most of it names elements
ReactUnity has not got while the rules that do land —
*clearing every margin and padding — leaveReactUnity's own control styling half undone. This is the equivalent written for what ReactUnity
renders: the padding and the grey fill on a
button, aninputand atogglego, sop-*andbg-*are the only things that decide,appearancestays so they still behave as controls, and theroot gets a line height and the font family ReactUnity actually ships. It also carries the two theme
values whose defaults are stacks of web font names,
--font-sansand--font-mono.An app still writes the one line Tailwind's docs give it.
@reactunity/renderer/vitealiases abare
@import "tailwindcss"to this file, so nothing about the setup changes:Only the bare specifier is aliased —
tailwindcss/theme.cssand the rest keep resolving to thepackage, which is where this entry gets them from — and
reactUnity({ tailwind: false })turns thealias off. Either half can also be imported alone, as
@reactunity/renderer/tailwind/preflight.css— intolayer(base), since an unlayered rule beatsevery utility — and
@reactunity/renderer/tailwind/theme.css. The kitchen-sink sample now loadsTailwind once for every page, Preflight included and the control look written back into its own
baselayer through@apply, andUsing Tailwind CSS covers what does and does
not carry over.
2026-09-03-4ac1e7.mdShow Bumped Packages (1)
npm:@reactunity/rendererCSS logical properties
padding-inline,margin-block,inset-inline-startand the rest of the logical box propertiesnow work, as longhands, as the two-value shorthands, and under their camelCase names in an inline
style:
The inline axis is handed to Yoga's own start and end edges rather than resolved here, which is what
makes
directionmean something: it is inherited, so setting it once turns the inline axis aroundfor a whole subtree, and an element needs no
directionof its own to follow one set above it. Theblock axis is always vertical —
writing-modedoes not exist here, soblock-startis the top.An inline value takes precedence over the physical property covering the same edge whichever order
the two are declared in. CSS would let the later declaration win; that is the one place the two
disagree, and the reason not to set both on one edge. Under UIToolkit the inline axis is always the
physical one, since UIElements has no start and end edge and ignores
direction.padding-start,margin-endandborder-start-width— the React Native spellings that have beenin the property table all along — reach the same edges, and now do something for the first time:
nothing had been pushing them to Yoga.
The practical effect is that Tailwind's
px-*,py-*,mx-*,my-*,inset-x-*,border-x-*,ps-*,ms-*,start-*andspace-x-*all work. v4 writes every two-axis and start/end utilityas a logical property, so those had been quietly doing nothing.
2026-09-03-7b3e41.mdShow Bumped Packages (1)
npm:@reactunity/rendererA Vite preset for ReactUnity apps
@reactunity/renderer/viteconfigures Vite the way a ReactUnity app needs it, so a project's wholeconfig is:
Output goes into the Unity project. The preset walks up from the Vite root looking for an
Assetsfolder beside aProjectSettings/ProjectVersion.txt, testing each ancestor and one levelbelow it, and builds into that project's
Assets/Resources/react. Both layouts are found: the appinside the project, and the app beside it.
unityProjectandassetPathsay it outright when theguess is wrong, and it stays out of the way entirely if the config already sets
build.outDir.The output folder is cleaned, and the
.metafiles survive. Unity writes a.metabeside everyasset, and the GUID in it is what scenes and prefabs reference — so emptying the folder the way Vite
would means every rebuild mints new GUIDs and breaks whatever pointed at the old ones. The clean
deletes the output and leaves the metas, and once the build has written its files it drops only the
metas whose asset did not come back, along with any directory the build left empty. A build that
fails leaves the metas alone, so the next successful one restores the files under their original
GUIDs.
preserveexempts paths the build does not own, and the clean refuses outright to run on adirectory that is not plausibly build output — a package, a repository, an
Assetsfolder, oranything containing the Vite root.
For the same reason, output filenames carry no content hash by default: a hash renames the file on
every build, which costs it its meta.
hashFileNames: truerestores Vite's naming.Emitted assets carry their extension twice, so
index.cssis written asindex.css.css. Unityfinds a resource by its path minus the last extension, which makes
assets/index.jsandassets/index.cssthe same resource -- and asking for the script could hand back the stylesheet, tobe run as JavaScript. Doubling puts the type back into the name the lookup uses. Content hashes did
this by accident before; without them it has to be deliberate.
Assets are never inlined. Vite turns a file under 4 kB into a
data:URI, and Unity loads anasset URL through its own loaders — so a small image that got inlined stopped resolving. The limit
is 0, which is where
react-unity-scriptshad it too.CSS is emitted for an old browser. ReactUnity's CSS subset does not know
oklch(),color-mix()or nesting, and a stylesheet that uses them silently loses those declarations.
build.cssTargetdefaults to
chrome87, which is what makes Lightning CSS lower them on the way out — enough thatTailwind v4's palette arrives as hex.
Vite's devtools and the HMR error overlay are off. Neither has anywhere to draw in Unity, and an
overlay rendered into a page nobody is looking at only hides the console message.
Everything the config sets itself wins — the preset only fills in what was left out — and
react: falsedrops@vitejs/plugin-reactfor a project that wants to configure React itself.2026-09-05-9f2d4b.mdShow Bumped Packages (1)
npm:@reactunity/renderer@starting-style, light-dark(), color-scheme, place-* shorthands, math functions, :nth-child(of S), attr(), linear() and boolean hover/pointer queries
@starting-styleis the style an element has on the frame it appears, and the state atransitionstarts from. It used to collapse into the enclosing rule, so@starting-style { opacity: 0 }made the element invisible for good. Each rule in a block is now applied through:enter, which is the machinery ReactUnity already had for the first frame:.a { @starting-style { opacity: 0 } }is.a:enter { opacity: 0 }, and the top-level form works the same. The one visible deviation is that:enteradds pseudo-class specificity where the web adds none.light-dark(<light>, <dark>)picks one of two values, wherever a color goes. The newcolor-schemeproperty (normal,light,dark,light dark; inherited) decides: a single scheme settles it for the subtree, anything else defers to theprefers-color-schememedia feature, and a change to that feature restyles the tree.color-schemeon the root element also seedsprefers-color-scheme, so@media (prefers-color-scheme: dark)rules follow the page's declared scheme; a value set throughMediaProvider.SetValueis never seeded over, and withdrawing the declaration restores what the feature held.DefaultMediaProvider.SeedValueis the public shape of that.place-content,place-itemsandplace-selfset the align and justify pair in one declaration, which Tailwind emits constantly. Yoga has nojustify-itemsorjustify-self, so the last two set their align property and only check that a second value is a keyword. The align and justify longhands also take the web spellingsstart,end,self-start,self-end,left,rightandnormal.The math functions beyond
min()/max()/clamp():round()withnearest,up,downandto-zero,mod(),rem(),abs(),sign(),sin(),cos(),tan(),asin(),acos(),atan(),atan2(),pow(),sqrt(),hypot(),log()andexp(), plus the constantspi,e,infinity,-infinityandNaN. Operands are calc() expressions, so they nest in one another and incalc(), and takevar(); all-constant inputs fold at parse time. The property's own converter reads the operands, soround(1250ms, 1s)is a duration, and when it refuses one they are all read as plain numbers, which is whatcalc(mod(7, 3) * 1s)needs;round(1s, 1)mixes the two and is invalid. Trigonometric arguments are angles or radians, the inverse functions return degrees.calc()with a leading sign,calc(-1 * 10px), used to fail to parse and now works.:nth-child(An+B of S)and:nth-last-child(An+B of S)count only the siblings matchingS, a selector list of compound selectors; a branch with a combinator is dropped, since siblings are what is counted. The clause adds the specificity of its most specific branch, as on the web.attr()reads adata-*prop into any property:content: attr(data-label), with the prefix optional, a fallback as the second argument, and a unit after the name (attr(data-size px)) or a type for a typed value. A pseudo element reads the element it belongs to, and a number or boolean prop reads as its text.linear()joinssteps()andcubic-bezier()as an easing:linear(0, 0.25 40%, 1), with up to two positions per stop to hold a value, missing positions spread evenly, and extrapolation past the ends.The
hover,any-hover,pointerandany-pointermedia features are now always defined, so the boolean forms(hover)and(pointer)are the standard way to gate hover-only styling; a platform whose real value was the provider's default used to leave them undefined. Two provider bugs went with it: a mobile platform registered theconsolemedia type instead ofmobile, and the legacy Input path listed a touch screen as a fine pointer.TypeScript's
RenderStylegainscolorScheme,placeContent,placeItemsandplaceSelf, and the align and justify types take the new spellings.2026-09-05-b7e3a1.mdShow Bumped Packages (1)
npm:@reactunity/rendererAccept the CSS that Tailwind and the web actually emit
A stylesheet written for a browser, or generated by Tailwind v4, reaches ReactUnity full of spellings the engine parsed into nothing. Each one below was silently dropped before; each is now either honoured or mapped onto the nearest thing the renderer already had.
Names and keywords. Vendor prefixes are stripped, so
-webkit-line-clampismax-linesand-webkit-text-strokeistext-stroke(custom properties keep their--).text-decoration-linesets the same flags asfont-style.overflow: autoscrolls andoverflow: cliphides.font-variant: small-caps(and-caps,all-small-caps,petite-caps) is atext-transform.text-aligntakesjustify,startandend;text-overflowtakesclip.text-wrap: balance | pretty | stableparse as plain wrapping. Thedvh/svh/lvhfamily,dvw/svw/lvw, theirmin/maxforms andvi/vball resolve as viewport lengths.min(),max()andclamp(). Anywherecalc()works, with the same operand grammar, soclamp(1rem, 2vw + 0.5rem, 2rem)andmin(100%, var(--width))resolve against the same reference ascalc.noneis accepted for either bound ofclamp, and all-constant inputs fold at parse time.white-space.text-wrap: nowrapused to be the whole story;white-spacenow carriespre,pre-wrap,pre-lineandbreak-spaces, and TextMeshPro'stextWrappingModefollows it, preserving runs of spaces and newlines where the value asks for it. The renderer'swhiteSpacetyping grew the same values.text-shadow. Rendered through TMP's underlay, so the shader draws it and no second text object exists. Pixel offsets and blur are converted to the underlay's own units from the font asset's sampling point size and gradient scale, which is why1px 1px 2pxlooks the same at any font size. TMP has a single underlay, so only the first shadow of a list is drawn.overflow-xandoverflow-y.overflowis now a two-value shorthand over them. Yoga still has one overflow, so it gets the stronger of the two (scroll over hidden over visible), and the mask applies when either axis is hidden, because a rectangle mask cannot clip one axis alone. What the split does buy is scroll direction: a<scroll>withoverflow-x: hiddenscrolls vertically only, andoverflow-y: hiddenhorizontally only, unless thedirectionprop says otherwise.caret-colorand::selection. Both on<input>. The caret defaults tocurrentColoras the spec says.::selectionis a pseudo-element like::placeholder, and itsbackground-coloris the TMP_InputField's selection highlight, soinput:focus::selection { background-color: … }works too. That is the whole of what TextMeshPro can recolor: the selected glyphs keep their own color, and<text>has no selection at all.scrollbar-colorandscrollbar-width.scrollbar-color: <thumb> <track>andscrollbar-width: auto | thin | none | <length>on a<scroll>. They are implemented as custom properties the user-agent::scrollbarand::scrollbar-thumbrules read throughvar(), which gives the precedence the web has for free: a::scrollbarrule of your own still wins, andautofalls back to the defaults.noneis a zero-size scrollbar rather thandisplay: none, because the ScrollRect toggles the scrollbar object itself to auto-hide it. One deviation: both inherit, where the spec inherits only the color.Media queries.
prefers-color-schemeandprefers-reduced-motionexist as features. The default scheme follows the Editor skin in the Editor and islightin a player; reduced motion defaults tono-preference. Both are runtime values, set withDefaultMediaProvider.SetValuefrom whatever the game reads from the platform.Gradients.
linear-gradient(to right in oklab, …), and the same hint onradial-andconic-gradient, parse. The colour space itself is not honoured; interpolation stays in sRGB, which is what a browser does for the defaultin srgband is a close match for the short hues Tailwind emits.A regression test pins down that Tailwind's
filter: var(--tw-blur,) var(--tw-brightness,) …chains already worked: an emptyvar()fallback resolves to nothing and the remaining functions compose.2026-09-05-e4c1a7.mdShow Bumped Packages (1)
npm:@reactunity/rendererContainer queries, style queries and container units
container-type(normal,size,inline-size),container-nameand thecontainershorthand make an element a query container, and@containerrules read the nearest eligible ancestor:(min-width: 400px),(width > 400px),(400px <= width < 800px),aspect-ratio,orientation,and/or/not, an optional container name, and nesting inside style rules,@media,@supports,@layerand other@containerblocks. A length in a condition resolves against the container, so20emis its font size.style()queries compare a property of the container: a custom property as its declared text, withstyle(--theme)alone meaning it is set; a regular property as its computed value. Every element can answer one, whatever itscontainer-type.cqw,cqh,cqi,cqb,cqminandcqmaxmeasure the nearest size container's content box on each axis, and fall back to the viewport where there is none.A container is measured after layout, and one whose content box changed since a query or a unit last read it restyles its subtree and lays out again in the same frame, up to twice. There is no size containment, so a container whose size depends on what the query changes can oscillate, and stops where the second pass lands. UIToolkit, whose layout is the framework's, sees a resize a frame later. A change to a property a
style()query reads re-resolves the elements that read it, inherited or not.The bundled parser reads a
@containerprelude as written, since a container query is not a media query, and parses one nested in a style rule the way it parses a nested@media.TypeScript's
RenderStylegainscontainerTypeandcontainerName.2026-09-06-a4c7e2.mdShow Bumped Packages (1)
npm:@reactunity/rendererscrollbar-gutter, and scrollbar-size is gone
scrollbar-gutter: auto | stable | stable both-edgeson a<scroll>. ReactUnity's scrollbars are drawn over the content, so there was never layout shift to prevent; whatstablebuys here is that content is never covered. The viewport shrinks by the bar's thickness along the bar's edge, for each direction the view can scroll in, and Yoga loses the same width, so awidth: 100%child fits the visible area exactly and does not start a horizontal scroll of its own.both-edgesmirrors the gutter onto the opposite edge. The thickness comes from the bar's own computed size, soscrollbar-width: thinreserves 6px andnonereserves nothing. One limit: a percent padding on the scroll view cannot absorb the gutter, so the gutter is skipped there.scrollbar-size, an alias ofscrollbar-widththat never existed on the web, is removed from the property map and theRenderStyletyping.scrollbar-widthis the spelling.2026-09-06-d2e7b9.mdShow Bumped Packages (1)
npm:@reactunity/rendererlight-dark() reaches Unity as written from a Vite build
The Vite plugin names an old browser as the CSS target so Lightning CSS lowers oklch, color-mix and nesting, which ReactUnity does not read. That target also lowered
light-dark(), into two variables that only acolor-schemedeclaration in the same sheet defines, so an app's ownlight-dark()colors came out invalid unless a Tailwindscheme-*utility happened to sit next to them. The plugin now excludes that one feature from the lowering, since ReactUnity resolveslight-dark()itself; an app that setscss.lightningcss.excludekeeps its own value.2026-09-07-8b4f2c.mdShow Bumped Packages (1)
npm:@reactunity/rendererfilterrendersfilterhas parsed since the property was added and applied to nothing.FilterDefinitionread every function, interpolated between them, and
HasBorderOrBackgroundeven brought aBorderAndBackgroundinto existence for it -- but no code ever looked at the value, so astylesheet asking for
filter: grayscale(1)got an unfiltered element and no diagnostic.It draws now, in the UGUI framework:
blur,brightness,contrast,grayscale,hue-rotate,invert,opacity,saturate,sepia, plus the non-standardgrainandpixelatethatbackdrop-filteralready accepted -- anddrop-shadow, which had been acommented-out field. Four more non-standard ones are added below.
The element's subtree is rendered into a RenderTexture and the result is composited back into
its original slot with the filter applied in one fragment pass. That shape is why
filterneeds no per-pipeline code: unlike
backdrop-filter, which reads what is behind the elementand so needs URP's
_CameraOpaqueTexturein one subshader and a built-inGrabPassinanother, a filter only ever reads the element's own pixels. One subshader serves built-in, URP
and HDRP -- and HDRP is the interesting one, because
backdrop-filterhas always been brokenthere. Its URP subshader is tagged
UniversalPipeline, so HDRP falls through to the built-inone, whose
GrabPassHDRP does not support.An ancestor's
overflow: hiddenclips the filtered result, not the filter input, which iswhat CSS says and what falls out of this design for free.
overflow: hiddenhere is a stencilMask, and stencil state does not survive a render-target switch, so the offscreen passrenders the subtree unclipped; the composite is a
MaskableGraphicsitting in the element'soriginal place, and gets clipped normally. A
Maskinside a filtered element still clips,because the offscreen canvas recomputes stencil depth consistently within itself.
Two details that are easy to get wrong and are worth stating, since both were measured rather
than assumed:
The render target is snapped to whole device pixels. Text is
TextMeshProUGUI, and its SDFantialiasing is derivative-based, so an offscreen canvas that mirrors
scaleFactorandreferencePixelsPerUnitreproduces in-place rendering bit for bit -- zero differing pixels,for a screen-sized target, an element-sized one, and at a fractional
scaleFactorof 1.5.Alignment is the only thing that matters: half a pixel of offset moves 2.75% of pixels with
local deltas to 136/255, which reads as shimmer on anything animating. Nothing about the
target's size changes glyph weight, which never moved more than 0.5% in any variant.
The composite blends premultiplied. UGUI draws with
SrcAlpha OneMinusSrcAlpha, so a targetcleared to transparent black comes back with its colour already multiplied by coverage;
blending that again the same way multiplies twice and leaves dark fringes on every edge. The
filter functions are defined on straight alpha, so the shader unpremultiplies, applies the
chain, and premultiplies on the way out.
There is a filter region, as on the web: the target and the composite are both grown past the
element's box so nothing is sliced off at the edge. It is per-side rather than one margin,
because a drop-shadow only needs room on the side it falls -- and the sides are grown to keep
the frame's centre on a whole pixel, since half a pixel of offset is the shimmer case above.
Each side is the blur kernel's exact support rather than a guessed multiple of the radius. One
pass reaches four taps at quarter spacing, so plus or minus its own radius; n passes of
r/sqrt(n) reach sqrt(n)*r, which is at most 2r because n is capped at four. That is half what
the first cut allocated, and the falloff it produces is identical pixel for pixel --
blur(24px)still measures 0.99 0.96 0.90 0.79 0.64 0.45 0.28 0.15 0.06 0.02 0.00 across its soft edge --
so the render target is about a quarter smaller in area for a wide blur, clipping nothing.
drop-shadowcasts from the element's alpha, which is what separates it frombox-shadow: ittraces glyphs and transparent images instead of the border box. It is derived from the capture
after the element's own blur, so a blurred element throws a blurred shadow -- the order
blur() drop-shadow()gives on the web -- and it is composited in the fragment pass rather thanblended into the capture, so the colour ops reach the element and not the shadow. Measured:
under
grayscale(1) drop-shadow(20px 20px 0 blue)the element reads 0.30 grey, Rec.601 luma ofred, while the shadow stays pure blue. The blur is shared with
filter: blur()and lands closeto the spec's sigma of half the radius: a 12px radius measures about 6.4px of sigma.
drop-shadowreusesBoxShadow's parser, so it also accepts a spread andinset, which CSSdoes not have here. Both are ignored rather than rejected.
A filtered element stays interactive. Isolating one subtree is only possible by
reparenting it under an offscreen canvas -- a camera
cullingMaskcannot do it, because aScreenSpaceOverlaycanvas has no camera to cull against -- and that takes the subtree out ofthe main
GraphicRaycaster. Events are routed back the wayeventViewportalready routes theminto a canvas rendered to a texture:
CustomViewportRaycasterremaps the pointer from thecomposite's rect into the offscreen camera's screen space, so the existing machinery needed no
changes. Two things had to be added on top of it.
The offscreen canvas is
WorldSpace, andGraphicRaycasterreportsint.MinValueforsortOrderPriorityon anything that is not an overlay -- so every hit inside a filter wouldhave lost to the canvas the composite sits in. It borrows that canvas's priorities instead.
And the raycast is gated on the composite. The composite stands exactly where the element was,
so whether a point reaches it is whether that point should reach the subtree -- and asking it
runs the ancestor chain the reparent removed. That is what still applies an ancestor's
pointer-events: none, and what makes an ancestor'soverflow: hiddenclip hits the same wayit clips pixels: with a 200px button inside a 100px
overflow: hiddenwrapper, a point at 50pxhits and one at 150px does not, filtered or not.
A filtered element stays interactive inside a real page, which the fixtures could not see.
Their filtered element sat directly under the canvas, where nothing else is a raycast target; a
<scroll>around it is, and its viewport was taking every click. Hits inside a filter come backmeasured against the offscreen camera, which says nothing about the canvas they have to be sorted
against -- and depth, which would say it, is only compared within one root raycaster. They are
rebased onto the composite now, a thousandth of a unit in front: far too little to reach past a
canvas that genuinely sits closer, and enough to beat the containers holding the element.
Bubbling had the same hole.
ExecuteHierarchywalks up from whatever was hit, and the reparenttruncates that walk at the offscreen canvas -- so a wheel over a filtered card would not scroll the
page it was on. The top of the subtree carries on from the composite instead, which is only reached
when nothing inside handled the event first.
The surface is also a scene root, since a nested canvas would inherit the render mode it exists to
escape -- so nothing in the context's own hierarchy reached it, and disposing the context left the
whole subtree behind with a live raycaster on it. Reloading the app a few times was enough to
collect stray raycasters that answered clicks anywhere on screen.
rotateandscalesurvive a filter, which took a fix rather than a test. The capturetakes the element's rotation so it is framed square-on, and the composite was taking no
transform at all -- so the two cancelled and
rotate: 45degcame back as an upright square,measured as coverage 0.00 -> 1.00 at a corner the rotation had emptied.
scale: 2framed onlythe middle half of the element and then drew it into the unscaled box, 1.00 -> 0.00 at 250px
along a 400px-wide element.
Both now come out of the capture and go onto the composite instead, which is also the order CSS
asks for: the chain runs in the element's local space, so
pixelateblocks turn with theelement rather than staying screen-aligned, and a blur radius widens with
scalebecause therender target is sized in the element's scaled pixels and the blur is measured in its texels.
What is not there yet:
the subtree a hair in front of the composite, not by real depth -- the event system compares
RaycastResult.depthonly within onerootRaycaster, andBaseRaycaster.rootRaycasterisnot virtual. So a filtered element wins a tie against unfiltered content drawn over it.
backdrop-filter: drop-shadow(...)parses and does nothing. The two properties shareFilterDefinition, and a backdrop has no alpha edge to cast from.filter. Nothing in Unity's own styling maps to it.A filtered element that is not changing costs nothing per frame. It re-renders when the element
resizes, when a descendant graphic rebuilds, or when any descendant transform moves -- that last
one being the case a naive invalidation misses, since moving a child dirties no graphic at all and
the canvas just re-batches. A static filtered element settles after one render.
A change to the filter value only re-captures when the capture would come out different, which
means a blur radius, or a drop-shadow that moves or resizes the filter region. Every colour op is
a uniform the composite is drawn with, so animating
hue-rotateorbrightnesscosts onematerial write per frame and no offscreen pass at all -- it was costing a full re-render of the
subtree on every frame of the animation.
Blur iterates its kernel rather than stretching it. Nine taps whose spacing grows with the
radius sample too sparsely for a wide blur and band; convolving the kernel n times widens it by
sqrt(n) with the taps left where they are, so the radius picks a pass count instead, up to
four.
blur(24px)falls off monotonically across its whole soft edge.Four more filter functions, aimed at game UI
posterize,scanlines,tintandchromatic-aberrationjoingrainandpixelateasfunctions the web does not have. All four work in
backdrop-filtertoo, since the twoproperties share
FilterDefinition.Three of them are pointwise, which is the whole reason they are cheap: they are a uniform the
composited image is drawn with, so they never re-capture the subtree and animating one costs a
material write.
chromatic-aberrationis the exception -- it reads one offset either side, andneeds transparent margin to read there, or the sampler's clamp leaves the outer edge with no
fringe at all. It widens the capture region the way a blur does. But the margin is whole pixels,
so it only re-renders when that integer changes: animating one through three pixels re-renders
three times, not sixty times a second.
tintmultiplies, so it only darkens;brightness(1.4) tint(#f66)is the damage flash, andgrayscale(1) tint(...)is how a panel greys out.posterize(n)rounds to n evenly spacedlevels including both ends, so
posterize(2)is black and white rather than black andmid-grey. Ordering matters for these: the quantisation lands after the colour ops and the tint
so it applies to the final value, and before grain and scanlines, since posterizing noise
flattens it away.
Neither grain nor scanlines takes a time input. Both take a trailing phase, and a
@keyframesanimation on that is what moves them -- which keeps a static filter free and putsthe per-frame cost only where it was asked for. A CRT's scanline pattern does not move either;
its hum bar drifts, so the phase is a length and one period of it is one roll. Grain's phase
feeds the noise hash instead, where any change gives an unrelated field rather than the same
one slid across -- so it flickers rather than crawls, and the timing function picks the rate:
linearresamples every frame,steps(12)gives grain at twelve frames a second.grainwas static before this and had no way not to be.style={undefined}no longer takes the whole app downA prop that is present and undefined is ordinary React, and
styleanddatawere the two thatcould not survive it: they are read as objects, and an undefined one arrives in the command batch
as a JSON null. Reading a null's children threw inside the batch flush, which is an unhandled
promise rejection -- so one
style={cond ? x : undefined}anywhere left the app with nothingrendered and a Newtonsoft stack trace as the only clue. An undefined style now means no
declarations, which is what it says.
filterandbackdrop-filterare typedStyleis hand-written rather than generated, and neither property was in it -- sostyle={{ filter: 'blur(4px)' }}was a type error in every app, while the same declaration in astylesheet was fine. Both are
SafeStringnow, alongsideboxShadowandtextShadow.Colour ops reached the wrong material under a masking ancestor
Everything above was true of an element with no
Maskabove it, and false of one inside a<scroll>or anoverflow: hiddenancestor -- which is most real UI. Under a mask UGUI does notdraw a graphic's own material:
MaskableGraphic.GetModifiedMaterialhands it toStencilMaterial.Add, which makes a copy carrying the stencil state and caches it for the lifeof the base material. The filter chain was written to the original, so whichever of the two was
made first won -- and when the copy came first, every uniform-driven function fell back to the
shader's identity defaults. Only
blursurvived, because a blur is baked into the render texturerather than read at draw time.
The chain is written to whatever is actually being drawn now, and again whenever UGUI rebuilds it
-- the composite listens for its own material going dirty, which is what a mask coming or going
above it raises.
This is a good example of a test suite agreeing with itself: the fixtures put the element
directly under the canvas, so the copy was always made after the write and 20 tests passed on a
feature that did not work on the page it was written for.
Two filters no longer capture each other
Every offscreen surface was parked at the same world position, and every offscreen camera has the
same culling mask -- so a camera saw not just its own subtree but any other subtree that happened
to overlap it there. With one filter on a page nothing shows; with twenty, elements at similar
offsets within their canvases collect pieces of each other. Each surface now takes a slot of its
own, spaced wider than a camera's far plane, and slots are handed back when a filter goes away so
the coordinates stay small enough to stay exact.
Animating
backdrop-filterno longer leaks a material per frameWebFiltercached materials in a static dictionary keyed on the whole property set, includingthe
FilterDefinition. Since a definition is interpolatable, every frame of atransition: backdrop-filterproduced a distinct key and so a freshMaterialthat was neverreleased -- for the life of the process. Each component now owns one material and mutates it in
place.
The cache could not have hit in any case.
FilterDefinitionoverridesEqualsbut notoperator ==, and the key'sEqualscompared its definition with==-- by reference --while its
GetHashCodehashed the same field by value. Both now compare by value, which alsokeeps an all-identity
filtersuch asbrightness(1)from allocating a render target fornothing.
Applying a style that did not change no longer rebuilds the element
An element's style is applied on every frame it is visited, and three of the things that
application assigns rebuilt their graphic whether or not the value had moved: the background
image's size, the border rounding, and TMP's font style, weight and font asset. Nothing showed,
because a rebuild produces the same pixels -- it was paid for by every element on screen, every
frame, and it is only visible from something that watches for a rebuild.
filteris that something. A filtered element re-captures its subtree when a descendant graphicgoes dirty, so an animation on a pointwise op --
hue-rotate,grain's phase,scanlines'phase, none of which need a new capture -- was re-rendering the whole subtree sixty times a
second anyway. Measured on the kitchen-sink filter page, four animated cards were re-capturing on
every frame; three of them now hold one capture for the life of the animation, and the fourth
animates
blur, which genuinely has to.The guards are equality checks at the four assignment sites. Rounding needed value equality to
compare with (
WebRoundingPropertiesis a class and each site builds a fresh instance from thestyle, so a reference check would never match), and
RecalculateFontStyleAndWeighthad to stopassigning
fontStyletwice -- once provisionally and once with the transform flags -- before aguard there could hold.
Offscreen filter surfaces are collected under one object
There was a
[FilterSurface]at the scene root per filtered element. They cannot go under theReact canvas: each carries a
WorldSpacecanvas, and a canvas nested under another inherits thatone's render mode, which would turn the offscreen pass back into an onscreen one. They are
parented to a single
[Filters]object instead, created with the first filter in the scene anddestroyed with the context -- which also replaced the per-filter disposable that was keeping them
from outliving the app.
2026-09-07-c9a4e2.mdShow Bumped Packages (1)
npm:@reactunity/rendererContainer queries measure the content box on UIToolkit, and a few smaller fixes
On UIToolkit a size query and a container unit were reading the element's border box, since that framework's components have no Yoga node to take the padding and border from. They now read
contentRect, which is what the docs promised and what UGUI already did, socontainer-type: sizeon a padded element answers the same question on both frameworks.container-typeacceptsscroll-state, alone or withsizeandinline-size, so a stylesheet written for the web parses; there are no scroll-state queries, so it decides nothing. Astyle()query on a custom property registered with a typed@propertysyntax,<length>or<color>say, compares computed values rather than declared text, sostyle(--gap: 20px)holds for--gap: 1emat a 20px font size.A container is now measured only while something reads it. Every restyle of its subtree lets the rules and units that still depend on it register again, and one nothing reads any more drops out of the post-layout check instead of staying there for life. A disabled stylesheet's rules are skipped while matching rather than matched with nothing to apply, which is what kept a removed
@containerrule measuring its container. That uncovered a cascade slip: when every matching rule was!important, an inline style was placed above them instead of below, and now sits where it belongs.RenderStylegains thecontainershorthand, and the nesting page no longer claims container queries do not exist.Publish
The following packages will be published if merged:
@reactunity/create0.24.0npm@reactunity/material0.24.0npm@reactunity/renderer0.24.0npm@reactunity/scripts0.24.0npm