Status: normative, part of the format-v1.0.0 contract.
This document, together with effect-manifest-v1.md, is the normative rendering contract for
the screen effect manifest v1. It is self-contained: a renderer for screen effects needs only
these two documents. The format is tagged format-v1.0.0; until the first effect is published
and the SDKs' 1.0.0 ship, every change to v1 is part of that tag (the tag is moved forward and
the SDKs re-pin it).
effect-manifest-v1.md defines the manifest's shape and validation. It does not say how a
renderer interprets the values. The iOS SDK, the Android SDK and the editor's web preview are
three independent renderers that must agree in golden tests, so every interpretation below is
pinned once, here.
Each rule carries one marker:
- [S] Settled by spec (§x) — already decided in the design spec
(
2026-09-28-ingevora-mirage-design.md); cited, not re-opened.
- [P] Default — no field change — a rule on top of the existing fields.
Layer types: 1 particles · 2 full-screen sprite · 3 motion · 4 image · 5
sprite. Event sources: 1 lifecycle · 2 host. Keyframe animation: §E10.
#E1. Coordinates
E1.1 [S] §3.3, §4.2 — A screen effect draws in an overlay view the host places above its UI.
E1.2 [P] Overlay axes: origin top-left of the overlay's bounds, +x right, +y down, in the
overlay's points/dp (not device pixels). origin.x and position.x are fractions of the
overlay width, origin.y and position.y of its height (0,0 top-left; 1,1
bottom-right).
Rationale: the author places emitters and images relative to the screen edges.
E1.3 [P] All other lengths — particle speed (per second), gravity (per second²),
sizeScale, image and sprite scale, shake amplitude — are multiples of
S = min(overlay width, overlay height).
Rationale: one isotropic unit keeps directionDeg and spreadDeg true angles on any aspect
ratio; the short side keeps sizes stable across phones.
E1.4 [P] Gravity is a constant acceleration along +y (down) in overlay axes, in
S/s²; negative values accelerate upward.
Rationale: gravity is a world direction; the confetti scenario (speed 0.8 up, gravity 1)
then rises 0.32 S and falls back, which is its intent.
E1.5 [P] Rotation: degrees, positive = clockwise on screen (y-down), 0 = the asset
upright as authored. Applies to rotationDeg and directionDeg. directionDeg 0 = +x (right),
90 = down, −90 = up (the confetti scenario's −90 fires upward).
Rationale: consistent with the y-down overlay axes; matches CSS rotate() for the web preview.
#E2. Sprite sheets (types 2 and 5)
E2.1 [P] Frames are laid out row-major from the top-left: columns = floor(sheetWidth / frameWidth), rows = floor(sheetHeight / frameHeight); frame k is the cell at column
k mod columns, row floor(k / columns). Leftover pixels on the right/bottom edges are
ignored.
E2.2 [P] frameCount > columns × rows is invalid, rejected where images are decoded: at
upload/publish by the API, and by the SDK's validate-before-cache (every image actually
decodes). Issue code asset.sheet_too_small (decode-time, not in the manifest rules, because
the manifest has no image dimensions).
E2.3 [P] Frame index during playback: k = floor(tPlay × fps / 1000) where tPlay is ms
since playback started. loop: true → k mod frameCount; loop: false → after
frameCount − 1, the layer follows sheet.endBehavior: 1 holds on the last frame · 2
hides (visible becomes false; a later show or play sprite makes it visible again, E2.7).
E2.4 [P] A sprite layer that is visible but not playing (for example one shown again after
its end-hide, E2.7) shows frame 0.
E2.5 [P] Sampling: bilinear, clamped to the frame's own cell (inset half a texel) so
neighbouring frames never bleed in; no mipmaps.
E2.6 [P] A type 2 sprite keeps its explicit fit, relative to the overlay and centred with
the frame's aspect preserved: contain (1) scales it to fit inside the overlay, cover (2)
scales it to cover the overlay and the overflow is cropped by the overlay.
Rationale: an animation must not distort.
E2.7 [P] When a non-looping sprite with endBehavior: 2 passes its last frame — visible or
not (a sprite hidden by an action keeps its clock, E6.2) — its playback stops and visible
becomes false. A later show makes it visible showing frame 0 without playing (E6.1 —
show sets visible only; E2.4); a later play sprite replays it from frame 0 (E6.3). The
end-hide is evaluated when the sprite clock advances at the start of a frame (E5.4), so a show
or play sprite on the frame the sprite ends wins.
#E3. Placed layers (types 4 and 5)
E3.1 [P] An image (type 4) and a sprite (type 5) each draw one textured quad:
- centre
C = (position.x × overlay width, position.y × overlay height) (E1.2);
- width
w = scale × S (E1.3); height h = w × image height / image width for type 4, and
w × frameHeight / frameWidth for type 5;
- rotated by
rotationDeg about C (E1.5);
- drawn with
opacity (E4.3), premultiplied like every quad (E4.2).
Worked example (scenario effect/placed-layers.json): on a 390 × 845 overlay (S = 390) an
image at position (0.5, 0.4), scale 0.5, 200 × 100 px has corners (97.5, 289.25),
(292.5, 289.25), (292.5, 386.75), (97.5, 386.75).
E3.2 [P] A type 5 sprite plays exactly like a type 2 sprite (E2.3, E2.4, E2.7): it starts at
startMs if visible at the start (E7.2), play sprite restarts it from frame 0 and implies show
(E6.3), and endBehavior applies when loop is false.
E3.3 [P] Shake and pulse (E8.1, E8.3) move and scale types 4 and 5 like every other
non-flash layer. Nothing else moves them in v1. Scenario effect/shake-pulse-placed.json pins it;
its description works every corner out by hand.
#E4. Draw order and blending
E4.1 [P] layers[] is ordered. Array order is back to front: index 0 is drawn first
(nearest the host app's UI, the back of the stack), the last layer on top.
E4.2 [P] Compositing is source-over with premultiplied alpha, in sRGB-encoded
space (no linear blending). PNGs are decoded as straight alpha and premultiplied once at load;
every asset is treated as sRGB and embedded colour profiles / gamma chunks are ignored.
Rationale: matches HTML canvas (the editor preview) and is cheap on the oldest devices
(§4.4).
E4.3 [P] opacity (types 4, 5) multiplies the premultiplied source. Particles (type 1)
draw at 1; a full-screen sprite (type 2) draws at its animated opacity, which rests at 1 (E10.5);
a flash (type 3) draws with its own alpha (E8.2).
E4.4 [P] Asset colour/format: every asset is an 8-bit sRGB PNG (RGBA or RGB); 16-bit or
palette PNGs are expanded to 8-bit RGBA at decode. Texture dimensions ≤ 1024 are already
enforced at upload.
E4.5 [P] Within a particle layer, particles draw in emission order (oldest first), each as
an upright quad centred on its position, width sizeScale × S, height by the asset's aspect
ratio, opacity 1 for its whole life, removed when its age reaches lifetimeMs. No per-particle
rotation or fade in v1.
E4.6 [P] Several effect instances at once (overlapping plays) are independent: each has its
own clock, particles and cap; the later-started instance draws on top.
Overlap itself is [S] §2 ("Simultaneous presses: both play, overlapping") — generalised
here as a platform rule.
#E5. Triggers
E5.1 [P] Lifecycle: effect.started fires at effect time 0, on the first frame processed
after the effect starts; effect.ended at effect time durationMs (E7.3).
E5.2 [P] Host events are delivered to every running effect instance; they are applied
at the next rendered frame. Events arriving before an effect has started or after it has ended
are dropped, never queued. Events with no matching trigger are ignored.
E5.3 [P] When one event matches several triggers, triggers run in array order and actions
in array order within each, all before the frame renders; later actions win (show then hide
in the same frame = hidden).
E5.4 [P] Order within one frame. At effect time t:
- Clocks advance to
t, in this order:
a. Scheduled starts (E7.2): each type 2, 3 or 5 layer that was visible: true at start
and has not been started by an action begins once t ≥ startMs, with its clock at
startMs (not at t). A type 3 layer that is hidden at that moment does not begin, and
the schedule is spent (a hidden motion does not run, E6.2, E8.4). A type 2 or type 5
layer begins whether or not it is visible (its clock is independent of visibility, E6.2).
Autoplay clips (E10.3) begin the same way at their startMs, whether or not the layer
is visible, unless an action has already started them.
b. Sprite clocks, including the end behaviour (E2.7). Motion clocks: a motion whose
elapsed time has reached its durationMs stops (E8). Clip clocks: a non-looping clip
reaches its end at durationMs (E10.3).
c. Particle pools: continuous emission that was active at the end of the previous frame
(E6.6) emits every due t_k ≤ t, in order. Before each emission at t_k, the particles
whose age at t_k has reached lifetimeMs are removed, so the cap (E6.4, E9.2) is
evaluated at that instant. Then every particle whose age at t has reached lifetimeMs
is removed (E4.5). Emission instants and particle ages are whole microseconds, computed in
IEEE-754 double precision and rounded to the nearest integer with halves rounded up
(every value here is ≥ 0; e.g. JS Math.round, Swift .rounded(), Kotlin Math.round —
not round-half-even): the frame's T = round(t × 1000) (t in ms); T_0 is the T of the
frame on which emission started (step 4); T_k = T_0 + round((k × 1 000 000) / ratePerSecond) (multiply, then divide); a particle's birth B is T_k for continuous
emission and the frame's T for a burst or emit; t_k is due when T_k ≤ T; a
particle has reached its lifetime at instant X when X − B ≥ lifetimeMs × 1000; its
age in E6.8 is (T − B) / 1 000 000 seconds. Particles emitted for t_k < t spawn at
the origin evaluated at t_k (E10.5), with this frame's S.
- On the first frame only: the
burstCount of every type 1 layer that is visible at start
(E6.7), then the effect.started triggers (E5.1).
- Host events, in arrival order (E5.2) — on the first frame this step is empty: host
events that arrived before the first frame was processed are dropped.
- Emission start (E6.6): every particle layer whose continuous-emission conditions now hold
but which is not emitting starts emitting with
t_0 = t, so its k = 0 particle is emitted
now. (A hide stops emission and clears the pool when it is applied, E6.2; so show then
hide on one frame emits nothing and draws nothing from the stream, E6.9.)
- Draw from the resulting state, every layer at its values for
t (E10.1); motions
(E8.4) use the motions that are running and visible after step 4.
All actions of steps 2 and 3 apply to this frame's render, and later actions win across the
whole sequence; E5.3 still orders the triggers matching one event. Particles emitted by an
action (E6.4) or a burst are born at t; the burst of a layer that first becomes visible
through show or emit happens when that action is applied, before the emit's own count
(E6.7, E6.4).
Example: an effect.started action hides a startMs: 0 layer. Step 1a starts it at effect
time 0; step 2 then hides it. A type 3 motion stops (E6.2) and has no effect unless run again (a
later show alone does not restart it, E6.1). A type 2 or type 5 sprite keeps its clock running
from 0 and, when shown later, appears mid-animation (E6.2).
The durationMs cut (E7.3): the first frame whose time t ≥ durationMs runs none of the
steps above. It evaluates the effect.ended triggers, then the instance is torn down: that frame
and every later one draw nothing, and host events delivered with them are dropped (E5.2).
Rationale: one fixed order is the only way two renderers agree when a host event and a
scheduled start touch the same layer on the same frame; pinning emission and expiry to event
instants (t_k), not to frames, keeps particle pools identical at any frame rate.
#E6. Actions
E6.1 [P] show / hide are idempotent: they set visible and nothing else. show on a
visible layer and hide on a hidden layer do nothing.
E6.2 [P] hide on a particle layer clears its live particles and stops continuous
emission; hide on a motion layer stops the motion; hide on a sprite keeps its playback
clock running (visibility and playback are independent).
E6.3 [P] play sprite implies show, and restarts from frame 0 if already playing.
Rationale: playing an invisible sprite is never the intent; restart is what "play" means on
every media API.
E6.4 [P] emit particles implies show, then emits count particles at once. If fewer than
count slots are free under maxParticles, the excess is dropped — live particles are
never evicted.
Rationale: deterministic and allocation-free.
E6.5 [P] run motion implies show and restarts from 0 if already running.
E6.10 [P] play animation (action 6) implies show of its clip's layer and starts the clip
from 0 at that frame — restarting it if it is playing, replacing another clip playing on the
same layer, and cancelling the clip's own pending autoplay start (E10.3).
E6.6 [P] Continuous emission (type 1) runs at ratePerSecond while the layer is
visible. Emission times are t_k = k / ratePerSecond for k = 0, 1, 2, … measured from when
emission became active (re-started at 0 on each show), evaluated in whole microseconds as E5.4
(1c) specifies. ratePerSecond: 0 never emits continuously.
E6.7 [P] burstCount (type 1) = particles emitted at the instant the layer first becomes
active: effect time 0 if visible: true, otherwise at the first show/emit. Only once per
effect instance.
E6.8 [P] Particle motion is evaluated in closed form, never integrated:
p(t) = p0 + v0·t + ½·g·t² with t the particle's age in seconds, v0 = speed × (cos θ, sin θ) and g = (0, gravity), in S units. Every length — speed, gravity, sizeScale —
uses the S taken at emission, and the spawn point is the origin at the emission instant
(animated, E10.5) × the overlay size at emission. A resized overlay or a moving origin does not
move or resize live particles.
Rationale: identical positions on any frame rate.
E6.9 [P] Spread and randomness. θ = directionDeg − spreadDeg/2 + spreadDeg × r,
r ∈ [0, 1). r comes from a mulberry32 stream per layer per instance, seeded with the
32-bit FNV-1a hash of the UTF-8 string "<effectId>:<layerId>", one draw per emitted particle
in emission order (dropped particles draw nothing). Angle is the only random quantity in v1.
Rationale: without a pinned PRNG, particle layers can never pass a golden test.
#E7. Timing
E7.1 [P] Clock. Effect time = display time since the host's play call. Golden tests
supply timestamps directly: effect time is a step's timeMs minus that of the first step.
E7.2 [P] startMs (types 2, 3, 5) is measured from effect time 0, and applies only to
layers with visible: true at start, which begin automatically at startMs (not drawn
before). A layer started by an action (play sprite / run motion) starts at that action;
its startMs is ignored.
Rationale: the existing timing rules (timing.exceeds_duration) check startMs against the
effect timeline, so that is what it means.
E7.3 [P] Everything in an effect is cut at durationMs: an action-started motion or
sprite that would overrun stops there, and live particles vanish. At durationMs the engine
evaluates effect.ended triggers, then tears the instance down; nothing renders after, and the
host's "effect finished" callback (§4.2) fires. Actions on effect.ended therefore have no
visible result in v1 — the editor should warn about them.
Rationale: hosts key cooldowns to durationMs (§3.2); an effect must never outlive it.
E7.4 [P] A host stopping an effect early ends it immediately without firing
effect.ended.
E7.5 [S] §4.2 — The SDK does not transport effect events; the host moves them and calls
play on each device.
#E8. Motion (type 3)
Let τ = t / 1000 seconds since the motion started, D its durationMs, f = frequencyHz,
I = intensity, e = 1 − t / D a linear decay envelope.
E8.1 [P] Shake (1): displaces all other layers of the same effect instance —
types 1, 2, 4 and 5; a flash fill (E8.2) is excluded — by
d = I · e · 0.03 S · (sin(2π f τ), sin(2π · 0.8 f · τ + π/3)). At I = 1 the peak is 3 % of
the short side. The overlay cannot move the host's UI (§4.2 places it above the UI), so SDKs
may also expose d(t) to the host, but golden tests cover the overlay only. A flash fill
always covers the whole overlay and is never displaced or scaled.
E8.2 [P] Flash (2): draws, at its own array position, a full-overlay white fill with
alpha I · (0.5 − 0.5 cos(2π f τ)) — f flashes per second, each starting and ending at 0.
Rationale: frequencyHz then is the flash rate; the flashing-limit profile rule counts these
cycles in any one-second window, all flash motions together (rule-profiles-v1 §5).
E8.3 [P] Pulse (3): scales all other layers of the instance (types 1, 2, 4 and 5; not
the flash fill) about the overlay centre by 1 + 0.10 · I · e · (0.5 − 0.5 cos(2π f τ)). A
flash fill always covers the whole overlay and is never displaced or scaled.
E8.4 [P] Several running motions combine: shake displacements add; pulse scales multiply;
the transform applies as scale then translate. A hidden motion has no effect.
#E9. Other gaps a renderer needs pinned
E9.1 [P] Initial state. At start every layer takes its manifest visible; motions,
sprites, emitters and clips are idle except as E6.6, E6.7, E7.2 and E10.3 start them.
E9.2 [P] Particle cap is per layer (maxParticles) per instance; the cross-layer 300
total is a validation rule (particles.too_many), not a runtime pool.
E9.3 [P] Forward path for fields. Because parsing is strict, a field added after the tag
must come with a field-level engine level (an EFFECT_ENGINE_LEVEL_BY_FIELD table alongside
EFFECT_ENGINE_LEVEL_BY_LAYER_TYPE and EFFECT_ENGINE_LEVEL_BY_EVENT_SOURCE, feeding the
computation of minEngineVersion), so an older SDK reports "engine too old" instead of "invalid
manifest". No manifest change — a rule for how the editor computes minEngineVersion.
E9.4 [P] Rendering is per frame and stateless apart from sprite clocks, motion clocks,
pending startMs starts, particle pools with their emission state, PRNG streams, and each
layer's playing clip with its start time and the pending autoplay starts (E10.6) — the full list
of per-instance state an SDK keeps.
#E10. Keyframe animation
A clip (animations[], effect-manifest-v1.md Animations) animates one layer. Its tracks
hold numeric keys; a playing clip's values replace the layer's resting values. Face lenses use
E10.1–E10.3 as they stand, on lens time (rendering-v1.md §11).
E10.1 [P] Value at a moment. For a track at clip time c (ms): before the first key, the
first key's value; after the last key, the last key's value; between keys a and b,
u = (c − a.atMs) / (b.atMs − a.atMs) and value = a.value + (b.value − a.value) · ease_a(u),
where ease_a is key a's easing. A property with no track in the playing clip — or on a layer
with no playing clip — takes its resting value: the layer's own field (position.x,
position.y, scale, rotationDeg, opacity, or origin.x / origin.y for particles), and 1
for a full-screen sprite's opacity and zoom. Rotation interpolates the raw degrees (no wrap).
Easings may overshoot (back): when a value is used, an opacity is clamped to [0, 1] and a scale or
zoom to ≥ 0; x, y and rotation are used as they are.
E10.2 [P] Easing, u ∈ [0, 1]:
| Easing |
ease(u) |
| 1 linear |
u |
| 2 ease in |
u² |
| 3 ease out |
1 − (1 − u)² |
| 4 ease in-out |
2u² for u < 0.5, else 1 − (−2u + 2)² / 2 |
| 5 hold |
0 (the value jumps at the next key) |
| 6 back |
1 + 2.70158 (u − 1)³ + 1.70158 (u − 1)² (overshoots, then settles) |
| 7 bounce |
with n = 7.5625, d = 2.75: n u² for u < 1/d; n (u − 1.5/d)² + 0.75 for u < 2/d; n (u − 2.25/d)² + 0.9375 for u < 2.5/d; else n (u − 2.625/d)² + 0.984375 |
E10.3 [P] Clip clock.
- An
autoplay clip starts at startMs (absent = 0) of effect time, with its clock at startMs
(not at the frame time), whether or not its layer is visible — a scheduled start, as E7.2.
play animation (E6.10) starts its clip at the frame time t (clock 0 at t) and cancels that
clip's pending autoplay start.
- The clock runs whether or not the layer is visible.
- Clip time:
c = elapsed, or elapsed mod durationMs when loop is true.
- A non-looping clip ends when
elapsed ≥ durationMs: with endBehavior 1 its values hold at
c = durationMs for the rest of the effect; with 2 the clip stops and the layer takes its resting
values.
- One clip per layer: starting a clip replaces the one playing on its layer; properties the
new clip does not animate return to rest at once.
E10.4 [P] Frame order (E5.4). Autoplay starts are scheduled starts (step 1a); clip ends
are clock advances (step 1b); play animation runs with the other actions (steps 2–3); drawing
(step 5) evaluates every layer's values at t.
E10.5 [P] What the values change.
- Images and positioned sprites (types 4, 5) draw at their animated
x, y, scale,
rotationDeg and opacity (E3.1).
- A full-screen sprite (type 2): its fitted quad (E2.6) is scaled about the overlay centre by the
animated zoom, and drawn with the animated opacity.
- Particles (type 1): a particle spawns at the origin evaluated at its own emission instant —
t_k for continuous emission (including catch-up, E5.4 (1c)), the frame time t for a burst or
an emit. Catch-up evaluates each t_k against the clips as they stood at t_k: plays of
earlier frames and autoplay starts scheduled at or before t_k count, and a clip that ends after
t_k still plays at t_k; this frame's actions come later. A particle never moves with the
origin after its emission (E6.8).
- Shake and pulse (E8.1, E8.3) apply after the animated values; the flash (E8.2) is unchanged.
- The
durationMs cut (E7.3) ends every clip with the effect.
Rationale: evaluating the origin at each emission instant keeps particle pools identical at any
frame rate, as E5.4's whole-microsecond instants do.
E10.6 [P] State. Per layer: the playing clip and its start time (the clip history back
to the previous frame, for E10.5's catch-up), and the pending autoplay starts.
#Golden tests
Golden scenarios live in packages/render/fixtures/scenarios/effect/*.json: confetti-shake
(particles under a shake, E8.1), continuous-emission-cap (whole-microsecond emission at the cap,
E5.4 (1c)), placed-layers (E3.1), shake-pulse-placed (E3.3), and for keyframes
animation-easings (E10.2), animation-clips (E10.3), animation-origin (E10.5) and
animation-under-shake (E10.5, E8.1). The "Scenario
fixtures" section of packages/render/README.md
describes the same file shape, which lens and screen effect scenarios share. Every renderer (the
editor preview and the iOS and Android SDKs) replays them and must agree.
A scenario file is { description, manifest, assets, seed, steps: [{ input, expect }] }:
manifest — a valid effect manifest v1.
assets — the pixel size of each image the manifest names: { "<asset name>": { "width": 256, "height": 128 } }.
seed — for each particle layer, fnv1a32("<effectId>:<layerId>") (E6.9), so a renderer can
check its hash before replaying.
steps — replayed in order on one instance. input is
{ kind: 2, timeMs, overlaySize, hostEvents }: kind is always 2 for a screen effect. The
effect renderer and the lens renderer in packages/render share one runtime, and kind: 2
tells it that this step overlays the effect on the host's UI, at overlaySize, instead of
drawing on a camera frame. overlaySize is { width, height } in points,
and hostEvents are the host events that arrived since the previous step (those given to the
first step arrived before the start and are dropped, E5.2). Effect time is timeMs minus the
first step's timeMs (E7.1).
expect — the step's draw list, back to front (E4.1), with numbers rounded to 3 decimals.
Effects use two draw commands and never emit lut:
{ op: 'quad', layerId, asset, frameIndex, src, corners, opacity } — src is the rectangle
of the asset to draw, in asset pixels (the whole image, or the sprite cell); corners are the
destination corners in overlay points, in the order top-left, top-right, bottom-right,
bottom-left, with rotation and the screen-motion transform applied; frameIndex is the sprite
cell number, or null for a layer that is not a sprite.
{ op: 'fill', layerId, color, alpha } — a full-overlay fill (a screen flash, E8.2); color
is sRGB-encoded RGB, each channel in [0, 1], and alpha is its opacity.
A renderer passes a step when every number in its draw list is within 0.002 of the expected
value and everything else is exact: op, layerId, asset, frameIndex (including null),
object keys and list lengths.