Skip to content

Modifiers

import { Onion, Symmetry, Elongate, Twist, Bend, Displace, Extrude, Revolve } from '../lib/fabric/modifiers'

A modifier is a wrapper. It merges one field into the modifier frame that every shape inside it reads at registration; updateSDFBuffers writes that frame into the last three slots of the shape’s record — the mask and the parameters, in modA to modC — and the shader applies whichever bits are set. Modifiers act in the shape’s local space — after its position and rotation have been removed — so they move with the shape.

The shader fixes the order. Nesting order in JSX does not change it.

elongate → symmetry → bend → twist → primitive → displace → onion

Extrude and Revolve belong to the primitive step: they are how a 2D shape gets its third dimension.

ComponentPropsActs onMetricGrows the envelope by
<Elongate>amountthe pointyesamount, per axis
<Symmetry>axesthe pointyesnothing
<Bend>amountthe pointnohalf the largest extent × amount
<Twist>amountthe pointnonothing
<Extrude>thickness2D shapes onlyyesthickness, along Z
<Revolve>radius2D shapes onlyyesradius, across XZ
<Displace>amount, frequencythe distancenoamount
<Onion>thicknessthe distanceyesthickness

Every numeric prop takes a plain number, a PhotonValue, or a callable signal; updateSDFBuffers resolves it each frame, and a PhotonValue in a modifier prop dirties the frame like one on the shape itself. A value of 0 leaves the modifier’s bit clear, so an inactive modifier costs no shader branch.

PropTypeMeaning
amountnumber, [x, y, z] or { x, y, z }Half-extent to stretch by, per axis. One number stretches all three.

Pulls the primitive’s centre apart into a segment, a rectangle or a box of that half-extent, and evaluates the primitive around it:

let h = o.modC.xyz;
pTr = pTr - clamp(pTr, -h, h);

A sphere elongated by [1, 0, 0] is a capsule 2 units long along X. From FabricCanvas.tsx, a puck stretched into a pill:

<Elongate amount={[0.5, 0, 0]}>
<Cylinder position={[0, -3, 0]} radius={1} height={0.2} color="#ffb050" material={{ ambient: 0.5, shader: 'lambert' }} />
</Elongate>
PropTypeMeaning
axes'x', 'y', 'z' or any combination — 'xy', 'xyz', in any orderThe local axes to mirror across. Case-insensitive, duplicates ignored. A plain string, not reactive.

Folds the local point, one abs per listed axis, before the primitive runs:

if ((modMask & 0x2u) != 0u) { pTr.x = abs(pTr.x); }
if ((modMask & 0x4u) != 0u) { pTr.y = abs(pTr.y); }
if ((modMask & 0x8u) != 0u) { pTr.z = abs(pTr.z); }

Because the fold is in the shape’s own space, it mirrors the positive half of the shape onto the negative half. A primitive already symmetric about its centre — a sphere, a box — looks the same. It shows on the ones that are not: a <CutSphere> under axes="y" is cut at both +cutHeight and -cutHeight.

<Symmetry axes="y">
<CutSphere radius={1} cutHeight={0.5} />
</Symmetry>

Nested Symmetry wrappers accumulate axes; an inner one cannot un-mirror an outer one. The fold never enlarges the shape, so the envelope does not grow.

PropTypeMeaning
amountnumber or PhotonValue<number>Radians per unit of local X. 0.3–1.0 is a gentle curl on a unit-sized shape.

Rotates the local XY plane by amount × x, so the further along X, the more the point turns — the shape curves into an arc around +Z:

let k = o.modA.w;
let c = cos(k * pTr.x);
let s = sin(k * pTr.x);
pTr = vec3f(c * pTr.x - s * pTr.y, s * pTr.x + c * pTr.y, pTr.z);

The arc’s ends rise past the straight shape’s bounds, so the proxy cube grows by half the largest extent × amount. From FabricCanvas.tsx:

<Bend amount={0.4}>
<Box position={[-1, -3, 0]} size={{ x: 1, y: 0.4, z: 0.4 }} color="#50d0ff" material={{ ambient: 0.5, shader: 'lambert' }} />
</Bend>
PropTypeMeaning
amountnumber or PhotonValue<number>Radians per unit of local Y. Positive spirals counter-clockwise looking down +Y. 0.3–1.5 on a unit-sized shape.

Rotates the local XZ plane by amount × y:

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);

Twist adds no envelope term; the proxy cube stays at the untwisted shape’s extents. From FabricCanvas.tsx:

<Twist amount={1}>
<Box position={[-3, -3, 0]} size={{ x: 0.4, y: 1, z: 0.4 }} color="#ff8050" material={{ ambient: 0.5, shader: 'lambert' }} />
</Twist>
PropTypeMeaning
thicknessnumber or PhotonValue<number>Half-thickness along local Z. A 2D shape extruded by 0.5 is 1 unit deep.

Only Circle2D, Rect2D and RoundedRect2D respond; a 3D primitive ignores it. The 2D distance is taken in the local XY plane and combined with the Z slab:

let h = select(0.01, opParam, isExtrude);
let w = vec2f(d2, abs(pTr.z) - h);
d = min(max(w.x, w.y), 0.0) + length(max(w, vec2f(0.0)));

That 0.01 is what a 2D shape gets with neither Extrude nor Revolve: a wafer, so it is still visible. From FabricCanvas.tsx:

<Extrude thickness={0.5}>
<Circle2D position={[-4, -3, 0]} radius={0.5} color="#ff8050" material={{ ambient: 0.5, shader: 'lambert' }} />
</Extrude>
<Extrude thickness={0.4}>
<RoundedRect2D position={[1, -2, 0]} size={{ x: 1, y: 1.8 }} roundness={0.15} color="#80ff50" material={{ ambient: 0.5, shader: 'lambert' }} />
</Extrude>

The first is a cylinder of radius 0.5, one unit deep.

PropTypeMeaning
radiusnumber or PhotonValue<number>Distance from the local +Y axis at which the 2D profile is swept.

The profile is evaluated at (distance from the Y axis − radius, y), which is already a correct 3D distance:

p2 = vec2f(length(pTr.xz) - opParam, pTr.y);

A Circle2D of radius r revolved by R is a torus of major radius R and minor radius r; a Rect2D becomes a ring with a rectangular section. The envelope grows to radius plus the 2D extent across XZ. Revolve wins over Extrude whatever the nesting order — updateSDFBuffers clears the extrude bit when it sets revolve. From FabricCanvas.tsx:

<Revolve radius={0.7}>
<Circle2D position={[-2, -3, 0]} radius={0.3} color="#ffb050" material={{ ambient: 0.5, shader: 'lambert' }} />
</Revolve>
<Revolve radius={0.7}>
<Rect2D position={[2, -2, 0]} size={{ x: 0.2, y: 0.4 }} color="#a050ff" material={{ ambient: 0.5, shader: 'lambert' }} />
</Revolve>
PropTypeDefaultMeaning
amountnumber or PhotonValue<number>requiredAmplitude of the distance perturbation. 0.02–0.15 reads as surface bumps.
frequencynumber or PhotonValue<number>8Waves per local unit. Higher is finer.

Adds a sin³ term to the distance, sampled at the already-modified local point, so the bumps follow a twist or bend:

let bump = sin(freq * pTr.x) * sin(freq * pTr.y) * sin(freq * pTr.z);
d = d + bump * amp;

The surface can move out by amount, so the envelope grows by amount. Live in FabricCanvas.tsx, inside an <Effect> and a <Rig drag>:

<Displace amount={0.04} frequency={12}>
<Sphere position={[1, -3, 0]} radius={1} color="#a050ff" material={{ ambient: 0.5, shader: 'lambert' }} />
</Displace>
PropTypeMeaning
thicknessnumber or PhotonValue<number>Half the wall: the shell extends thickness either side of the original surface, so the wall is 2 × thickness. 0 disables.

Applied last, so it wraps a displaced surface too:

d = abs(d) - o.modA.y;

A <Sphere radius={1}> under <Onion thickness={0.05}> is a hollow sphere with a 0.1 wall. The outer surface moves out by thickness, so the envelope grows by that much. A closed shell looks solid from outside; the wall shows where the primitive is open — a CutSphere, a SolidAngle.

<Onion thickness={0.05}>
<CutSphere radius={1} cutHeight={0.3} />
</Onion>
  • A glass sphere’s exit. Glass takes modifiers like any shape, and its surface march follows them. Under refraction: 'volume' a sphere’s exit is solved from its bare radius, so the modifier shapes the front surface only.
  • Pointer picking. pickShape marches the unmodified primitive on the CPU. A twisted, bent, displaced or hollowed shape is hit-tested as its plain self; only Revolve widens the test, by its radius.
  • autoCull. The CPU bounding test does not evaluate modifiers either; it adds a fixed 0.5 margin to cover their growth.

Each wrapper spreads the enclosing frame and overwrites its own field, so different modifiers combine, and an inner wrapper of the same modifier replaces the outer value. Symmetry is the exception: its axes OR together.

<Symmetry axes="x">
<Twist amount={0.8}>
<Box size={{ x: 0.4, y: 1, z: 0.4 }} />
</Twist>
</Symmetry>

Bend, Twist and Displace distort space, so the distance they return can overstate how far a ray may safely step, and the march can pass through a surface — missing or shimmering surfaces at grazing angles. Reduce the amount, raise maxSteps, or lower stepRelaxation on <Scene>.

The shake plugin removes a modifier’s shader branch when its wrapper tag — <Twist, <Displace — appears nowhere in the app’s source. Extrude and Revolve live inside the 2D block, which leaves with the 2D shapes. Adding a modifier shows every file a modifier lives in.