Skip to content

Adding a modifier

A modifier is a wrapper component that merges one field into the modifier frame, a bit in the per-shape mask, and a branch in the shader. Twist is the worked example; a new modifier is the same six edits with its own bit.

Each shape carries a modifier block at the end of its record — modA, modB and modC, slots 52 to 63 — with the mask in modA.x and the parameters around it:

BitModifierParameter slot
0onionmodA.y thickness
1 · 2 · 3symmetry X · Y · Z—
4twistmodA.z k
5bendmodA.w k
6displacementmodB.x amount, modB.y frequency
7elongationmodC.xyz half-extents
8extrudemodB.z half-thickness
9revolvemodB.z radius

The next free bit is 10. modB.w and modC.w are free parameter floats. Glass shapes carry the block too, so a new modifier applies to glass as well.

export type TwistComponentProps = {
/** Twist amount in radians per unit Y. */
amount: number | PhotonValue<number>
children: JSXChild | (() => JSXChild)
}
export function Twist({ amount, children }: TwistComponentProps) {
const parent = useModifier()
const merged: ModifierState = { ...parent, twist: amount }
pushModifier(merged)
try { return runChildren(children) as Node } finally { popModifier() }
}

Read the enclosing frame, merge your field into it, push, render the children, pop. Merging with the parent is what lets modifiers nest. Export it from modifiers/index.ts.

2. The state field — ModifierState in core/types.ts

Section titled “2. The state field — ModifierState in core/types.ts”
twist?: number | PhotonValue<number>

3. The record — updateSDFBuffers in render/sdfEngine.ts

Section titled “3. The record — updateSDFBuffers in render/sdfEngine.ts”

Resolve the value, set the bit if it is active, and write the parameter into its slot:

if (mod.twist != null) {
const k = resolveValue(mod.twist, 0)
if (k !== 0) { modMask |= 0x10; twistK = k }
}
// …
data[idx+52] = modMask // modA.x
data[idx+54] = twistK // modA.z

A value of zero leaves the bit clear, so the shader takes no branch for it.

4. The shader — evalObjectSDF in sdf.wgsl

Section titled “4. The shader — evalObjectSDF in sdf.wgsl”

A domain modifier rewrites the local point before the primitive runs:

// Twist: rotate XZ by k*y. Spirals the shape around +Y.
//#region MODIFIER Twist
if ((modMask & 0x10u) != 0u) {
let k = o.modA.z;
let c = cos(k * pTr.y);
let s = sin(k * pTr.y);
pTr = vec3f(c * pTr.x - s * pTr.z, pTr.y, s * pTr.x + c * pTr.z);
}
//#endregion MODIFIER Twist

Order matters and follows IQ’s article: elongate → symmetry → bend → twist → primitive. A distance modifier — onion, displacement — rewrites d after the primitive instead, in the block below the dispatch.

If the modifier makes the shape bigger, the proxy cube has to grow or the shape clips at its silhouette. Twist has no term. The ones that do:

let onionExpand = o.modA.y * f32((modMaskV & 0x1u) != 0u);
let displExpand = o.modB.x * f32((modMaskV & 0x40u) != 0u);
let bendK = o.modA.w;
let bendExpand = max(max(env.x, env.y), env.z) * abs(bendK) * 0.5
* f32((modMaskV & 0x20u) != 0u);
let elongExpand = o.modC.xyz * f32((modMaskV & 0x80u) != 0u);

Each term is multiplied by its bit, so it contributes zero when the modifier is off. Add a term for yours if it can push the surface past the bare envelope.

6. The shake plugin — MODIFIER_NAME_TO_BITS

Section titled “6. The shake plugin — MODIFIER_NAME_TO_BITS”
Twist: [0x10],

The keys are the wrapper tags the plugin looks for. When <Twist appears nowhere in the app’s source, it removes the MODIFIER Twist region from step 4. It strips only the names in this table, so without an entry your branch always ships.

If the modifier distorts space non-uniformly, the distance it returns overestimates how far a ray can safely step, and the march can pass through a surface. Twist, bend and displace are non-metric; onion, symmetry and elongate are not. Document which yours is — see Modifiers — so users know to reduce the amount or raise maxSteps.