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.
Order of application
Section titled “Order of application”The shader fixes the order. Nesting order in JSX does not change it.
elongate → symmetry → bend → twist → primitive → displace → onionExtrude and Revolve belong to the primitive step: they are how a 2D shape
gets its third dimension.
| Component | Props | Acts on | Metric | Grows the envelope by |
|---|---|---|---|---|
<Elongate> | amount | the point | yes | amount, per axis |
<Symmetry> | axes | the point | yes | nothing |
<Bend> | amount | the point | no | half the largest extent × amount |
<Twist> | amount | the point | no | nothing |
<Extrude> | thickness | 2D shapes only | yes | thickness, along Z |
<Revolve> | radius | 2D shapes only | yes | radius, across XZ |
<Displace> | amount, frequency | the distance | no | amount |
<Onion> | thickness | the distance | yes | thickness |
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.
<Elongate amount>
Section titled “<Elongate amount>”| Prop | Type | Meaning |
|---|---|---|
amount | number, [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><Symmetry axes>
Section titled “<Symmetry axes>”| Prop | Type | Meaning |
|---|---|---|
axes | 'x', 'y', 'z' or any combination — 'xy', 'xyz', in any order | The 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.
<Bend amount>
Section titled “<Bend amount>”| Prop | Type | Meaning |
|---|---|---|
amount | number 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><Twist amount>
Section titled “<Twist amount>”| Prop | Type | Meaning |
|---|---|---|
amount | number 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><Extrude thickness>
Section titled “<Extrude thickness>”| Prop | Type | Meaning |
|---|---|---|
thickness | number 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.
<Revolve radius>
Section titled “<Revolve radius>”| Prop | Type | Meaning |
|---|---|---|
radius | number 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><Displace amount frequency>
Section titled “<Displace amount frequency>”| Prop | Type | Default | Meaning |
|---|---|---|---|
amount | number or PhotonValue<number> | required | Amplitude of the distance perturbation. 0.02–0.15 reads as surface bumps. |
frequency | number or PhotonValue<number> | 8 | Waves 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><Onion thickness>
Section titled “<Onion thickness>”| Prop | Type | Meaning |
|---|---|---|
thickness | number 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>What modifiers do not reach
Section titled “What modifiers do not reach”- 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.
pickShapemarches the unmodified primitive on the CPU. A twisted, bent, displaced or hollowed shape is hit-tested as its plain self; onlyRevolvewidens 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.
Nesting
Section titled “Nesting”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>Non-metric modifiers
Section titled “Non-metric modifiers”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>.
In the build
Section titled “In the build”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.