4

Procedural Marble Slab

keys 1-4 swap stone, drag to tilt, double-click reseeds

A flat slab shaded entirely on the GPU as polished stone using Ken Perlin's classic marble formula. The fragment shader builds a multi-octave fractal Brownian motion field where is a small per-octave rotation that decorrelates the lattice axes. Taking gives ridged turbulence — the wispy mineral-flow look. Feeding that into a phase warp

produces the iconic anisotropic veining: streaks running along with chaotic micro-curvature. The scalar is then routed through a per-preset palette: Carrara (white + charcoal), Onyx (black + gold), Malachite (concentric emerald bands derived from radial coordinates), and Lapis Lazuli (ultramarine + sparse high-frequency gold flecks thresholded from ). Lighting is a Ward-style anisotropic specular: the half-vector is projected onto a tangent/bitangent frame and weighted as with the cross-vein width an order of magnitude tighter than along-vein, so the highlight slides as a thin streak when the slab tilts. Drag rotates the mesh in two axes; the seed offset feeds the fBm so a double-click randomizes the entire vein pattern.

idle
275 lines · three
view source
// Procedural Marble Slab — a draggable tilted plane shaded entirely on the
// GPU as polished stone. The fragment shader layers fBm-driven turbulence
// into the classic Perlin marble formula
//   value = sin(uv.x * k + turbulence(uv) * warp),
// remaps that scalar through a per-preset palette, then adds an
// anisotropic specular streak that slides with the tilt angle.
//
// Interaction:
//   - drag             : tilt the slab (pitch from mouseY, yaw from mouseX)
//   - 1 / 2 / 3 / 4    : swap stone (Carrara, Onyx, Malachite, Lapis)
//   - tap / click      : also cycles the preset
//   - double-click     : reseed the noise offset (vein pattern regenerates)

const VERT = `
  varying vec2 vUv;
  varying vec3 vNormal;
  varying vec3 vViewDir;
  void main() {
    vUv = uv;
    // World-space lighting vectors. The plane is in the xy plane in local
    // space; rotating the mesh rotates its normal too, which is exactly
    // what makes the specular slide as the user tilts.
    vec4 worldPos = modelMatrix * vec4(position, 1.0);
    vNormal = normalize(mat3(modelMatrix) * normal);
    vViewDir = normalize(cameraPosition - worldPos.xyz);
    gl_Position = projectionMatrix * viewMatrix * worldPos;
  }
`;

// Fragment shader: marble formula + per-preset palette + spec.
//
// Preset IDs:
//   0 Carrara     white base with charcoal veins, soft anisotropic sheen
//   1 Onyx        deep black/brown base with amber/gold veins
//   2 Malachite   emerald greens, concentric banding via radial coords
//   3 Lapis       ultramarine blue with sparse gold pyrite flecks
//
// We share one shader for all four; selection is a uniform branch. Stays
// well within the iso-cost budget at 60fps on integrated GPUs.
const FRAG = `
  precision highp float;

  varying vec2 vUv;
  varying vec3 vNormal;
  varying vec3 vViewDir;

  uniform float uTime;
  uniform vec2  uSeed;     // noise-space offset; double-click randomizes
  uniform int   uPreset;
  uniform vec3  uLightDir; // unit, world space

  // ---- hash + value noise ------------------------------------------------
  // We use a value-noise lattice (cheaper than gradient noise, plenty for
  // marble where we want soft blobs, not sharp ridges). 5 octaves of fBm
  // gives the multi-scale wisps without going overboard on ALU.
  float hash21(vec2 p) {
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
  }
  float vnoise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    float a = hash21(i);
    float b = hash21(i + vec2(1.0, 0.0));
    float c = hash21(i + vec2(0.0, 1.0));
    float d = hash21(i + vec2(1.0, 1.0));
    vec2 u = f * f * (3.0 - 2.0 * f);     // smoothstep curve
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
  }
  float fbm(vec2 p) {
    float v = 0.0;
    float amp = 0.5;
    // Slight rotation per octave decorrelates the grid axes — avoids the
    // tell-tale axis-aligned streaking of unrotated fBm.
    mat2 rot = mat2(0.80, -0.60, 0.60, 0.80);
    for (int i = 0; i < 5; i++) {
      v += amp * vnoise(p);
      p = rot * p * 2.02;
      amp *= 0.5;
    }
    return v;
  }
  // Turbulence: |2*fbm - 1| style absolute-valued field, gives ridged
  // streaks that read as fluid mineral flow once put through sin().
  float turbulence(vec2 p) {
    return abs(fbm(p) * 2.0 - 1.0) * 1.4;
  }

  // ---- per-preset palette ------------------------------------------------
  vec3 paletteCarrara(float v, vec2 uv) {
    // Off-white base, charcoal veins where v is near 0.
    vec3 base = vec3(0.93, 0.93, 0.92);
    vec3 vein = vec3(0.18, 0.18, 0.20);
    // v is in [0,1]; flip and sharpen so dark veins thin out.
    float t = smoothstep(0.05, 0.55, v);
    vec3 col = mix(vein, base, t);
    // A faint warm cream cast in the mid-range.
    col += vec3(0.04, 0.03, 0.0) * (1.0 - abs(v - 0.5) * 2.0);
    return col;
  }
  vec3 paletteOnyx(float v, vec2 uv) {
    // Almost black field, gold-amber veins.
    vec3 base = vec3(0.04, 0.03, 0.04);
    vec3 vein = vec3(0.95, 0.66, 0.18);
    float t = smoothstep(0.0, 0.35, v);
    vec3 col = mix(vein, base, t);
    // Warm undertone in mid-bands.
    col += vec3(0.10, 0.05, 0.0) * (1.0 - smoothstep(0.4, 0.85, v));
    return col;
  }
  vec3 paletteMalachite(float v, vec2 uv) {
    // Concentric green bands. We feed a radial coordinate into the band
    // calc so the rings look like cross-sectioned mineral nodules.
    vec2 c = uv - vec2(0.5);
    float r = length(c) * 4.0;
    float bands = 0.5 + 0.5 * sin(r * 18.0 + v * 6.2832);
    vec3 dark  = vec3(0.03, 0.16, 0.10);
    vec3 mid   = vec3(0.05, 0.45, 0.22);
    vec3 light = vec3(0.55, 0.85, 0.45);
    vec3 col = mix(dark, mid, smoothstep(0.0, 0.6, v));
    col = mix(col, light, smoothstep(0.55, 1.0, bands));
    return col;
  }
  vec3 paletteLapis(float v, vec2 uv) {
    // Deep ultramarine with white-grey wisps + sparse gold flecks.
    vec3 deep  = vec3(0.04, 0.10, 0.42);
    vec3 ultra = vec3(0.10, 0.22, 0.65);
    vec3 wisp  = vec3(0.80, 0.85, 0.95);
    vec3 col = mix(deep, ultra, smoothstep(0.0, 0.7, v));
    col = mix(col, wisp, smoothstep(0.80, 0.98, v) * 0.35);
    // Sparse gold pyrite flecks: high-frequency value noise, thresholded.
    float fleck = vnoise(uv * 220.0 + uSeed * 13.0);
    float spec  = smoothstep(0.94, 0.99, fleck);
    vec3 gold = vec3(0.95, 0.78, 0.30);
    col = mix(col, gold, spec * 0.85);
    return col;
  }

  void main() {
    // Drive the formula in a stretched UV space so veins are anisotropic
    // (long along x, narrow across y) — the classic slab look.
    vec2 p = vec2(vUv.x * 3.0, vUv.y * 1.6) + uSeed;
    float t = turbulence(p);
    // Classic Perlin marble: sin of warped x. k controls vein density,
    // warp controls how chaotic the streaks get.
    float k    = 18.0;
    float warp = 4.5;
    float m = sin(p.x * k + t * warp);
    // Remap from [-1,1] to [0,1] then sharpen with a smoothstep so the
    // vein contrast pops without going binary.
    float v = smoothstep(-0.7, 0.7, m);

    vec3 col;
    if (uPreset == 0)      col = paletteCarrara(v, vUv);
    else if (uPreset == 1) col = paletteOnyx(v, vUv);
    else if (uPreset == 2) col = paletteMalachite(v, vUv);
    else                   col = paletteLapis(v, vUv);

    // ---- Anisotropic specular -------------------------------------------
    // Polished marble has a directional sheen — broader along the vein
    // direction (here ~x) than across. We compute a half-vector, then
    // raise the dot to two separate exponents in tangent/bitangent and
    // multiply. Tangent is roughly world-x projected onto the surface;
    // for our slab in the xy plane this is fine without a TBN frame.
    vec3 N = normalize(vNormal);
    vec3 L = normalize(uLightDir);
    vec3 V = normalize(vViewDir);
    vec3 H = normalize(L + V);
    float NdotH = max(dot(N, H), 0.0);
    // Decompose H around N into tangent (x) and bitangent (y) projections.
    // For a plane facing +z (or whatever it rotates into), this is an
    // approximation that's good enough for a sliding highlight effect.
    vec3 T = normalize(vec3(1.0, 0.0, 0.0) - N * dot(N, vec3(1.0, 0.0, 0.0)));
    vec3 B = cross(N, T);
    float ht = dot(H, T);
    float hb = dot(H, B);
    float aniso = exp(- (ht*ht / 0.35 + hb*hb / 0.04));
    // Diffuse term to keep the slab readable when nearly edge-on.
    float diff = max(dot(N, L), 0.0);
    float specStrength = (uPreset == 0) ? 0.55
                       : (uPreset == 1) ? 0.95
                       : (uPreset == 2) ? 0.50
                       : 0.70;
    vec3 specTint = (uPreset == 1) ? vec3(1.0, 0.90, 0.55) : vec3(1.0);

    vec3 lit = col * (0.55 + 0.55 * diff) + specTint * aniso * specStrength;
    // Subtle vignette so the slab feels lit from above-left.
    float vig = 1.0 - 0.35 * length(vUv - vec2(0.5));
    lit *= vig;

    // Gamma-ish toe to keep deep colors from crushing to pitch.
    lit = pow(max(lit, 0.0), vec3(0.92));

    gl_FragColor = vec4(lit, 1.0);
  }
`;

const PRESET_NAMES = ["Carrara", "Onyx", "Malachite", "Lapis Lazuli"];

let plane;
let material;
let preset = 0;

// Tilt state. We integrate the user's drag into pitch/yaw so the slab
// keeps the new orientation when they release the mouse.
let targetPitch = -0.55;  // initial nice angle
let targetYaw   = 0.25;
let curPitch    = -0.55;
let curYaw      = 0.25;
let isDragging  = false;
let dragStartX  = 0;
let dragStartY  = 0;
let pitchAtDown = 0;
let yawAtDown   = 0;
let dragMoved   = false;

// Click-vs-double-click disambiguation. We can't use ondblclick — the
// sandbox only forwards single clicks. Two clicks within 350ms at the same
// spot count as a double-click → reseed.
let lastClickTime = -10;
let lastClickX = 0;
let lastClickY = 0;
const DBL_MS = 350;
const DBL_DIST_SQ = 36 * 36;

// HUD sprite shows current stone name.
let hudCanvas, hudCtx, hudTex, hudSprite;
let lastHudPreset = -1;

function rand() { return Math.random(); }

function drawHud(idx) {
  const w = hudCanvas.width, h = hudCanvas.height;
  hudCtx.clearRect(0, 0, w, h);
  hudCtx.fillStyle = "rgba(0,0,0,0.55)";
  const pad = 8, radius = 12;
  const rx = pad, ry = pad, rw = w - pad * 2, rh = h - pad * 2;
  hudCtx.beginPath();
  hudCtx.moveTo(rx + radius, ry);
  hudCtx.lineTo(rx + rw - radius, ry);
  hudCtx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + radius);
  hudCtx.lineTo(rx + rw, ry + rh - radius);
  hudCtx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - radius, ry + rh);
  hudCtx.lineTo(rx + radius, ry + rh);
  hudCtx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - radius);
  hudCtx.lineTo(rx, ry + radius);
  hudCtx.quadraticCurveTo(rx, ry, rx + radius, ry);
  hudCtx.closePath();
  hudCtx.fill();

  hudCtx.fillStyle = "#e8eaf6";
  hudCtx.font = "bold 34px ui-monospace, SFMono-Regular, Menlo, monospace";
  hudCtx.textAlign = "center";
  hudCtx.textBaseline = "middle";
  hudCtx.fillText(PRESET_NAMES[idx], w / 2, h / 2 - 6);
  hudCtx.font = "13px ui-monospace, SFMono-Regular, Menlo, monospace";
  hudCtx.fillStyle = "#a3a8c3";
  hudCtx.fillText("1·2·3·4 swap   drag tilt   dbl-click reseed", w / 2, h / 2 + 22);
  hudTex.needsUpdate = true;
}

function reseed() {
  material.uniforms.uSeed.value.set(rand() * 100.0, rand() * 100.0);
}

function init({ scene, camera, renderer, width, height }) {
  renderer.setClearColor(0x0a0a0a, 1);
  camera.position.set(0, 0, 3.4);
  camera.lookAt(0, 0, 0);

  // A generously-tessellated plane — the shader is per-fragment so we
  // don't actually need geometry detail for the look, but a few segments
  // keep the silhouette smooth at glancing angles.
  const geo = new THREE.PlaneGeometry(3.2, 2.0, 4, 4);

  material = new THREE.ShaderMaterial({
    vertexShader: VERT,
    fragmentShader: FRAG,
    uniforms: {
      uTime:     { value: 0.0 },
      uSeed:     { value: new THREE.Vector2(rand() * 100, rand() * 100) },
      uPreset:   { value: preset },
      uLightDir: { value: new THREE.Vector3(0.4, 0.7, 0.8).normalize() },
    },
    side: THREE.DoubleSide,
  });

  plane = new THREE.Mesh(geo, material);
  plane.rotation.x = curPitch;
  plane.rotation.y = curYaw;
  scene.add(plane);

  // Stone-name HUD pinned to the camera.
  hudCanvas = document.createElement("canvas");
  hudCanvas.width = 420;
  hudCanvas.height = 96;
  hudCtx = hudCanvas.getContext("2d");
  hudTex = new THREE.CanvasTexture(hudCanvas);
  hudTex.minFilter = THREE.LinearFilter;
  hudTex.magFilter = THREE.LinearFilter;
  const hudMat = new THREE.SpriteMaterial({
    map: hudTex, transparent: true, depthWrite: false, depthTest: false,
  });
  hudSprite = new THREE.Sprite(hudMat);
  hudSprite.scale.set(0.78, 0.18, 1);
  hudSprite.position.set(0, -0.85, -2);
  hudSprite.renderOrder = 999;
  camera.add(hudSprite);
  scene.add(camera);

  drawHud(preset);
  lastHudPreset = preset;

  return { scene, camera };
}

function tick({ dt, time, scene, camera, renderer, width, height, input }) {
  // --- Keyboard preset swap ---------------------------------------------
  if (input.justPressed("1")) preset = 0;
  else if (input.justPressed("2")) preset = 1;
  else if (input.justPressed("3")) preset = 2;
  else if (input.justPressed("4")) preset = 3;

  // --- Click → cycle preset, double-click → reseed ----------------------
  const clicks = input.consumeClicks();
  for (const c of clicks) {
    if (dragMoved) continue; // ignore clicks that were actually drags
    const dx = c.x - lastClickX;
    const dy = c.y - lastClickY;
    const distSq = dx * dx + dy * dy;
    const dtMs = (time * 1000) - lastClickTime;
    if (dtMs < DBL_MS && distSq < DBL_DIST_SQ) {
      reseed();
      // Suppress further chaining: mark this click "consumed" so a third
      // tap doesn't re-trigger as another double.
      lastClickTime = -10;
    } else {
      preset = (preset + 1) % PRESET_NAMES.length;
      lastClickTime = time * 1000;
      lastClickX = c.x;
      lastClickY = c.y;
    }
  }

  // --- Mouse drag → tilt -------------------------------------------------
  if (input.mouseDown) {
    if (!isDragging) {
      isDragging = true;
      dragMoved = false;
      dragStartX = input.mouseX;
      dragStartY = input.mouseY;
      pitchAtDown = targetPitch;
      yawAtDown   = targetYaw;
    } else {
      const dx = input.mouseX - dragStartX;
      const dy = input.mouseY - dragStartY;
      // Inverted-y feels natural ("pull bottom toward you" = more tilt down).
      targetYaw   = yawAtDown   + dx * 0.006;
      targetPitch = pitchAtDown + dy * 0.006;
      // Clamp so the slab can't flip behind the camera.
      targetPitch = Math.max(-1.25, Math.min(0.4, targetPitch));
      targetYaw   = Math.max(-1.1, Math.min(1.1, targetYaw));
      if (dx * dx + dy * dy > 16) dragMoved = true;
    }
  } else {
    isDragging = false;
  }

  // Critically-damped-ish smoothing so tilt settles without overshoot.
  const k = Math.min(1, dt * 8);
  curPitch += (targetPitch - curPitch) * k;
  curYaw   += (targetYaw   - curYaw)   * k;
  plane.rotation.x = curPitch;
  plane.rotation.y = curYaw;

  // Resize on iframe size change.
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height, false);

  // Push uniforms.
  material.uniforms.uTime.value = time;
  material.uniforms.uPreset.value = preset;

  if (preset !== lastHudPreset) {
    drawHud(preset);
    lastHudPreset = preset;
  }

  renderer.render(scene, camera);
}

Comments (0)

Log in to comment.