Skip to content

Adding a shape

Octahedron is type 10. Everything below is its real code, in the order you would add it. A new shape is the same seven edits with its own numbers.

1. The distance function — primitives/octahedron.wgsl

Section titled “1. The distance function — primitives/octahedron.wgsl”
// Octahedron — 8-faced diamond. Vertices at ±size on each axis.
fn sdOctahedron(p: vec3f, size: f32) -> f32 {
let pa = vec3f(abs(p.x), abs(p.y), abs(p.z));
let m = pa.x + pa.y + pa.z - size;
var q: vec3f;
if (3.0 * pa.x < m) { q = pa.xyz; }
else if (3.0 * pa.y < m) { q = pa.yzx; }
else if (3.0 * pa.z < m) { q = pa.zxy; }
else { return m * 0.57735027; }
let k = clamp(0.5 * (q.z - q.y + size), 0.0, size);
return length(vec3f(q.x, q.y - size + k, q.z - k));
}

One function: the point in the shape’s own frame, the shape’s parameters, the signed distance back. p arrives already local — the caller has subtracted the position and undone the rotation.

Add it to the include list in sdf.wgsl, inside a region named for its type ID:

//#region SDF_TYPE [10]
#include "./primitives/octahedron.wgsl"
//#endregion SDF_TYPE [10]

Prefer an exact distance. A lower bound works — ellipsoid and triangular prism are bounds — but it needs more march steps near grazing angles, and every scene that uses the shape pays for that.

2. The dispatch case — evalObjectSDF in sdf.wgsl

Section titled “2. The dispatch case — evalObjectSDF in sdf.wgsl”
//#region SDF_TYPE [10]
} else if (typeId < 10.5) {
// Type 10: Octahedron — size = (size, _, _)
d = sdOctahedron(pTr, o.size_type.x);
//#endregion SDF_TYPE [10]

The region opens before the else if and closes before the next case’s, so removing it leaves the chain balanced.

pTr is the local point after modifiers. o.size_type.xyz carries the three size fields and o.pos_rounding.w the rounding. The comment records what the three size fields mean for this type — that is the contract step 6 fills.

3D shapes are contiguous from 0; the next free is 22. 2D shapes are 30–32. Leave the gap, and do not reuse a retired ID.

The vertex shader draws a unit cube per shape and scales it to bound the shape, so the fragment shader runs over the right pixels:

} else if (o.size_type.w > 9.5 && o.size_type.w < 10.5) {
// Octahedron: vertices at ±size on each axis
env = o.size_type.xxx;
}

env is the half-extent on each axis. The shader inflates it afterwards for rounding, glow radius and modifier reach; you supply the bare shape.

Without a case the cube falls back to size + rounding. For an octahedron that happens to be right. For a torus, a capsule or a link — anything whose extent is bigger than its size fields — it is too small, and the shape draws with its silhouette cut off.

5. The grid envelope — assign_objects in grid.wgsl

Section titled “5. The grid envelope — assign_objects in grid.wgsl”
//#region SDF_TYPE [10]
} else if (typeId < 10.5) { // Octahedron
env = sz.xxx;
//#endregion SDF_TYPE [10]

Same value, different syntax. This one files the shape into the cells of the spatial grid. Without it the shape falls back to sz and is filed into too few cells — it draws correctly and vanishes from every scene-level effect: outlines will not merge with it and neighbours will not see it.

The two envelope tables must agree. Over-estimating costs a little march time; under-estimating produces the two failures above. Round up.

6. The component — shapes/Octahedron.tsx

Section titled “6. The component — shapes/Octahedron.tsx”
export type OctahedronProps = {
position?: FlexibleVec3
/** Vertex distance from center (vertices at ±size on each axis). */
size?: number | PhotonValue<number>
rotation?: FlexibleVec3
color?: string
opacity?: number | PhotonValue<number>
material?: Material
onClick?: (shape: SDFState, e: any) => void
onPointerOver?: (shape: SDFState) => void
onPointerOut?: (shape: SDFState) => void
}
export function Octahedron(props: OctahedronProps) {
const { position, size = 0.5, rotation, color, opacity = 0.9,
material = { ambient: 0.2 }, onClick, onPointerOver, onPointerOut } = props
const ctx = useFabric()
const { shapesRef } = ctx
const { outline, glow } = useEffects() // from an enclosing <Effect>
const fast = useFast() // from an enclosing <Fast>
const modifier = useModifier() // merged modifier frame
const visibleGroup = useVisibleGroup() // from an enclosing <Visible>
const id = nextShapeId()
const pos = toVec3(position)
const rot = toRad(rotation)
effect(() => {
const g = ctx.gpu()
if (!g) return
import('../render/sdfEngine').then(({ initSDF }) => {
const live = ctx.gpuRef.current
if (live) initSDF(live)
})
shapesRef.current.set(id, {
type: 10,
position: pos,
size: { x: size, y: 0, z: 0 } as Vec3, // ← the contract from step 2
rotation: rot,
roundness: 0, color, opacity, material,
outline: fast.enabled ? undefined : outline,
glow: fast.enabled ? undefined : glow,
fast: fast.enabled,
fastAA: fast.enabled ? fast.aa : 0,
modifier, visibleGroup,
onClick, onPointerOver, onPointerOut,
})
g.renderDirtyFrames = Math.max(g.renderDirtyFrames, 2)
const unsub = subscribeToSignals({ ...props, outline, glow, modifier }, () => {
g.renderDirtyFrames = Math.max(g.renderDirtyFrames, 2)
})
cleanup(() => {
unsub()
shapesRef.current.delete(id)
g.renderDirtyFrames = Math.max(g.renderDirtyFrames, 2)
})
})
return null as unknown as Node
}

Four things to get right in it:

  • Its own size vocabulary. Users write <Octahedron size={0.5}>; the component maps that into size.xyz exactly as step 2’s comment says — here { x: size, y: 0, z: 0 }. A cylinder maps radius and height; a torus maps radius and tube. The prop names are the shape’s; the three floats are the shader’s.
  • type: 10, as a literal. The build reads that exact text from each shape component to learn which region number belongs to which tag. A computed value is not seen, so the tag maps to nothing and the shape’s regions are never stripped.
  • Read every composer stack. useEffects, useFast, useModifier, useVisibleGroup — reading them at registration is what makes <Effect>, <Fast>, the modifiers and <Visible> work on this shape with no further code.
  • Subscribe, register, clean up. subscribeToSignals subscribes to every reactive prop so a change marks the frame dirty; cleanup unregisters.

Export it from shapes/index.ts.

Picking. pickShape in core/interact.ts raymarches on the CPU for click and hover, with a small distance function per type:

case 10: d = sdOctahedron(pLocal, sz.x); break;

A bounding sphere is acceptable — types 13 to 21 use one — since picking only needs the silhouette roughly right. Without a case the shape can never be picked.

Stripping. The three SDF_TYPE [10] regions from steps 1, 2 and 5 are what the build removes: when <Octahedron appears nowhere in the app’s source, all three leave. The envelope case in vs is not a region and always ships.

TYPE_TO_FNS in the shake plugin feeds only the older chunk pass, which consumers do not run — see Lazy subsystems. An entry there changes nothing today.

updateSDFBuffers, the pipelines, the renderer, the record layout. A shape registers a record and everything downstream of that is generic.