4
Procedural Marble Slab
keys 1-4 swap stone, drag to tilt, double-click reseeds
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.