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:
| Kind | Implementation |
|---|---|
| Template-backed component | A #[pax] type with #[file(...)] or #[inlined(...)]; its template composes other nodes. |
| Primitive | A #[pax] type with #[primitive("...")]; a Rust InstanceNode implements its runtime behavior, with no attached .pax template. |
| Data type | A #[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
InstanceNodeimplementation supplies construction and runtime hooks. A sharedBaseInstancecarries 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_mountto the properties and geometry it reads. Standard drawing primitives usechanged_listenerto mark their retained drawing dirty. Reserveupdatefor work that cannot be driven by those dependencies; it requires opting in throughrequires_non_reactive_update. - Canvas drawing: implement
renderthroughRenderContext. 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_nodesuppliespaint_opacity: use that value for drawing so a backend that composes subtrees does not apply ancestor opacity twice. The currentEllipseInstancedemonstratesbegin_bounded_canvas_nodeand clearing the dirty flag only afterend_nodesucceeds. 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
truefromhas_scene_lighting()when the primitive implementsresolve_scene_lightorresolve_scene_ambient_light. This capability stays true even when the resource'senabledproperty is false. Existing custom lighting primitives must add this opt-in; resolvers alone no longer register a contributor. FollowLightSourceInstance'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:
Ellipse/EllipseInstance— a bounded canvas primitive: properties, dirty tracking, geometry, material-aware drawing, and coverage.Group/GroupInstance— child expansion, layout measurement, and conditional native-surface integration.Text/TextInstance— native create/update/delete messages and text measurement. Follow the message to the target interface for the platform half.InstanceNode,BaseInstance, andInstantiationArgs— the runtime contract and default hook implementations.ComponentInstance— the engine implementation behind ordinary template-backed components, including scope and slot projection.
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.