Compositing and Effects

A note card might combine a photograph, a vector border, editable text, and a native button. Compositing brings those pieces into one scene: what appears in front, what remains visible through a shape, and how transparency changes the result.

This chapter builds on Layout and Drawing. It covers the boundaries around content; Animation explains how to move those boundaries and change their properties over time.

Neon Opacity's Afterimage Observatory combines moving vector layers, transparency, and masked native controls. Adjust the BEAM slider in the Console to change the shared opacity (initially 75%), then try the text field or ENGAGE button while the scene moves. Watch how overlapping surfaces and their controls fade together. Open standalone gives the composition more room to explore.

The masks in this example use geometric coverage; the painted alpha mask section below describes a separate mode. Its motion is driven by frame-based Rust updates.

On iOS and iPadOS, the chassis applies native scene updates and presents Metal surfaces in the same Core Animation transaction. Native text and Scroller occlusion masks are also resolved and applied synchronously in that update, so a new popup does not wait on separate mask callbacks to cover underlying rows. Unchanged masks reuse their applied image; changed masks use a raster cache or are generated before presentation. Cache misses therefore contribute to frame time. Image loading remains asynchronous; supply a placeholder if an image must be visible immediately.

Choose the boundary

Earlier siblings in a Pax template appear in front of later siblings. Put the label before its surface and the surface before its shadow. Nesting gives a subtree a shared coordinate system, so moving or rotating its container carries its descendants with it. See element order and transforms for those foundations.

Choose a container based on what should happen at its edge:

ContainerUse it for
GroupA shared transform and layout space, with content free to overflow
FrameA rectangular or rounded-rectangular clipping boundary
MaskA boundary supplied by another subtree's geometry or painted alpha

These containers do not paint a background by themselves in an ordinary scene. Add a Rectangle or another drawing primitive when you want a surface. A rounded Rectangle changes its own silhouette; it does not clip text, images, or other siblings to that silhouette.

Clip with a Frame

A Frame clips its descendants to its bounds by default. corner_radius rounds that boundary, using a number of pixels:

<Frame x=24px y=24px width=280px height=160px corner_radius=24>
    <Text x=16px y=16px width=248px height=32px text="A view of the coast"
        style={font: "Arial", font_size: 18px, fill: rgb(36, 54, 47)} />
    <Ellipse x=196px y=80px width=140px height=140px
        fill=rgb(72, 124, 112) />
    <Rectangle width=100% height=100% fill=rgb(237, 241, 226) />
</Frame>

The ellipse extends beyond the Frame's right and bottom edges. Only the part inside the rounded boundary is visible. The Text participates in the same clipped composition even though it uses a native text surface.

The radius is clamped to fit the smaller dimension. It is a single radius for the clipping boundary; the four-corner list accepted by Rectangle is a different property contract. For ordinary grouping without a crop, use Group. Layout covers autosizing and choosing the Frame's dimensions.

Clipping changes visibility, not the child's layout size or application state. A clipped child remains mounted. Nested Frames further restrict the visible area; a child's transform does not free it from an ancestor's clip. Leave room inside that boundary for strokes and animated overshoot, or place the decoration outside the clipped subtree.

Image fitting and clipping are separate choices. An Image handles its own rectangular fit; a surrounding Frame can add rounded corners. See image fitting.

Shape content with a Mask

A Mask takes exactly two direct children:

  1. The content to show.
  2. The subtree that supplies the mask.

The alpha property selects how that second subtree is used:

ModeSource behaviorContent support
alpha=false (default)Geometric coverage gives a hard clipping boundaryRendered and native content
alpha=truePainted alpha controls how much content remains visible at each pointGPU-rendered canvas content

Use a Group when either side contains several elements. Here, the first Group contains a surface, native text, and a native button. The second child is an ellipse that selects the visible area:

<Mask x=24px y=24px width=280px height=180px>
    <Group width=100% height=100%>
        <Button x=70px y=84px width=140px height=36px label="Open note" />
        <Text x=60px y=44px width=160px height=28px text="Moss and rain"
            style={font: "Arial", font_size: 18px, fill: rgb(36, 54, 47)} />
        <Rectangle width=100% height=100% fill=rgb(237, 241, 226) />
    </Group>
    <Ellipse x=20px y=10px width=240px height=160px fill=BLACK />
</Mask>

The ellipse itself is not painted as a black foreground object. Pax uses its transformed coverage path to clip the first child's descendants, including native surfaces. Black is simply a convenient fill for the source shape.

Both sides use the Mask's coordinate space. Percentage dimensions resolve against that space; positions, rotations, and nested transforms carry through to the resulting coverage. Bind a source shape's position or size to a property to move the visible window, or animate those values with a timeline. Moving the source changes the window without moving the content underneath it.

Use a path or a subtree

For a custom silhouette, replace the second child with a filled, closed Path. This one cuts the corners off a rectangular label:

<Path width=100% height=100% fill=BLACK
    elements={[
        PathElement::Point(12%, 0%),
        PathElement::Line, PathElement::Point(88%, 0%),
        PathElement::Line, PathElement::Point(100%, 20%),
        PathElement::Line, PathElement::Point(100%, 80%),
        PathElement::Line, PathElement::Point(88%, 100%),
        PathElement::Line, PathElement::Point(12%, 100%),
        PathElement::Line, PathElement::Point(0%, 80%),
        PathElement::Line, PathElement::Point(0%, 20%),
        PathElement::Close
    ]} />

In the default geometry mode, Mask collects coverage paths from descendants of its second child. A source Group can therefore contain several shapes. Keep the source tree small and purposeful: only geometry with a coverage-path implementation contributes. A Group by itself contributes no shape, and native text does not turn into letter-shaped clipping geometry.

The source paths are combined for clipping; this is not a general boolean geometry editor. For holes, overlapping contours, or self-intersections, verify the result on the target renderer instead of assuming a particular union or subtraction rule. Drawing owns path commands and SVG conversion.

Geometry and transparency

With alpha=false, the mask uses geometric coverage rather than painted transparency. An Image contributes its rectangular bounds, not the silhouette of its transparent pixels. Use alpha=true for soft coverage authored with vector fills, strokes, and gradients, as shown below.

Use explicitly filled shapes for predictable source coverage. Shape APIs decide which geometry contributes: for example, a Path with no visible fill or stroke may have no coverage at all. If the source produces no coverage path, geometry mode applies no clip; it does not hide everything. Use conditional content when the intended state is “show nothing.”

A nested Mask can further constrain already-masked content. In geometry mode, the source's paint appearance does not determine coverage, and source-side Frame and Mask clips are not applied to the collected coverage paths. Painted alpha sources use the retained renderer and preserve those vector clips.

Painted alpha masks

Set alpha=true to reveal content according to the source's painted alpha. Opaque paint reveals fully, transparent paint hides, and partial alpha gives partial visibility. The source's RGB color is irrelevant: opaque black and opaque white both reveal fully. This is alpha masking, not luminance masking.

Here a gradient reveals a green surface through the middle and fades it away at both ends:

<Mask x=24px y=24px width=280px height=120px alpha=true>
    <Rectangle width=100% height=100% fill=rgb(56, 100, 78) />
    <Rectangle width=100% height=100%
        fill=@gradient {
            linear: { start: [0%, 50%] end: [100%, 50%] }
            0%: rgba(0, 0, 0, 0)
            25%: rgba(0, 0, 0, 255)
            75%: rgba(0, 0, 0, 255)
            100%: rgba(0, 0, 0, 0)
        } />
</Mask>

Rectangle, Ellipse, Path, and Line appearance layers can supply alpha, including solid alpha and linear or radial gradient stops. Source transforms and opacity apply too. Use up to eight ordered gradient stops. A Group or repeated subtree can combine supported source shapes; overlapping paints combine with source-over alpha, so two half-opacity shapes give 75% coverage where they overlap.

A primitive source combines all its fill and stroke layers before applying its own opacity. Two opaque layers on one 50%-opaque source contribute 50% alpha; two 50%-opaque layers on an opaque source contribute 75%. Layer paint uses the full smoothed geometry bounds, independent of outline width or reveal. Alpha sources use the same retained vector draws and offscreen surfaces as ordinary group opacity. Group boundaries apply after their children compose: two overlapping opaque shapes under Group opacity=50% produce 50% coverage throughout, including their overlap. This differs from setting 50% opacity on each shape, which produces 75% in the overlap.

Source-side Frames and nested Masks apply their vector clips. For example:

<Mask width=280px height=100px alpha=true>
    <Rectangle width=100% height=100% fill=CYAN/>
    <Frame width=100% height=100%>
        <Group width=100% height=100% opacity=50%>
            <Rectangle width=65% height=100% fill=WHITE/>
            <Rectangle x=35% anchor_x=0% width=65% height=100% fill=WHITE/>
        </Group>
    </Frame>
</Mask>

The source is captured independently of the consuming content's ancestor clips and opacity. Its alpha is then feathered, if requested, and combined with the consumer's enclosing alpha mask. The content still uses its own ordinary clips and opacity; those factors are not applied again inside the source.

Path strokes used as alpha sources respect draw_start and draw_end. A reusable vector-producing component can supply the source too. Its template expands and its mount handler runs normally; reactive properties, timelines and tick/pre-render handlers remain live until the source is removed. For example, A mesh gradient on the painted surface keeps one continuous field across the word while handwriting reveals it.

Handwriter can reveal a colored surface as its generated stroke is drawn:

<Mask width=300px height=100px alpha=true>
    <Rectangle width=100% height=100% fill=rgb(0, 220, 235)/>
    <Handwriter width=100% height=100% text="Pax"
        stroke={paint: WHITE, width: 5px}
        draw_end=@timeline { duration: 2s, 0: 0%, 100%: 100% }/>
</Mask>

The source is a live component tree, but its output goes only to the mask. It does not create visible native surfaces or input targets. Native Text and controls—including Handwriter's invisible selectable text equivalent—do not contribute mask pixels. Keep a text equivalent outside the mask when a vector reveal carries meaning. Source mount handlers can still perform application side effects, so use the same lifetime/cleanup discipline as visible components. Detached sources do not receive viewport-proximity events: their local geometry supplies the mask and does not represent visible viewport content.

This does not make every component a supported source: the rendered leaves must still be supported vector shapes. Scrollers and native leaves inside the source are excluded; this does not capture across native surfaces. The default geometric mode also differs from painted alpha: a Path's nonempty draw range contributes its complete stroke outline to geometric coverage, not the progressively drawn segment. Use alpha=true for handwriting reveals.

Add feather=2.0 to the Mask to soften its painted coverage. feather is the Gaussian standard deviation in logical pixels, rather than a percentage or a Pax length literal. It defaults to zero; negative values are treated as zero. It affects alpha masks only and does not blur the revealed content itself. Captures include a gutter for the blur's three-standard-deviation support, including nested feathering, so tile edges do not cut off nearby source pixels. If the padded capture exceeds the GPU texture-size limit, the mask hides its content and reports a rendering diagnostic.

Living Quilt uses alpha=true feather=2.0 for its moving color waves. See the live Living Quilt and its source for the complete two-child composition: a colored quilt followed by a Group of repeated animated paths that supplies the coverage.

Nested alpha masks on the content multiply their coverage, while ordinary geometric clips further restrict the result. An empty or fully transparent alpha source hides all content. Alpha masking modulates individual canvas draws; it does not flatten the content into an isolated group before applying transparency.

Alpha masks can reveal canvas content inside a Scroller, including a nested Scroller with intervening Frames or Masks. The source and content use the owning Scroller's content coordinates, so their authored offsets stay aligned as the viewport scrolls. The mask-strokes example includes live handwriting, a direct Path source, and a nested scrolling ring to exercise this boundary. Source-side vector clipping is supported; a Scroller inside the source subtree still cannot supply a cross-surface capture.

The current alpha path requires the GPU renderer. Native text and controls are not supported as content inside an alpha mask, and Piet does not support alpha masking. Images, text, and native elements are not supported alpha sources. For a mixed native/rendered composition, keep the default geometry mask; for a soft visual reveal, use supported vector source paint and keep interactive hit targets separate. Alpha coverage does not change hit testing.

Paint, layer opacity, and stroke-reveal changes reuse retained geometry. A changing reveal redraws the source texture with GPU draw-range parameters; it does not trim and tessellate a new outline on the CPU. Unchanged captures reuse their pixels. Removing a source retires its retained draws, mask textures, and capture resources.

Opacity through a subtree

A vector element assembles its fill and stroke layers before applying its opacity, so overlapping layers fade together. Each layer also has independent opacity; see Paint layers.

opacity accepts a normalized value such as 0.5, or a percentage such as 50%. On WGPU and the browser's Piet/Canvas2D renderer, it fades the composed canvas content in a subtree. There is no separate isolation setting. For example, these overlapping rectangles form one silhouette with uniform 50% opacity:

<Group x=24px y=24px width=280px height=120px opacity=50%>
    <Rectangle x=0px y=0px width=160px height=100px
        fill=rgb(56, 100, 78) />
    <Rectangle x=100px y=20px width=160px height=100px
        fill=rgb(56, 100, 78) />
</Group>

The rectangles compose at their own paint alpha first; the Group then fades that result once. Nested opacity works the same way: a child with opacity=50% fades inside its parent, and a parent at 50% fades the assembled result again. Setting a child to opacity=1 cannot cancel an ancestor's fade. A Group does not add clipping; use a Frame or Mask to restrict overflow.

Paint alpha remains useful for a different purpose. Lower a background's fill alpha to let content behind it show through while its label stays crisp. Set the container's opacity to fade its assembled canvas artwork. See paint alpha, including rgba units. Independent sibling fades still overlap using source-over; complementary opacities do not keep a crossfading silhouette fully opaque.

Current backend limits: both renderers compose each canvas portion separately. Live native text and controls, and content in separate native Scroller surfaces, fade independently. Their overlap can therefore differ from an exact image of the whole mixed subtree. The native coverage masks below remain approximate. The focused examples/fixtures/group-compositing fixture has matching overlap probes on web WGPU, native macOS, iPhone/iPad simulators and a physical iPad. Its interrupted exit preserves edited native text on web, macOS and that iPad. These checks do not establish full accessibility coverage or performance for larger applications. Piet's browser fixture also matches the overlap reference and preserves native input during reversal. There is no author-selectable legacy mode.

WGPU retains visible group content across opacity changes. Source image, paint, transform, clip and lighting changes invalidate that content; outer translation and native mask updates still perform work. Group surfaces add memory and composition passes, so caching is not a guarantee of faster frames. Piet replays dirty canvas content and reuses temporary canvases for composition; it does not retain composed group pixels between dirty frames. Fully opaque scopes bypass the temporary canvas, and fully transparent scopes skip painting. Each simultaneously nested translucent scope can require another tile-sized canvas. The browser still owns live native text and controls.

Opacity does not unmount a component, disable a control, or manage keyboard focus. Keep those state and interaction decisions explicit. Likewise, a visual mask is not an accessibility description or a promise of pixel-exact hit testing for every control. See Accessibility and Native Controls.

Native compositing

Pax draws vector primitives and Image content through its renderer. Text, NativeImage, and form controls use native surfaces supplied by the browser or operating system. Those surfaces share the Pax scene's transforms and ordering, even though they are not all painted into the same canvas.

To make rendered content appear above a native control, Pax computes its coverage and masks the corresponding portion of the native surface. This is often called punch-through in the implementation. You normally express the desired order through the template:

<Group x=24px y=24px width=280px height=120px>
    <Ellipse x=180px y=0px width=80px height=80px fill=rgb(207, 120, 79) />
    <Button x=24px y=24px width=220px height=44px label="Partly covered" />
    <Rectangle width=100% height=100% fill=rgb(237, 241, 226) />
</Group>

The ellipse is earlier in the template, so its overlapping area appears in front of the Button. The later Rectangle stays behind both. Moving the ellipse updates that relationship; wrapping the composition in a Mask adds another visible boundary.

On Apple targets, changing coverage can require CPU rasterization of native punch-through masks. These masks store one coverage byte per pixel, and unchanged results are cached. A moving or fading overlay can invalidate that cache every frame, so a large masked surface can still be expensive. Native and rendered content are published synchronously to keep their coverage aligned. The browser uses SVG/CSS masks; the Apple rasterization cost is not a measurement of that path. macOS text snapshots consume that same coverage, including fully hidden pixels and partial opacity; changing text reapplies the mask to the new snapshot.

On macOS, masked Text snapshots include the text's painted overflow when clip=false; entering an overlap does not itself crop the label to its layout box. clip=true and ancestor Frame clips still constrain the text. Occlusion candidates are currently selected using layout bounds, so give text a box that covers its visible content when accurate masking of all overflow is required.

Updating native content

Ordinary leaf animation updates shared scene geometry and queries nearby native surfaces to refresh punch-through masks. Both the old and new coverage matter: a moving or disappearing shape must uncover the surface it leaves behind. Scroll-content geometry stays indexed in content coordinates, so scrolling a leaf-only native scroller queries the old and new visible regions without reindexing every descendant.

On macOS and iOS, the Swift native host also applies leaf patches to retained views by element ID. An unchanged native control keeps its view, selection, and focus. Opacity and stacking updates avoid rewriting native geometry; actual scale changes still use the platform's geometry path. On macOS, updated native content keeps its punch-through mask even when the mask geometry is unchanged. On macOS, clipped scroller islands containing native leaves can also detach offscreen Text and NativeImage views outside the renderer's warm region. This uses the same geometry index and platform prewarm margins as canvas rendering; it does not change the fixed margins of @viewport_proximity_* events. The runtime keeps the Pax nodes alive, and the native host retains each view and continues applying its patches. Re-entry restores that instance with its latest content, geometry, mask, and stacking order. This reduces the attached AppKit hierarchy, rather than the number of Pax nodes or retained native instances.

Editable text, focused or selected text, text overflowing its layout bounds, glass-backed content, and other native controls stay attached. Enabling VoiceOver or Switch Control restores the full native hierarchy. This culling optimization is currently macOS-only; web, iOS, and iPadOS retain their existing behavior. Nested presentation containers and unclipped/escaping content keep the conservative rendering path.

Structural changes, inherited clip/container changes, escaping unclippable branches, and scroll domains with nested presentation containers, disabled clipping, or disabled scroll islands currently use full reconciliation. These optimizations do not guarantee a particular frame rate; layout, native controls, and GPU work still contribute to frame time.

Coverage has limits

Native punch-through uses coverage geometry and an opacity estimate; it does not sample every final rendered pixel. This matters for subtle cross-surface blends:

In particular, a translucent canvas popup above a native Scroller does not behave like an isolated composited surface above that Scroller. The underlying canvas is attenuated again where the partially masked native scroll surface covers it. During a fade, artwork can therefore appear stronger in row gutters than over the rows themselves, even when the masks update synchronously. Per-primitive opacity does not correct this cross-surface blending limitation.

  • A gradient with changing alpha has one estimated coverage opacity for the native mask, rather than a separate alpha value at each pixel.
  • A rendered Image's coverage is rectangular, including transparent pixels in the source image.
  • A partially revealed Path can retain conservative coverage for its full stroke so the runtime does not rebuild native occlusion geometry on every animation sample.

These cases can differ from placing both pieces in a single paint surface. Keep important text and controls clear of such overlaps, and verify an intentional cross-surface effect on the target where it will ship. The occlusion and neon-opacity examples are useful places to explore the current behavior.

Scroller islands and modal underlays

A native Scroller can host its own rendered-content surface, often called an island. Its scrolling and clipping belong to that host. A translucent Rectangle on the root canvas therefore cannot be assumed to tint every native control and scroller island as if the whole interface were one image.

For a modal background that must both dim the scene and absorb pointer input, use an EventBlocker with a translucent solid background. Place the panel before it and the underlying content after it:

<Group width=100% height=100%>
    <Group x=50% y=50% width=280px height=120px>
        <Text x=20px y=20px width=240px height=32px text="A moment to pause"
            style={font: "Arial", font_size: 18px, fill: rgb(36, 54, 47)} />
        <Rectangle width=100% height=100% corner_radius=16
            fill=rgb(237, 241, 226) />
    </Group>
    <EventBlocker width=100% height=100% background=rgba(0, 0, 0, 128) />
    <Button x=24px y=24px width=160px height=44px label="Underlying action" />
    <Rectangle width=100% height=100% fill=rgb(248, 244, 234) />
</Group>

This demonstrates the surface ordering, not a complete modal component. In an application, mount the panel and blocker conditionally and provide dismissal and focus behavior. EventBlocker's background is transparent by default. It is a native surface that absorbs pointer input; it does not establish a keyboard focus trap. Routing covers route-driven panels and their lifecycle.

On native iOS and iPadOS, the blocker retains UIKit touch ownership so the content behind it cannot scroll. It forwards touch sequences into Pax's scene hit test, allowing GPU-rendered panel controls above it and an @click handler on the blocker itself to work. A single-finger tap activates once; drags, cancelled touches, and multi-finger sequences do not become backdrop clicks.

Platform-specific effects

Apple Liquid Glass

LiquidGlass provides an inherited effect scope for supported Apple native surfaces. A Group inside it can supply a rounded glass surface, while supported controls receive the native treatment:

<LiquidGlass x=24px y=24px width=280px height=160px
    spacing=12px variant="regular">
    <Group width=100% height=100% corner_radius=20>
        <Text x=20px y=20px width=240px height=32px text="Field notes"
            style={font: "Arial", font_size: 18px, fill: rgb(36, 54, 47)} />
        <Button x=20px y=76px width=240px height=44px label="Continue" />
    </Group>
</LiquidGlass>

variant supports "regular" and "clear". tint supplies an optional color; spacing controls grouping distance. interactive=true requests the interactive treatment where supported. A nested <LiquidGlass enabled=false> opts its descendants out of the inherited scope.

The Apple implementation uses native glass on macOS 26 and iOS/iPadOS 26 or later, with native visual-effect fallbacks on earlier supported OS versions. The details differ by platform; for example, the current UIKit implementation applies the interactive flag, while the macOS implementation does not provide the same response. Web retains ordinary controls without the Apple glass treatment. Give that fallback an intentional, readable composition rather than depending on glass for contrast.

Use examples/liquid-glass to explore the native effect and nested opt-outs on Apple targets. A successful web run checks the fallback, not the Apple appearance. The LiquidGlass API lists the scope's properties.

Lighting and other effects

Light-reactive vector materials and LightFrame are taught in Drawing. Their GPU lighting changes the drawn material; it does not automatically blur or refract all the native content behind it. Piet renders those materials unlit.

In Materials, scroll through the collection of imagined elements. Scrolling moves a light across surfaces with matte, glossy, metallic, emissive, and custom materials. Compare how their highlights respond, then open the Rust source to see the material parameters and scroll-driven light position. The lighting requires the WGPU renderer; Piet shows the unlit fills and strokes.

An experimental GPU subtree blur is available through GaussianBlur. Configurable blend modes and portable backdrop materials are not part of the current public toolkit. You can also build depth with offset shapes, gradients, transparency, and motion. Keep authored shadows and glows distinct from a sampled backdrop effect when explaining what an example demonstrates.

Check the finished composition

Start with the smallest composition that shows the intended relationship. Check it at rest, after resizing, during scrolling, and at intermediate animation values. Include a native control in the test if the finished interface mixes native and rendered content; an all-vector mockup does not exercise that boundary.

When something disappears, inspect source order and ancestor clips first, then Mask child order and source coverage, followed by subtree opacity and the current native/canvas limits. If the issue appears only while crossing a native surface or Scroller, compare the surface arrangement as well as the element's local geometry.

From the repository root, run a canonical testbed:

pax-cli run --path examples/fixtures/occlusion --target web

neon-opacity adds animated nested transparency and mixed controls. Verify the target and renderer you intend to ship: web, macOS, iOS, and iPadOS share the authoring model, but their native surfaces and effect implementations have the qualifications described here.

Read more

Continue with Scrolling and Viewports for content coordinates and scroll-driven composition. Revisit Layout for transforms and bounds, Drawing for paint and geometry, and Animation for changing those values over time. The Frame, Mask, and EventBlocker references list their public APIs.

Experimental shader containers

A userland primitive can filter its rendered descendants with a sequence of WGSL fragment passes. pax_std::shaders provides GaussianBlur and Impressionist, also exported by pax_std::* and pax_kit::*. Both use the same public renderer extension API available to your own primitives. The shader-studio example imports these standard-library components.

Shader containers are opt-in. Enable the shaders Cargo feature on your existing pax-kit dependency (keep any existing path and other features):

[dependencies]
pax-kit = { version = "0.39.0", features = ["shaders"] }

This enables both the standard-library effects and the generic renderer support for custom effects. Applications using pax-std directly can enable its feature of the same name. Existing applications using shader containers must add this feature before rebuilding. shader-studio already enables it.

The feature is off by default in both debug and release builds. Without it, shader components, runtime hooks, capture/compositing code, and their runtime WGSL validation path are excluded. On web, this also avoids embedding the Naga WGSL parser/validator in the application Wasm. Ordinary drawing, opacity, and masks remain available. Cargo features are additive: if any dependency enables shaders, the support is included even when no effect is mounted. Template usage does not automatically enable the feature. When enabled, effect textures and pipelines are still allocated only when needed.

<Frame width=420px height=280px corner_radius=18>
    <GaussianBlur width=100% height=100% radius=8.0>
        <Ellipse x=40% y=40% width=120px height=120px fill=CYAN />
        <Rectangle width=100% height=100% fill=rgb(20,31,32) />
    </GaussianBlur>
</Frame>

This experimental slice captures vectors and renderer-backed Image nodes. Native Text, NativeImage, controls, native materials, descendant Scrollers, and unclippable descendants cannot be captured; unsupported content produces a diagnostic. A shader container may live inside a Scroller. Effects inside detached mask sources are also unsupported. Use the WGPU backend; the legacy Piet backend does not implement this API. The first target is web WebGPU, with Metal pixel tests covering the shared renderer.

Clips and opacity inside a shader container become part of its source image. Clips outside the container apply to the filtered output, and the container's own opacity applies after filtering. Nested containers run from the inside out. Filters may expand beyond layout bounds by their declared sampling support; an enclosing Frame clips that expansion. Layout and hit testing continue to use the original tree.

WGPU reuses capture textures when their pixel dimensions stay the same, including when only their origin changes. A shader container also reuses its pass textures and bindings when its coordinate transform changes without replacing its source texture or shader program. This avoids allocation churn; transformed or changed content still needs rendering. Resizing the capture can allocate new textures. The capture envelope includes descendant drawing bounds and sampling support, so a moving child that extends beyond the container can change that envelope although the container's layout size is fixed.

For an inexpensive exit, stop both source animation and shader phase, then fade a stationary ancestor Group. WGPU can reuse the assembled pixels across that opacity-only fade. Continuing paint, mask, or transform changes adds work; opacity alone does not freeze those descendants. To animate a radial color field without moving its capture bounds, animate the gradient's center inside fixed geometry. Keep effects around the smallest useful subtree.

When filtered GPU content overlaps native content, native punch-through masks continue to use the original geometry and coverage opacity. Ordinary transforms, clips, and opacity still update those masks. Shader displacement, output alpha, and blurred edges do not change the native cutout. This deliberate approximation means native overlap need not match the filtered silhouette. Native pixels are not sampled by the shader.

Built-in effects

GaussianBlur.radius is Gaussian standard deviation in local logical pixels, clamped to 0–48. Zero is identity. Its reference mode performs full-resolution horizontal and vertical convolution truncated at three standard deviations. The default mode smoothly blends to quarter-resolution convolution between 6 and 10 logical pixels. That reduced-resolution result is an approximation; it trades fine detail for lower sampling cost at large radii. reference=true keeps the full-resolution result for comparison. Both operate on premultiplied RGBA in the renderer's capture color space, without a linear-light guarantee.

Impressionist.mode selects a procedural brush treatment. The enum is exported alongside the component as ImpressionistMode:

ModeTreatment
Basic (default)The original three scales of softly layered oval marks.
GauguinBroad directional sweeps, simplified color planes, and subtle bristles.
VanGoghEvolving curling flows with highlight/shadow ridges suggesting thick paint.
SignacFine, closely packed rectangular dabs interweaving pale and chromatic pigment.
CezanneVaried patches of parallel marks and nested arcs, with broken blue-violet contours.
SeuratSmaller, denser rounded points with restrained pale pigment.
<Impressionist width=100% height=100%
    mode=ImpressionistMode::Cezanne size=24.0 seed=17>
    <Ellipse x=50% y=38% width=110px height=110px fill=rgb(248,213,141)/>
    <Rectangle width=100% height=100% fill=rgb(77,112,118)/>
</Impressionist>

These are techniques inspired by the painters, not reproductions of their compositions. The simulated paint relief is color shading, not actual surface geometry or scene lighting. Pale pigment stays within source coverage; transparent content does not acquire an opaque white background.

The modes share one two-pass shader program. Signac and Seurat use the same pointillist brush function with preset values for spacing, proportions, roundness, position variation, direction, pigment, relief, and drift. Other brush families have distinct construction functions in that program. These internal preset controls are not yet individual public properties; mode, size, seed, and phase are the supported authoring controls.

seed fixes surface anchors and seeds the directional fields. phase is an absolute motion position, not elapsed time owned by the shader. It drives breathing and bounded mark movement around those anchors: broad slow drift in Gauguin, grouped shifts in Cezanne, and smaller motions in the pointillist modes. VanGogh also moves and deforms its swirl contours and ripples paint ridges along the strokes, so motion extends beyond the vortex centers. These motions work over a stationary source. They are procedural, not fluid simulation: they use no history texture or additional rendering pass. Mark colors and contour alignment still respond to changing source content. There is no motion tracking or previous-frame state. Stopping phase updates freezes brush motion while source updates continue. Identical source content, mode, seed, size, and phase give identical output. Changing mode switches the treatment immediately; it does not morph one brush family into another. Mode and phase changes share the existing pipeline and capture envelope, so they can reuse an unchanged source capture. size is an overall brush scale in local logical pixels, clamped to 4–32. Each mode applies its own proportions: a Seurat dot is smaller than a Gauguin sweep at the same setting. Size also scales directional patterns, including VanGogh's whorls. Changing it redistributes the marks rather than scaling the source scene. Its default value of zero resolves to the minimum size.

For an interactive control, declare pub brush_size: Property<f64> on your component, initialize it to 24.0 in its mount handler, and bind both a Slider and the effect to it. Keep the native Slider outside the filtered subtree:

<Slider width=100% height=28px value=bind:brush_size min=4.0 max=32.0 step=1.0/>
<Impressionist y=44px anchor_y=0% width=100% height=240px
    mode=ImpressionistMode::VanGogh size={self.brush_size} phase={self.phase}>
    <Rectangle width=100% height=100% fill=rgb(77,112,118)/>
</Impressionist>

Here phase is another Property<f64>; advance it from a tick handler or bind the effect's phase to a timeline to animate the flow. Leave it constant to keep the brush field still. For adjustable speed, accumulate phase += delta_seconds * speed: zero holds the current pose and negative speed reverses it. Change the accumulated phase's rate rather than multiplying total elapsed time by a changing speed, which would jump the current pose. The same phase value always reconstructs the same motion position, so ordinary Pax property transitions can ease or scrub it without simulation state.

See the API references for GaussianBlur and Impressionist.

These inputs are ordinary properties. Bind or animate them using existing expressions and motion, without an effect-specific clock. The example keeps brush phase and source animation independently controllable and includes a selector for comparing all six modes on the same scene. Its brush-size slider starts at 24 and remembers a separate value per mode for the current session; reloading resets those values. A separate motion-speed slider ranges from −5× to 5×, initially 1×, and affects brush phase only.

Run the canonical example from the repository root:

pax-cli run -p examples/fixtures/shader-studio -t web

For the Rust extension contract, see shader primitives.