Primitives

A component gives a reusable interface its own template, properties, and actions. A primitive is a more advanced alternative: it implements a template element directly in Rust through Pax's runtime interfaces. This gives its author control over lower-level behavior such as drawing, child expansion, and integration with a platform surface.

You already use primitives when you place elements such as Ellipse, Group, and Text in a template. Their standard-library implementations connect those tags to the renderer and runtime. You can use the same extension point to build a new kind of element in your own project.

Components and primitives

From the caller's perspective, both can appear as template tags and expose Property<T> inputs. Their implementations differ:

KindImplementation
Template-backed componentA #[pax] type with #[file(...)] or #[inlined(...)]; its template composes other nodes.
PrimitiveA #[pax] type with #[primitive("...")]; a Rust InstanceNode implements its runtime behavior, with no attached .pax template.
Data typeA #[pax] type without a template or primitive declaration; it describes values such as the NoteEntry record.

For application UI, begin with components and composition. A reusable illustration can combine Path and the existing drawing elements; a new route presentation can use a custom route branch. A primitive becomes useful when the capability needs runtime hooks those elements do not expose: a specialized drawing operation, a new container behavior, or a platform integration.

That additional control comes with responsibility for rendering, lifecycle, and target-specific behavior. Choose it for the capability you need; changing a component into a primitive does not automatically improve performance.

This chapter assumes familiarity with Rust, properties, and component composition. It starts with a minimal registration scaffold, then explains the obligations of a working primitive and points to implementations in the engine and standard library.

Authoring primitives

Declare and connect a runtime node

The extension point is available in userland. The compiler generates a factory that calls your implementation through the import path in #[primitive(...)]; adding a node does not require an enum entry in pax-std.

There are two Rust types to understand:

  • The #[pax] type declares the properties visible to authors.
  • Its InstanceNode implementation supplies construction and runtime hooks. A shared BaseInstance carries the compiler-provided configuration.

The following scaffold establishes that connection. It deliberately has no drawing or platform side effects yet. Create src/runtime_marker.rs:

#![allow(unused)]
fn main() {
use pax_kit::*;
use pax_kit::pax_engine::api::Layer;
use pax_kit::pax_engine::pax_runtime::{
    BaseInstance, ExpandedNode, InstanceFlags, InstanceNode, InstantiationArgs,
};
use std::rc::Rc;

#[pax]
#[primitive("crate::runtime_marker::RuntimeMarkerInstance")]
pub struct RuntimeMarker {
    pub value: Property<f64>,
}

pub struct RuntimeMarkerInstance {
    base: BaseInstance,
}

impl InstanceNode for RuntimeMarkerInstance {
    fn instantiate(args: InstantiationArgs) -> Rc<Self> {
        Rc::new(Self {
            base: BaseInstance::new(args, InstanceFlags {
                invisible_to_slot: false,
                invisible_to_raycasting: true,
                layer: Layer::DontCare,
                is_component: false,
                is_slot: false,
            }),
        })
    }

    fn base(&self) -> &BaseInstance {
        &self.base
    }

    fn resolve_debug(
        &self,
        f: &mut std::fmt::Formatter,
        _expanded_node: Option<&ExpandedNode>,
    ) -> std::fmt::Result {
        f.debug_struct("RuntimeMarker").finish()
    }
}
}

The three methods shown are required by InstanceNode. The flags describe a non-drawing node that does not intercept pointer hit testing. A drawing primitive would choose Layer::Canvas and provide rendering behavior; a native surface has a different layer and platform-message lifecycle.

In lib.rs, add the module and expose its author-facing type:

#![allow(unused)]
fn main() {
pub mod runtime_marker;
pub use runtime_marker::RuntimeMarker;
}

It can now be instantiated in a template:

<RuntimeMarker value=0.5 />

It occupies a runtime node but paints nothing. Use this as a registration scaffold, then implement the specific hooks your capability needs. The crate::... path above refers to this application's Rust module. A primitive distributed in a library needs a path accessible from the consuming application's generated code; the standard library uses paths such as pax_std::drawing::ellipse::EllipseInstance.

Implement behavior at the right level

An InstanceNode can serve several expanded occurrences, for example when a template node is repeated. Each occurrence has an ExpandedNode with its own properties, geometry, children, and lifecycle state. Keep per-occurrence state there, or in state captured by an effect attached to that occurrence. Putting a mutable per-item value directly on the shared instance can make repeated items interfere with one another.

Runtime hooks receive the expanded node. Access its typed properties with expanded_node.with_properties_unwrapped(|properties: &mut RuntimeMarker| { ... }), and use transform_and_bounds for its resolved geometry. This is lower-level access than a component handler's NodeContext; keep ordinary application actions in event handlers.

Inside reactive effects and rendering hooks, read a shared component field through its local view, for example properties.fill.local().get(). Declare its local dependency with properties.fill.untyped(). This resolves a dirty binding in the current graph settlement; a raw shared get() only returns the latest published snapshot and may precede that binding's evaluation. Local fields use LocalProperty and can be read directly. Typed node-property access enters the owning graph, including for coverage and hit-testing hooks.

The main implementation responsibilities depend on the capability:

  • Reactive work: bind an effect in handle_mount to the properties and geometry it reads. Standard drawing primitives use changed_listener to mark their retained drawing dirty. Reserve update for work that cannot be driven by those dependencies; it requires opting in through requires_non_reactive_update.
  • Canvas drawing: implement render through RenderContext. Follow the retained-node begin/end protocol, use the correct surface-local transform, register the node's opacity scopes, and invalidate drawing when its inputs change. begin_bounded_canvas_node supplies paint_opacity: use that value for drawing so a backend that composes subtrees does not apply ancestor opacity twice. The current EllipseInstance demonstrates begin_bounded_canvas_node and clearing the dirty flag only after end_node succeeds. Unbounded geometry needs suitable coverage bounds.
  • Coverage and interaction: keep coverage paths, coverage opacity, and hit testing consistent with what the node draws. These hooks affect masks, native/canvas ordering, and pointer selection. The default hit test uses layout bounds; unusual shapes may need ray_cast_test.
  • Lighting resources: return true from has_scene_lighting() when the primitive implements resolve_scene_light or resolve_scene_ambient_light. This capability stays true even when the resource's enabled property is false. Existing custom lighting primitives must add this opt-in; resolvers alone no longer register a contributor. Follow LightSourceInstance's reactive invalidation so changed light values redraw affected canvases.
  • Children and cleanup: a container must manage child expansion and scope; overriding mount behavior also means preserving the needed child setup. Release subscriptions, resources, and native surfaces on unmount. Respect the received-versus-projected child model for container content.

These runtime interfaces evolve with the engine. Work from the implementation in the Pax version your project uses and test the capability on each backend you intend to support. Exercise repeated instances, reactive updates, resize, transforms, opacity, clipping, and removal—not just the first rendered frame. Check both debug and release builds.

Native integration and engine changes

A userland primitive can use the existing rendering and runtime interfaces. A completely new native widget may also need message types, platform-side creation/update/deletion handlers, event serialization, and accessibility behavior. Setting Layer::Native alone does not create that support.

That work can span pax-message, the web interface, and the Swift interfaces for macOS, iOS, and iPadOS. New renderer operations or template/control-flow syntax similarly need engine or compiler support. Keep the supported-target boundary explicit.

When adding data that must cross the compiler/runtime boundary, update the release representation and its round-trip tests too. A primitive's normal property declaration participates in generated descriptors; adding a new manifest field or value form entails a broader audit. See How Pax Runs for the execution model and the maintainer appendix for cartridge details.

Source examples to study

Read these alongside the code in your checkout:

The runtime API reference is useful for orientation. The source links follow the repository's development branch; use the corresponding files at your dependency's release when implementing a primitive.

Read more

Components and Composition covers reusable template-backed UI, slots, and shared state. Drawing and Styling introduces the existing drawing elements, and How Pax Runs explains the runtime and rendering model. For the lower-level interface, continue with the runtime API reference and the source examples above.

Shader primitives

Enable the shaders Cargo feature on pax-kit to use or define shader primitives. A custom primitive library depending on pax-engine directly can enable pax-engine/shaders without depending on pax-std. The lower-level pax-runtime, pax-gpu, and pax-runtime-api crates expose the same feature; enabling it on the engine or runtime propagates it to the required layers. See enabling shader containers for the dependency declaration and feature-off behavior.

InstanceNode::shader_effect is an experimental extension point for a GPU-only subtree filter. Return Some(ShaderEffect) from a primitive with Layer::Canvas. Keep returning Some throughout the node's mounted lifetime; use an identity pass or parameter value to disable filtering. The renderer captures its ordinary descendants and replaces their GPU presentation with the final pass output. The original nodes remain responsible for native coverage and hit testing.

The standard-library implementations are in pax-std/src/shaders/gaussian_blur.rs and pax-std/src/shaders/impressionist.rs, with WGSL alongside them. They use the same public extension point available to application-defined primitives; the renderer has no effect-specific implementation or registration. The default mount hook expands children and calls bind_shader_effect to subscribe the primitive's typed properties, geometry, child list, and opacity. If you override mount, perform both steps yourself. Shader-only typed properties should return false from property_requires_occlusion_recompute; common geometry and opacity remain tracked independently.

A ShaderProgram is an immutable Rust resource containing 1–8 ordered ShaderPass values. Each pass declares its WGSL source, diagnostic label, two inputs (the captured subtree or an earlier pass), a resolution divisor of 1, 2, 4, 8, or 16, and up to 32 typed parameters. Parameters support Float, Uint, Vec2, and Vec4; each occupies an explicit, zero-padded 16-byte uniform slot. ShaderEffect.parameters supplies one matching list of values per pass. Non-finite floats, mismatched schemas, invalid identifiers, and forward/cyclic pass references are rejected. WGSL is parsed and validated with the pinned Naga version, with the source label included in diagnostics.

Implement this function in each pass:

fn effect(p: vec2<f32>) -> vec4<f32> {
    return sample_input0(p) * parameters.tint;
}

Here tint would be a declared Vec4 parameter. Coordinates are local logical pixels. sample_input0(p) and sample_input1(p) sample the declared inputs, returning transparent outside their capture domain. input0_pixel_size() reports the local-axis texel spacing, including transform scale and DPR. The engine supplies entry points, bind groups, and attachments. User code cannot add entry points or GPU resources. Intermediate textures use the surface color format; the final output must be premultiplied RGBA. There are no compute passes, history textures, external texture bindings, or raw-device callbacks in this slice.

ShaderEffect.outset is the maximum absolute local sampling displacement and output expansion on either axis for the whole recipe. Include support from consecutive passes. Keep it stable during parameter animation. Underdeclaring it can clip output or create tile seams; overdeclaring it increases capture memory and work. Nested support adds along the capture chain. Tile gutters preserve samples across edges, within the adapter's maximum texture dimensions. Output textures have a 256 MiB budget per physical tile; exceeding the limit produces a diagnostic. Visible attachments and source captures add to that output budget.

The renderer caches source pixels separately from final output. Parameter-only changes reuse source pixels, shader pipelines, and same-sized output textures. Changes to source pixels, source-side clips, inner opacity, lighting, or nested output invalidate the relevant captures. Removed effects release their cached outputs. Lighting changes invalidate a shader source only when its retained vector draws receive the changed lighting; excluded and unlit sources keep their cached pixels. ResourceChurnStats exposes effect source renders/cache hits, pipeline compilations, texture allocations/bytes, pass counts, and rendered pixel counts. These counters measure submitted work and allocations, not GPU execution time.

Recipes live in compiled Rust, while their public inputs use normal property serialization. No effect program or device handle enters the manifest or baked program format. User primitives follow the existing generated factory path in both debug cartridges and baked release cartridges.

Read shader container composition for clipping, native overlap, and the current target/content restrictions.