OBSIDIAN BLOOM ← Back to the flower

Build Guide · No. 001

One equation,
photographed in a room
that never existed

Obsidian Bloom is a single procedural flower of black glass and chrome that opens as you scroll. There are no 3D models, no textures, and no AI assets on the page — only mathematics, light, and about 560 lines of JavaScript. This is exactly how it works, so you can build your own.

I

The concept

The design bet was absolute focus: one object, one camera, one gesture. Scrolling does not move the page so much as it turns a single number — uBloom, 0 to 1 — and everything the visitor sees (petals unfolding, the void warming, the HUD percentage) is a function of that number. Dark-luxury art direction: Italiana for display type, Manrope for body, one champagne-gold accent (#c8a25e) against near-black.

II

Petals grown in the vertex shader

The CPU never computes a petal. Geometry is built once as a bare grid of (u,v) coordinates — 25 petals × 3 whorls, ≈20k vertices — and a GLSL function positions every vertex, every frame, on the GPU:

// dat.x = angle around stem · dat.y = whorl (0 outer → 1 inner) · dat.z = per-petal random
vec3 petalPos(vec2 pr, vec3 dat) {
  float u = pr.x;                       // 0..1 along the petal
  float wn = dat.y;
  float L  = mix(1.45, 0.85, wn);       // outer petals are longer

  // staggered opening: outer whorl first, each petal its own delay
  float st = wn * 0.35 + dat.z * 0.10;
  float b  = smoothstep(st, st + 0.55, uBloom);
  ...
}

Normals cannot come from a static mesh whose vertices move in the shader, so they are computed numerically inside onBeforeCompile — the surface is evaluated three times (at p, p+∂u, p+∂v) and the normal is the cross product of the two tangents:

vec3 pP0 = petalPos(aParam, aData);
vec3 pPu = petalPos(aParam + vec2(pe_, 0.0), aData);
vec3 pPv = petalPos(aParam + vec2(0.0, pe_), aData);
vec3 n   = normalize(cross(pPu - pP0, pPv - pP0));

The petal material is a MeshPhysicalMaterial tuned like lacquered obsidian — near-black base, high clearcoat, low roughness — so all its character comes from what it reflects.

III

Jewellery lighting without a single light

Chrome is lit, not painted. Instead of loading an HDRI, the scene builds an invisible black photo studio — emissive planes acting as softboxes — and folds it into an environment map with PMREMGenerator:

softbox(5, 3,  0xeef2ff, 9,   [ 0,    6,  1.5]);  // broad cold key overhead
softbox(0.5,7, 0xdfe6ff, 7,   [-5.5,  1.5, 0.5]); // knife-edge strip → long chrome streaks
softbox(0.5,7, 0xffffff, 5,   [ 5.5,  2.0,-0.5]);
softbox(6, 0.4,0xcfd8ff, 4,   [ 0,    2.5,-6  ]); // rim behind
softbox(4, 1.2,0xffc76e, 2.2, [ 0,   -3.5, 4  ]); // warm champagne bounce

const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(studio, 0.035).texture;

Inside the flower sits a gold phyllotaxis heart — 64 instanced spheres placed on a Vogel spiral (θ = i × 2.39996, the golden angle), fully metallic, so the throat of the flower burns with reflected studio light at full bloom.

IV

Scroll as the only verb

GSAP ScrollTrigger scrubs scroll progress into a target value, which is then eased once more every frame (a classic double-smoothing trick — scrub for position, lerp for weight) before landing in the shader uniform and the HUD counter. The backdrop is not flat black either: a back-face sphere with its own tiny shader warms up as uBloom rises, and UnrealBloomPass + a generated 128×128 film-grain tile give the final image its photographic finish.

// the one number that runs the site
ScrollTrigger.create({
  start: 0, end: 'max', scrub: 0.6,
  onUpdate: (self) => { bloomTarget = self.progress; }
});
// per-frame: uBloom → petals, backdrop, HUD, exposure
Petals
25 · 3 whorls
Geometry
≈20k vertices, positioned in GLSL
Normals
Numeric ∂/∂u × ∂/∂v
Lighting
Procedural studio → PMREM
Post
UnrealBloom · grain · vignette
Stack
Three.js r170 · GSAP 3.12

V

The atelier — dials into the live shader

Halfway down the page sits an interactive station: three sliders — bloom, whorl tightness, petal length — wired directly to shader uniforms (uBloom, uWhorl, uLen). Tightness scales the open-tilt and receptacle radius; length scales the petal's parametric extent, with width following at 35% so proportions stay floral. Four presets (Bud Noir, Corolla, Medusa, Chalice) tween the sliders themselves with GSAP, and every uniform is lerped per-frame — nothing snaps, so handling the flower feels like handling jewellery. Beneath the dials a live hallmark engraves the current recipe (OB·001 — B088 · W1.40 · L1.28), like an assay mark that rewrites itself as you turn the stones.

// hand-off: scroll owns the bloom, until the bench is centred in view
atelierMix += (visibility - atelierMix) * ease;
target = storyBloom * (1.0 - atelierMix) + dialBloom * atelierMix;
// entering the atelier syncs the dial to wherever scroll left the flower,
// and the camera lerps to a jeweller's three-quarter view, flower off-axis

The blend runs on section visibility measured every frame, so scrolling in or out of the atelier never pops — control is handed over, both for the bloom value and for the camera.

VI

Materials & process, told on the page

Two documentary sections back the object up. Lacquer & light shows the material recipe as animated gauges (metalness 0.85, roughness 0.22, clearcoat 1.0) beside an SVG elevation of the invisible studio — five lamps that breathe in sequence and flare on hover, each labelled with its real intensity from the code. The lamps are live switches: click one and the corresponding softbox mesh is hidden, the studio scene is re-folded through PMREMGenerator on the spot (each on/off combination cached by bitmask), and the streak it owned visibly leaves the lacquer above. From equation to light is a four-step timeline (equation → normals → studio → bloom) whose gold rail draws itself across with a scrubbed ScrollTrigger. Both live on glass panels (backdrop-filter: blur) floating over the still-live flower, and the sticky nav gains a blurred veil once the hero is left behind. The page closes with a proper footer. Everything remains procedural — the diagrams are hand-set SVG, not images.

VII

Replicate it

1 — Ship a grid, not a mesh: encode (u,v) + per-petal data as attributes and do all shaping in onBeforeCompile.  2 — Build light as geometry: emissive planes → PMREM beats any free HDRI for product-shot chrome.  3 — Drive everything from one scrubbed, re-eased scalar so motion feels like a decision, not a mechanism.  4 — Cap devicePixelRatio at 2, listen for webglcontextlost, and honour prefers-reduced-motion by opening the flower instantly.

AI assets used: none. Every pixel is procedural.