From d72faf7c3940c5f5ad3b5e3cc6c65322579e3462 Mon Sep 17 00:00:00 2001 From: Ed Chen <37851723+Edwson@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:09:25 +0800 Subject: [PATCH 1/8] registry: 6 new WebGL2 shader components (refracted-glass, brushed-metal, moire-weave, velvet-sheen, translucent-wax, diffraction-grating) --- registry/reactomega/ui/brushed-metal.tsx | 211 ++++++++++++ .../reactomega/ui/diffraction-grating.tsx | 251 ++++++++++++++ registry/reactomega/ui/moire-weave.tsx | 174 ++++++++++ registry/reactomega/ui/refracted-glass.tsx | 186 ++++++++++ registry/reactomega/ui/translucent-wax.tsx | 320 ++++++++++++++++++ registry/reactomega/ui/velvet-sheen.tsx | 185 ++++++++++ 6 files changed, 1327 insertions(+) create mode 100644 registry/reactomega/ui/brushed-metal.tsx create mode 100644 registry/reactomega/ui/diffraction-grating.tsx create mode 100644 registry/reactomega/ui/moire-weave.tsx create mode 100644 registry/reactomega/ui/refracted-glass.tsx create mode 100644 registry/reactomega/ui/translucent-wax.tsx create mode 100644 registry/reactomega/ui/velvet-sheen.tsx diff --git a/registry/reactomega/ui/brushed-metal.tsx b/registry/reactomega/ui/brushed-metal.tsx new file mode 100644 index 0000000..23fc789 --- /dev/null +++ b/registry/reactomega/ui/brushed-metal.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface BrushedMetalProps { + className?: string; + /** `"linear"` for a straight-grain finish, `"radial"` for engine-turned. @default "linear" */ + pattern?: "linear" | "radial"; + /** How far the highlight is stretched across the grain, 0..1. @default 0.88 */ + anisotropy?: number; + /** Base roughness of the polish, 0..1. @default 0.34 */ + roughness?: number; + /** Colour the metal reflects. @default "#b9c8f0" */ + tint?: string; + /** Speed the light orbits at. @default 1 */ + speed?: number; +} + +/** + * BrushedMetal — a still, machined surface. Nothing about the metal moves; only + * the light does, and the highlight it drags is the entire subject. + * + * The grain is a direction field — constant for a linear finish, tangential + * around the centre for an engine-turned one — and the abrasive scratches are + * value noise sampled on coordinates stretched forty to one along that + * direction, so every groove runs with the grain. Lighting is an anisotropic + * GGX lobe: the roughness along the grain is held low while the roughness + * across it is pushed up by `anisotropy`, and because a microfacet + * distribution spreads reflections in the direction it is rough, the specular + * comes out as a long streak lying *perpendicular* to the brushing. That + * asymmetry is the whole tell of brushed metal, and it is computed rather than + * drawn. Smith-correlated shadowing keeps the grazing rim from blowing out, + * and the pointer takes the light over. + */ +export function BrushedMetal({ + className, + pattern = "linear", + anisotropy = 0.88, + roughness = 0.34, + tint = "#b9c8f0", + speed = 1, +}: BrushedMetalProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uRadial: pattern === "radial" ? 1 : 0, + uAniso: anisotropy, + uRough: roughness, + }), + [tint, pattern, anisotropy, roughness], + ); + + const { ref, supported } = useShader({ speed, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +const float PI = 3.14159265; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + // Quintic, not cubic: the milling term is sampled at a low frequency, and + // cubic value noise creases visibly along its lattice lines when it is. + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +// Scratch depth at a point already expressed in (along-grain, across-grain) +// coordinates. Three bands of abrasive grit, each stretched hard along the grain. +float grooves(vec2 g) { + float v = vnoise(vec2(g.x * 0.9, g.y * 40.0)) - 0.5; + v += 0.62 * (vnoise(vec2(g.x * 2.1 + 11.0, g.y * 130.0)) - 0.5); + v += 0.34 * (vnoise(vec2(g.x * 4.3 - 7.0, g.y * 420.0)) - 0.5); + return v; +} + +// Anisotropic GGX. Rough across the grain, smooth along it — a microfacet lobe +// spreads light in whichever direction it is rough, so the highlight ends up +// lying across the brushing rather than with it. +float ggxAniso(vec3 H, vec3 T, vec3 B, vec3 N, float ax, float ay) { + float ht = dot(H, T) / ax; + float hb = dot(H, B) / ay; + float hn = dot(H, N); + float w = ht * ht + hb * hb + hn * hn; + return 1.0 / (PI * ax * ay * w * w); +} + +float smithG(vec3 V, vec3 T, vec3 B, vec3 N, float ax, float ay) { + float vn = max(dot(V, N), 1e-4); + float vt = dot(V, T) * ax; + float vb = dot(V, B) * ay; + float a2 = (vt * vt + vb * vb) / (vn * vn); + return 2.0 / (1.0 + sqrt(1.0 + a2)); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.35; + + // Grain direction field. Radial mode is a turned finish, so the grain runs + // tangentially and the grooves become concentric. + vec2 rad = uv - vec2(0.06, -0.04); + float rl = max(length(rad), 1e-4); + vec2 tanDir = vec2(-rad.y, rad.x) / rl; + vec2 lin = normalize(vec2(0.995, 0.100)); + vec2 Td = normalize(mix(lin, tanDir, uRadial)); + + // A slow bow in the grain — dead-straight brushing reads as a CSS gradient. + float bow = (vnoise(uv * vec2(1.4, 2.6) + 17.0) - 0.5) * 0.16 * (1.0 - uRadial); + Td = normalize(Td + vec2(-Td.y, Td.x) * bow); + vec2 Bd = vec2(-Td.y, Td.x); + + // Grain-local coordinates: x along the brush, y across it. For a turned finish + // the brush runs *around* the centre, so the fast axis has to be the radius — + // put the angle there instead and the grooves come out as radial spokes, which + // is a completely different machining operation. + // atan2 jumps by 2*pi across its branch cut, and since the angle is the + // slow axis of the grain that jump prints a hard seam straight out from the + // spindle. Folding to |theta| removes the discontinuity entirely; the mirror + // it leaves along the other side is invisible because the noise varies barely + // at all in that direction. + vec2 g = mix(vec2(dot(uv, Td), dot(uv, Bd)), + vec2(abs(atan(rad.y, rad.x)) * 1.35, rl * 0.85), uRadial); + + float e = 1.0 / m; + float d = grooves(g); + float dx = grooves(g + vec2(0.0, e * 0.8)) - d; + // Only the across-grain derivative matters; a groove has no slope along itself. + float amp = 0.16 + 0.85 * uRough; + vec3 N = normalize(vec3(Bd * (-dx * amp / e) * 0.010, 1.0)); + + // Broad milling undulation, so large areas catch light differently. + float mill = (vnoise(g * vec2(1.6, 4.2) + 3.0) - 0.5) + + 0.5 * (vnoise(g * vec2(3.7, 9.5) - 8.0) - 0.5); + N = normalize(N + vec3(Bd * mill * 0.10, 0.0) + vec3(Td * mill * 0.03, 0.0)); + + vec3 T3 = normalize(vec3(Td, 0.0) - N * dot(N, vec3(Td, 0.0))); + vec3 B3 = normalize(cross(N, T3)); + vec3 V = normalize(vec3(-uv * 0.45, 1.0)); + + // The light orbits until the pointer claims it. + vec2 lp = mix(vec2(0.50 * cos(t * 0.9 + 0.6), 0.30 * sin(t * 0.7)), pc, uPointer.z); + vec3 L = normalize(vec3(lp - uv, 0.95)); + vec3 H = normalize(L + V); + + float ax = max(0.010, uRough * uRough * (1.0 - 0.94 * uAniso)); + float ay = max(0.020, uRough * uRough * (1.0 + 7.0 * uAniso)); + + float NdL = max(dot(N, L), 0.0); + float NdV = max(dot(N, V), 1e-3); + float D = ggxAniso(H, T3, B3, N, ax, ay); + float G = smithG(L, T3, B3, N, ax, ay) * smithG(V, T3, B3, N, ax, ay); + float F = 0.62 + 0.38 * pow(1.0 - max(dot(H, V), 0.0), 5.0); + float spec = D * G * F * NdL / (4.0 * NdV); + + // Second, much broader lobe: real brushed metal shows a wide sheen band far + // from the hot streak, and without it the plate looks like bare noise. + float ax2 = ax * 6.0 + 0.06; + float ay2 = min(1.0, ay * 2.2 + 0.30); + float sheen = ggxAniso(H, T3, B3, N, ax2, ay2) * NdL * 0.14; + + // Falloff of the light itself, so the plate has a lit end and a dark end. + float falloff = 1.0 / (1.0 + 2.6 * dot(uv - lp, uv - lp)); + + // A cool overhead gradient standing in for the room, so the plate has a body + // tone away from the streak. A milled part in a dark studio is not black. + vec3 room = mix(vec3(0.014, 0.017, 0.030), vec3(0.070, 0.082, 0.130), + smoothstep(-0.5, 0.7, dot(N, normalize(vec3(0.1, 0.9, 0.35))))); + + // The spindle centre has no defined grain direction, so ease the anisotropy + // out there rather than letting it converge into a bright knot. + float hub = mix(1.0, smoothstep(0.005, 0.055, rl), uRadial); + vec3 col = uTint * room; + col += uTint * (0.010 + 0.085 * NdL) * falloff; + col += uTint * clamp(spec, 0.0, 40.0) * 0.075 * falloff * hub; + col += uTint * sheen * falloff * 2.1 * hub; + col += vec3(1.0) * clamp(spec, 0.0, 40.0) * 0.022 * falloff * hub; + + // Anodised rim shade and a faint dirt in the grain valleys. + col *= 1.0 - 0.20 * smoothstep(0.0, 0.6, -d); + col = 1.0 - exp(-col * 1.55); + col *= 1.0 - 0.46 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.014; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/registry/reactomega/ui/diffraction-grating.tsx b/registry/reactomega/ui/diffraction-grating.tsx new file mode 100644 index 0000000..30be25e --- /dev/null +++ b/registry/reactomega/ui/diffraction-grating.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface DiffractionGratingProps { + className?: string; + /** Groove spacing in nanometres. A CD is 1600, a DVD 740, embossed foil ~3200. @default 2450 */ + pitch?: number; + /** How many spectral orders either side of the specular are kept. @default 3 */ + orders?: number; + /** Resolving power, 0..1 — how saturated each spectral line stays. @default 0.72 */ + sharpness?: number; + /** Colour of the metal under the grating. @default "#c9d8ff" */ + tint?: string; +} + +/** + * DiffractionGrating — the surface of a CD, or holographic foil: hard spectral + * streaks that jump position as the light moves, not a soft pastel wash. + * + * The grooves run in concentric arcs and the eye sits at a finite distance, so + * the view direction genuinely varies across the frame. From that geometry the + * shader builds the grating path difference s = d·(sinθ_in + sinθ_out) by + * projecting the light and view vectors onto the groove vector, and then simply + * solves d·sinθ = mλ for the wavelength: order m sends λ = s/m to the eye at + * this pixel, and nothing else. Solving for λ rather than integrating over a + * handful of sampled wavelengths is what makes the streaks continuous — sampled + * spectra bead into rows of coloured dots, because each sample resonates a few + * pixels away from the last. Each order is therefore a smooth ramp through the + * spectrum, cut off exactly where λ leaves the visible band. Because the pitch + * is coarse the path difference climbs steeply across the frame, which is what + * keeps each order a thin line rather than a wide band — sharp spectral lines + * read as optics, wide soft ones read as decoration. The energy is weighted the + * way a real grating weights it: the blaze falloff drops m=±2 to about a third of + * m=±1 and m=±3 to a tenth, and `sharpness` — the resolving power mλ/Δλ — + * additionally washes the high orders toward white, because the same physical + * groove count buys less resolution across a wider order and neighbouring + * wavelengths start overlapping at the eye. So the low orders are the saturated + * ones and the high orders are dim *and* pale, instead of three equal rainbows. + * Under all of it the substrate is a real surface, not a void: the pressed track + * gives a fine band-limited ruling (sinc-filtered against the pixel footprint, + * so it dissolves into its own mean rather than aliasing), a coarser sector + * banding gives structure at a scale the eye can hold, and a broad dim specular + * lobe squashed along the ruling supplies the oily sheen a disc carries + * everywhere the rainbows are not. The zeroth order is achromatic and is kept + * aside as a plain specular; the pointer takes the lamp, which walks the whole + * spectrum across the disc. + */ +export function DiffractionGrating({ + className, + pitch = 2450, + orders = 3, + sharpness = 0.72, + tint = "#c9d8ff", +}: DiffractionGratingProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uPitch: pitch, + uOrders: orders, + uSharp: sharpness, + }), + [tint, pitch, orders, sharpness], + ); + + const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +// A cosine convolved with the pixel footprint. The box filter of cos(2*pi*phi) +// over a width of w cycles is exactly sinc(w) = sin(pi*w)/(pi*w) — zero when the +// period reaches two pixels. The track structure below runs at three or four +// pixels a cycle and fans as it goes, so without this it would alias into +// crawling noise and take the spectra down with it. +float bandCos(float phi, float w) { + float a = 1.0; + if (w > 1e-4) a = clamp(sin(3.14159265 * w) / (3.14159265 * w), 0.0, 1.0); + return cos(6.2831853 * phi) * a; +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +// Rough sRGB response to a single wavelength in nanometres. Sums of gaussians +// rather than a hue ramp, so the band order and the muddy cyan-green at 500nm +// come out where a real spectrum puts them. +vec3 spectral(float l) { + vec3 c; + c.r = 1.06 * exp(-pow((l - 604.0) / 56.0, 2.0)) + + 0.46 * exp(-pow((l - 700.0) / 54.0, 2.0)) + + 0.20 * exp(-pow((l - 432.0) / 26.0, 2.0)); + c.g = 1.02 * exp(-pow((l - 542.0) / 52.0, 2.0)) + + 0.34 * exp(-pow((l - 592.0) / 38.0, 2.0)); + c.b = 1.14 * exp(-pow((l - 452.0) / 42.0, 2.0)) + + 0.32 * exp(-pow((l - 484.0) / 38.0, 2.0)); + return c; +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.4; + + // Foil relief: an extremely shallow crinkle. The path difference is a + // wavelength-scale quantity, so a normal that wanders even slightly shreds the + // orders into contour noise. Almost all of the variation has to come from the + // view geometry instead. + float e = 0.020; + float relief = vnoise(uv * 1.5 + vec2(t * 0.05, -t * 0.04)); + float rx = vnoise((uv + vec2(e, 0.0)) * 1.5 + vec2(t * 0.05, -t * 0.04)); + float ry = vnoise((uv + vec2(0.0, e)) * 1.5 + vec2(t * 0.05, -t * 0.04)); + vec3 N = normalize(vec3((relief - rx) * 0.055 / e, (relief - ry) * 0.055 / e, 1.0)); + + // Grooves in concentric arcs about a centre well outside the frame: near + // parallel, fanning slightly. That keeps the path difference monotonic across + // the frame, which is the only way the orders separate into clean streaks. + vec2 rad = uv - vec2(-3.1, -1.35); + vec2 gDir = normalize(rad); + float swirl = (vnoise(uv * 1.15 + 9.0) - 0.5) * 0.16; + gDir = normalize(gDir + vec2(-gDir.y, gDir.x) * swirl); + vec3 G = normalize(vec3(gDir, 0.0) - N * dot(N, vec3(gDir, 0.0))); + + // Track structure. The grooves that do the diffracting are a wavelength or two + // apart — far below a pixel, and drawing them would only alias. What you + // actually see on a disc is the coarser banding of the pressed track: hundreds + // of grooves to a visible line. Concentric about the same centre as the + // grating vector, because it is the same ruling, and band-limited because it + // runs at three or four pixels a cycle and fans as it goes. + float rl = length(rad); + float gph = rl * 74.0 + 1.4 * vnoise(uv * 2.2 + 3.0); + float groove = 0.5 + 0.5 * bandCos(gph, fwidth(gph)); + // A far coarser second banding — the pressed sectors — so the surface has + // structure at a scale the eye can hold as well as one it can only resolve. + float sect = 0.5 + 0.5 * bandCos(rl * 5.5 - 0.3, fwidth(rl * 5.5)); + + // Finite eye distance: the view direction is what makes s position-dependent. + vec3 V = normalize(vec3(-uv, 0.78)); + vec2 lxy = mix(vec2(0.30 + 0.55 * cos(t * 0.5 + 1.2), 0.34 * sin(t * 0.38)), pc, uPointer.z); + vec3 L = normalize(vec3(lxy - uv, 0.62)); + + // d * (sin(theta_in) + sin(theta_out)), both angles projected onto the groove + // vector. This single scalar is the entire grating equation. + float s = uPitch * (dot(V, G) + dot(L, G)); + + float NdL = max(dot(N, L), 0.0); + float atten = 1.0 / (1.0 + 1.2 * dot(uv - lxy, uv - lxy)); + + // Rate of change of the path difference, in nanometres per pixel. It sets how + // wide the band edges have to be feathered to stay smooth at any zoom. + float ws = max(fwidth(s), 1e-4); + float purity = clamp(uSharp, 0.0, 1.0); + + vec3 fan = vec3(0.0); + for (int mi = 1; mi <= 4; mi++) { + float mm = float(mi); + if (mm > uOrders + 0.5) break; + // The grating equation, solved for wavelength instead of for position. + float lam = abs(s) / mm; + float wl = ws / mm; + // Order m only exists here if the wavelength it wants is one we can see. + float band = smoothstep(0.0, 2.0 * wl + 5.0, lam - 398.0) + * smoothstep(0.0, 2.0 * wl + 5.0, 712.0 - lam); + // Blaze falloff: a real grating throws most of its energy into the low + // orders, and steeply. Three equally bright bands is the single thing that + // makes a grating read as a rainbow gradient instead of as optics. + float eff = 1.0 / (1.0 + 2.2 * (mm - 1.0) * (mm - 1.0)); + // Finite resolving power R = mN. The *same* physical groove count buys less + // resolution per unit wavelength as m rises relative to the width of the + // order, so the high orders both dim and wash toward white — they overlap + // themselves. Dimming alone would leave them fully saturated and still + // reading as ribbon. + float pur = purity / (1.0 + 0.90 * (mm - 1.0)); + vec3 sc = spectral(lam); + sc = mix(vec3(dot(sc, vec3(0.32, 0.55, 0.13))) * 1.32, sc, 0.24 + 0.58 * pur); + fan += sc * band * eff; + } + fan *= 1.18; + + // Zeroth order: ordinary mirror specular off the foil, plus the anisotropic + // smear a grooved surface gives it along the groove direction. + vec3 H = normalize(L + V); + float NdH = max(dot(N, H), 0.0); + float along = dot(H, G); + float spec = pow(NdH, 900.0) * 1.6 + + pow(NdH, 90.0) * 0.10 * exp(-along * along * 14.0); + + // Dark polycarbonate over aluminium. The substrate has to read as a *surface*: + // an empty black field between the orders is what left the earlier pass + // looking like three neon ribbons floating on nothing, because a spectrum with + // no object under it is just a gradient. + vec3 base = uTint * (0.030 + 0.060 * NdL); + base += uTint * 0.046 * pow(1.0 - abs(dot(V, N)), 2.4); + // Broad low specular lobe. Very wide, very dim, anisotropically squashed along + // the ruling: the oily sheen a disc carries everywhere the rainbows are not. + // This single term is what the spectra end up sitting on. + base += uTint * 0.38 * pow(NdH, 4.5) * (0.26 + 0.74 * exp(-along * along * 2.0)) * atten; + base += uTint * 0.085 * exp(-along * along * 3.0) * atten; + // The track modulates everything reflective, and hardest at grazing incidence + // where the ridges shadow one another. + base *= 0.70 + 0.56 * groove; + // Structure at a scale the eye can actually hold, as well as one it can only + // just resolve. With only the fine ruling, everywhere the spectra are not goes + // back to being a flat field — which was the original complaint about the + // substrate, and the fine banding alone does not answer it. + base *= 0.84 + 0.30 * sect; + base *= 0.90 + 0.22 * vnoise(uv * 1.3 + 17.0); + base += uTint * 0.014 * sect * groove; + + vec3 col = base; + // The spectra come off the ridges, so they carry the ruling too — faintly, or + // the fine banding starts competing with the orders for attention. + col += fan * mix(vec3(1.0), uTint, 0.18) * atten * (0.26 + 0.98 * NdL) + * (0.82 + 0.26 * groove); + col += vec3(1.0) * spec * atten * 0.42 * (0.62 + 0.52 * groove); + // Faint second-surface haze so the black between orders is not empty. + col += uTint * 0.024 * exp(-length(uv - lxy) * 1.6); + + col = 1.0 - exp(-col * 1.22); + col *= 1.0 - 0.36 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/registry/reactomega/ui/moire-weave.tsx b/registry/reactomega/ui/moire-weave.tsx new file mode 100644 index 0000000..df6c811 --- /dev/null +++ b/registry/reactomega/ui/moire-weave.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface MoireWeaveProps { + className?: string; + /** Lattice period in device pixels. Below about 2.5 the filter takes over. @default 8 */ + pitch?: number; + /** Angle between the two lattices, in degrees. Small angles give huge fringes. @default 4.5 */ + angle?: number; + /** `true` weaves the two thread sets over and under, `false` leaves flat line screens. @default true */ + weave?: boolean; + /** Colour of the lit threads. @default "#a8bcff" */ + tint?: string; +} + +/** + * MoireWeave — two rigid high-frequency lattices laid over each other at a few + * degrees, where the enormous soft fringes are interference between them and + * not a pattern anyone drew. + * + * Each lattice is a pair of cosine thread screens with an exact phase, so the + * beat visible across the frame is genuinely the difference frequency k1 - k2: + * shrink the angle and the fringes grow without bound, which is the signature + * of real moiré. The lattices sit on a slightly tilted plane, which means the + * period measured in pixels compresses toward the top of the frame and runs + * straight at the sampling limit — so every cosine is band-limited before it is + * used. Each thread is convolved with the pixel footprint analytically, the box + * filter of cos(2πφ) being sinc(w) with w = fwidth(φ) in cycles per pixel: the + * amplitude decays to exactly zero as the period reaches two pixels and the + * lattice dissolves into its own mean grey instead of boiling into noise. In + * weave mode a third band-limited cosine on φ₁+φ₂ decides which thread set + * passes over at each crossing. The pointer swells the local pitch. + */ +export function MoireWeave({ + className, + pitch = 8, + angle = 4.5, + weave = true, + tint = "#a8bcff", +}: MoireWeaveProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uPitch: pitch, + uAngle: angle, + uWeave: weave ? 1 : 0, + }), + [tint, pitch, angle, weave], + ); + + const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +const float PI = 3.14159265; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +// A cosine convolved with the pixel footprint. The box filter of cos(2*pi*phi) +// over a width of w cycles is exactly sinc(w) = sin(pi*w)/(pi*w) — zero when the +// period hits two pixels. This single line is the difference between moire and +// a screenful of crawling noise. +float bandCos(float phi, float w) { + float a = 1.0; + if (w > 1e-4) a = clamp(sin(PI * w) / (PI * w), 0.0, 1.0); + return cos(2.0 * PI * phi) * a; +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.25; + + // A mild tilt away from the viewer. This is not decoration: it forces the + // period in pixels to sweep through the whole range up to Nyquist, so the + // filter is doing visible work in every frame. + float z = 1.0 + 2.35 * (uv.y + 0.5); + vec2 P = (gl_FragCoord.xy - 0.5 * uResolution) * z; + + // The pointer swells the local pitch — the fringes rearrange around it, + // because a pitch change is a frequency change and the beat follows. + vec2 rel = uv - pc; + float swell = 1.0 - uPointer.z * 0.30 * exp(-dot(rel, rel) * 8.0); + + float f = 1.0 / max(2.0, uPitch * swell); + // Only the relative angle is animated. The fringe scale goes as 1/angle, so a + // half-degree drift is a very large change in what you see. + float a1 = radians(-0.5 * uAngle + 1.1 * sin(t * 0.5)) + 0.06 * sin(t * 0.31); + float a2 = radians(0.5 * uAngle + 1.1 * sin(t * 0.5 + 2.2)) + 0.06 * sin(t * 0.31); + vec2 k1 = f * vec2(cos(a1), sin(a1)); + vec2 k2 = f * 1.008 * vec2(cos(a2), sin(a2)); + + // Warp and weft of each lattice. + float p1 = dot(P, k1); + float q1 = dot(P, vec2(-k1.y, k1.x)); + float p2 = dot(P, k2); + float q2 = dot(P, vec2(-k2.y, k2.x)); + + float w1 = fwidth(p1), v1 = fwidth(q1); + float w2 = fwidth(p2), v2 = fwidth(q2); + + float A = bandCos(p1, w1), Ab = bandCos(q1, v1); + float B = bandCos(p2, w2), Bb = bandCos(q2, v2); + + // Over/under at each crossing, from a band-limited cosine on the sum phase. + float ck1 = 0.5 + 0.5 * bandCos((p1 + q1) * 0.5, fwidth((p1 + q1) * 0.5)); + float ck2 = 0.5 + 0.5 * bandCos((p2 + q2) * 0.5, fwidth((p2 + q2) * 0.5)); + + // Woven: the over/under decides which thread set is visible at each crossing. + // Unwoven: plain single-direction line screens, which is the textbook pairing + // and gives much cleaner fringes because only one frequency beats per lattice. + float l1 = mix(0.5 + 0.5 * A, mix(0.5 + 0.5 * Ab, 0.5 + 0.5 * A, ck1), uWeave); + float l2 = mix(0.5 + 0.5 * B, mix(0.5 + 0.5 * Bb, 0.5 + 0.5 * B, ck2), uWeave); + + // Superposition. Two overlaid screens multiply their transmittances; the beat + // is emergent, and this is where it comes from. + float sup = l1 * l2; + + // The same beat written out analytically at the difference frequency. Used + // only as a lighting envelope, so the fringes still read once the lattices + // themselves have been filtered away to grey near the horizon. + vec2 kd = k1 - k2; + float beat = 0.5 + 0.5 * bandCos(dot(P, kd), fwidth(dot(P, kd))); + vec2 kd2 = k1 - vec2(-k2.y, k2.x); + float beat2 = 0.5 + 0.5 * bandCos(dot(P, kd2), fwidth(dot(P, kd2))); + float env = mix(beat, beat2, 0.42); + + // Thread shading: a cylindrical cross-section catches light off to one side, + // which is what stops a woven surface looking like printed squares. + float lit = 0.5 + 0.5 * bandCos(p1 - 0.22, w1); + float lit2 = 0.5 + 0.5 * bandCos(q2 + 0.22, v2); + + vec3 warm = uTint; + vec3 cool = vec3(0.09, 0.11, 0.30); + + vec3 col = vec3(0.012, 0.014, 0.028); + col += mix(cool * 0.45, warm, smoothstep(0.06, 0.72, sup)) * (0.10 + 1.20 * pow(sup, 1.20)); + // Fringe lighting: crests of the beat get the specular, troughs go blue-black. + col *= 0.24 + 1.50 * pow(env, 1.9); + col += warm * pow(env, 4.0) * 0.42; + col += vec3(0.85, 0.90, 1.0) * pow(sup, 3.4) * pow(env, 3.0) * 0.14; + col += warm * 0.16 * lit * lit2 * env; + + // A broad key so the frame has a lit corner rather than uniform coverage. + col *= 0.55 + 0.85 * exp(-length(uv - vec2(-0.30, 0.16)) * 1.5); + + col = 1.0 - exp(-col * 1.70); + col *= 1.0 - 0.38 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/registry/reactomega/ui/refracted-glass.tsx b/registry/reactomega/ui/refracted-glass.tsx new file mode 100644 index 0000000..1718470 --- /dev/null +++ b/registry/reactomega/ui/refracted-glass.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface RefractedGlassProps { + className?: string; + /** Optical depth of the slab — how far the bent ray travels before it exits. @default 0.34 */ + thickness?: number; + /** Refractive index of the body. 1.5 is crown glass, 1.9 reads as sapphire. @default 1.52 */ + ior?: number; + /** Spread between the red and blue indices. 0 is achromatic, 1 is showy. @default 1 */ + dispersion?: number; + /** Width of the bevelled edge as a fraction of the panel, 0..1. @default 0.34 */ + bevel?: number; + /** Absorption colour of the glass body. @default "#9fc0ff" */ + tint?: string; +} + +/** + * RefractedGlass — a thick bevelled slab of glass laid over a procedural + * backdrop, refracting it rather than blurring it. + * + * The panel is a rounded-box SDF whose interior distance is lifted into a + * circular fillet, so the surface normal swings from straight-up in the middle + * to almost horizontal at the rim. The view ray is then refracted through that + * normal with Snell's law — separately for three indices, red low and blue + * high — and each channel samples the backdrop at its own exit point. Because + * the three exit points only diverge where the normal is steep, dispersion + * appears exactly where real glass shows it: hugging the bevel, absent across + * the flat. A Schlick Fresnel term on the same normal lights the rim, the + * fillet gathers a converging band of light a third of the way up its slope, + * and the transmitted colour is attenuated by the body tint. The pointer + * drags the reflected highlight across the panel. + */ +export function RefractedGlass({ + className, + thickness = 0.34, + ior = 1.52, + dispersion = 1, + bevel = 0.34, + tint = "#9fc0ff", +}: RefractedGlassProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uThickness: thickness, + uIor: ior, + uDispersion: dispersion, + uBevel: bevel, + }), + [tint, thickness, ior, dispersion, bevel], + ); + + const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +// What the glass is sitting on. Deliberately built from soft blooms *and* hard +// diagonal bars: the blooms sell depth, but only an edge makes dispersion legible. +vec3 backdrop(vec2 q, float t) { + vec3 c = vec3(0.016, 0.020, 0.042); + c += vec3(0.26, 0.40, 0.92) * 0.62 * exp(-length((q - vec2(-0.52, 0.26)) * vec2(1.0, 1.25)) * 2.6); + c += vec3(0.52, 0.30, 0.86) * 0.44 * exp(-length((q - vec2(0.54, -0.30)) * vec2(1.0, 1.3)) * 3.0); + c += vec3(0.16, 0.52, 0.70) * 0.22 * exp(-length(q - vec2(0.10, 0.52)) * 3.4); + float b = sin((q.x * 0.62 + q.y * 1.55) * 8.5 - t * 0.42); + c += vec3(0.70, 0.80, 1.00) * 0.52 * smoothstep(0.86, 0.995, b); + float b2 = sin((q.x * 1.30 - q.y * 0.70) * 5.0 + t * 0.28); + c += vec3(0.34, 0.54, 1.00) * 0.30 * smoothstep(0.88, 1.00, b2); + return c; +} + +float sdRoundBox(vec2 p, vec2 b, float r) { + vec2 q = abs(p) - b + r; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; +} + +// Interior distance lifted into a quarter-circle fillet. sqrt(1-(1-k)^2) has an +// infinite slope at the rim, which is precisely what a real bevel does to a normal. +float slabHeight(vec2 p, float bw) { + float k = clamp(-sdRoundBox(p, vec2(0.655, 0.352), 0.085) / bw, 0.0, 1.0); + return sqrt(max(0.0, 1.0 - (1.0 - k) * (1.0 - k))); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime; + float bw = max(0.02, uBevel * 0.30); + float sd = sdRoundBox(uv, vec2(0.655, 0.352), 0.085); + + if (sd > 0.0) { + // Outside the panel: the raw backdrop, plus the shadow the slab casts and a + // thin sliver of light leaking out along the ground contact. + vec3 col = backdrop(uv, t) * (1.0 - 0.62 * exp(-sd * 7.0)); + col += uTint * 0.16 * exp(-sd * 60.0); + col *= 1.0 - 0.40 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + fragColor = vec4(max(col, 0.0), 1.0); + return; + } + + float e = 1.6 / m; + float h = slabHeight(uv, bw); + vec3 N = normalize(vec3((slabHeight(uv - vec2(e, 0.0), bw) - slabHeight(uv + vec2(e, 0.0), bw)) * 0.42, + (slabHeight(uv - vec2(0.0, e), bw) - slabHeight(uv + vec2(0.0, e), bw)) * 0.42, + e)); + + vec3 I = vec3(0.0, 0.0, -1.0); + float d0 = uDispersion * 0.125; + // Cauchy ordering: blue is bent hardest, so the blue fringe always lands + // further in from the rim than the red one. + float travel = uThickness * (0.70 + 0.30 * h); + vec2 oR = vec2(0.0), oG = vec2(0.0), oB = vec2(0.0); + vec3 tR = refract(I, N, 1.0 / max(1.02, uIor - d0)); + vec3 tG = refract(I, N, 1.0 / max(1.02, uIor)); + vec3 tB = refract(I, N, 1.0 / max(1.02, uIor + d0)); + if (tR.z < -0.02) oR = tR.xy * (travel / -tR.z); + if (tG.z < -0.02) oG = tG.xy * (travel / -tG.z); + if (tB.z < -0.02) oB = tB.xy * (travel / -tB.z); + + // Two taps per channel a little apart along the refraction direction: the + // cheapest thing that reads as "solid glass" rather than "a warped picture". + float frost = 0.006 + 0.030 * (1.0 - h); + vec3 s0 = vec3(backdrop(uv + oR, t).r, backdrop(uv + oG, t).g, backdrop(uv + oB, t).b); + vec3 s1 = vec3(backdrop(uv + oR * 1.22 + frost, t).r, + backdrop(uv + oG * 1.22 - frost, t).g, + backdrop(uv + oB * 1.22 + frost * 0.5, t).b); + vec3 through = mix(s0, s1, 0.34); + + // Beer-Lambert through the body: thick glass is not just darker, it is tinted. + through *= exp(-(1.0 - uTint) * travel * 2.1); + + float cosI = clamp(N.z, 0.0, 1.0); + float F = 0.055 + 0.945 * pow(1.0 - cosI, 5.0); + + // Reflected environment: a vertical sky ramp is enough, because the only + // place the reflection vector swings far off axis is on the bevel anyway. + vec3 R = reflect(I, N); + vec3 env = mix(vec3(0.05, 0.06, 0.11), vec3(0.42, 0.54, 0.86), smoothstep(-0.6, 0.9, R.y)) + + vec3(0.30, 0.34, 0.50) * smoothstep(0.2, 1.0, -R.x) * 0.5; + + vec3 Lp = vec3(mix(vec2(-0.46, 0.40), pc, uPointer.z), 0.85); + vec3 L = normalize(Lp - vec3(uv, h * uThickness)); + float spec = pow(max(dot(R, L), 0.0), 46.0) * 1.5 + pow(max(dot(R, L), 0.0), 6.0) * 0.14; + + vec3 col = mix(through, env, F) + spec * (0.5 + 0.5 * uTint); + + // The fillet is a lens; a third of the way up its slope the rays it turns all + // pile into one band. That band is what makes a bevel look expensive. + float k = clamp(-sd / bw, 0.0, 1.0); + float conv = exp(-pow((k - 0.30) / 0.13, 2.0)) * (1.0 - 0.55 * abs(uv.y) / 0.36); + col += uTint * conv * 0.30; + col += vec3(1.0) * exp(-pow((k - 0.06) / 0.05, 2.0)) * 0.085; + + // Interior sheen so the flat centre is not dead: a very broad grazing term. + col += uTint * 0.05 * pow(1.0 - cosI, 1.6); + + col = 1.0 - exp(-col * 1.34); + col *= 1.0 - 0.30 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/registry/reactomega/ui/translucent-wax.tsx b/registry/reactomega/ui/translucent-wax.tsx new file mode 100644 index 0000000..195ffa9 --- /dev/null +++ b/registry/reactomega/ui/translucent-wax.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface TranslucentWaxProps { + className?: string; + /** Depth of the slab. Thicker material lets less of the backlight through. @default 1 */ + thickness?: number; + /** Width of the forward-scattering lobe and how far light bleeds sideways. @default 1 */ + scatter?: number; + /** Colour the material transmits — what survives the absorption. @default "#f7d2a8" */ + tint?: string; + /** Beer-Lambert extinction coefficient. Higher goes waxy, lower goes glassy. @default 2.45 */ + absorption?: number; + /** Width of the ground edge as a fraction of the slab, 0..1. @default 0.52 */ + bevel?: number; +} + +/** + * TranslucentWax — a ground slab of backlit alabaster, honey onyx cut thin + * enough to pass light. Almost everything you see has been through the material + * rather than off it. + * + * The body is a rounded-rectangle slab with a wide ground edge, and that + * boundary is doing most of the work: a translucent solid is legible only by the + * contrast between a glowing thin rim and a choked interior, so the form has to + * be one whose thickness varies in a way the eye can read as a shape. A blob + * cannot do that — its silhouette carries no information — whereas a slab says + * "large through the middle, small at the edge" before any light is traced. The + * ground edge is deliberately wide and its depth ramp very nearly linear, like a + * chamfer rather than a fillet: a fillet reaches full depth within a few pixels + * of the silhouette, so the pale rim exists but is too narrow to see and the slab + * collapses back into one flat sheet with a hot outline. For + * every pixel the shader marches the real light path, fourteen steps from the + * front surface toward the source, accumulating the distance that stays between + * the slab's lower and upper skins. Beer-Lambert then attenuates each channel by + * exp(-σ·d), with σ taken as the complement of the tint and modulated by a + * banded strata field, so the ground edge passes a pale cream and the centre + * chokes down through amber to a deep ember, in that order, for the same reason + * real onyx does. The veining is banded along the slab rather than isotropic: + * strata read as stone, wandering noise reads as putty. A wrapped half-Lambert + * against the back face lets the terminator bleed around the edge roll, and a + * forward-scattering lobe blooms where the lamp sits directly behind a thin + * section. Front lighting is deliberately almost absent: a little polish + * specular, nothing more. The lamp is behind the stone and follows the pointer. + */ +export function TranslucentWax({ + className, + thickness = 1, + scatter = 1, + tint = "#f7d2a8", + absorption = 2.45, + bevel = 0.52, +}: TranslucentWaxProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uThickness: thickness, + uScatter: scatter, + uAbsorb: absorption, + uBevel: bevel, + }), + [tint, thickness, scatter, absorption, bevel], + ); + + const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +// Quintic interpolant rather than the usual smoothstep. The occupancy field is +// integrated along a light path, and cubic value noise has a discontinuous +// second derivative at every lattice line — which shows up in the transmission +// as faint polygonal creases across the stone. +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm(vec2 p) { + float a = 0.5, v = 0.0; + for (int i = 0; i < 4; i++) { + v += a * vnoise(p); + p = mat2(1.6, 1.2, -1.2, 1.6) * p; + a *= 0.5; + } + return v; +} + +float sdRoundBox(vec2 p, vec2 b, float r) { + vec2 q = abs(p) - b + r; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; +} + +float smax(float a, float b, float k) { + float h = clamp(0.5 + 0.5 * (a - b) / k, 0.0, 1.0); + return mix(b, a, h) + k * h * (1.0 - h); +} + +// The same field, but with the interior corner rounded. A box distance field has +// a gradient discontinuity along each diagonal, and because the ground edge here +// is wide those four creases run a long way in and meet near the middle — the +// depth field's own medial axis, printed across the slab as an envelope-flap X. +// Softening only the interior branch removes them without moving the silhouette: +// the outer branch, length(max(q,0)), is what sets the boundary and is untouched, +// and the k/4 bias smax adds along the diagonal is subtracted straight back off. +float sdSlabSoft(vec2 p, vec2 b, float r) { + vec2 q = abs(p) - b + r; + const float K = 0.13; + return min(smax(q.x, q.y, K) - 0.25 * K, 0.0) + length(max(q, 0.0)) - r; +} + +const vec2 SLAB = vec2(0.575, 0.325); +const float SLAB_R = 0.115; +// A few degrees off axis. A slab squared to the frame reads as a UI panel; the +// same slab tilted reads as an object that was placed there. +const mat2 TILT = mat2(0.99255, -0.12187, 0.12187, 0.99255); + +// Bedding planes. The band coordinate runs across the short axis of the slab and +// is warped by a stretched fbm — anisotropic on purpose, because the whole point +// is that the layers stay layers. Isotropic noise here is what made the earlier +// pass read as putty rather than as a cut stone. +float strata(vec2 sp, float t) { + vec2 w = sp * vec2(0.9, 2.3) + vec2(t * 0.055, 0.0); + float warp = fbm(w) - 0.5; + float u = sp.y * 7.6 + sp.x * 1.30 + warp * 2.0; + float band = 0.82 + 0.30 * sin(u * 2.1) + 0.17 * sin(u * 5.3 + 1.7) + 0.09 * sin(u * 11.0); + // Fine grain on top, small enough that it never competes with the banding. + band += 0.10 * (fbm(sp * 7.0 + 11.0) - 0.5); + return max(band, 0.28); +} + +// Interior fraction of the slab: 0 outside, 1 across the flat. The ground edge +// is where it ramps, and how wide that ramp is decides how much glowing rim +// there is to look at. +float slabK(vec2 sp, float bw) { + return max(-sdSlabSoft(sp, SLAB, SLAB_R), 0.0) / bw; +} + +// Cross-section against interior fraction. This is the single most important +// number in the file, because the *width of the thickness ramp* is what the eye +// reads as "light is coming through a solid thing". A circular fillet, or any +// power below 1, reaches full depth within a few pixels of the silhouette: the +// pale rim then exists but is two pixels wide, and the slab reads as one flat +// sheet of amber with a hot outline. A chamfer — depth rising very nearly +// linearly across a wide ground edge — spreads the whole pale-to-amber-to-ember +// ramp over sixty pixels, which is the only reason the gradient is legible. +// +// It saturates exponentially instead of being clamped. A clamp at full depth +// puts a kink in the depth field along the whole locus where it first bites, and +// because the normal is a difference of this function that kink prints as a hard +// rectangle drawn inside the slab — the plateau's own outline, which is not a +// feature of any real stone. +float profile(float x) { + return 1.0 - exp(-1.6 * pow(x, 1.15)); +} + +// Half-depth of the slab at this point, including a little relief in the +// bedding so the interior thickness is not perfectly constant. The relief is +// banded for the same reason the absorption is. +float halfDepth(vec2 sp, float t, float bw) { + float prof = profile(slabK(sp, bw)); + float lay = 0.5 + 0.5 * sin(sp.y * 7.4 + 1.9 * (fbm(sp * vec2(0.8, 2.0) + 4.0) - 0.5) * 3.0); + return prof * (0.90 + 0.10 * lay); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.5; + vec2 sp = TILT * uv; + float bw = max(0.03, uBevel * 0.30); + + float sd = sdRoundBox(sp, SLAB, SLAB_R); + float k = slabK(sp, bw); + float hd = halfDepth(sp, t, bw); + float top = 0.55 * uThickness * hd; + + // The lamp is behind the slab and drifts; the pointer takes it over. Kept well + // off centre: a lamp behind the middle lights the slab radially, and radial + // symmetry is what makes backlit things look like lamps instead of like stone + // on a light box. + vec2 lxy = mix(vec2(-0.30 + 0.44 * cos(t * 0.80), 0.24 * sin(t * 0.63 + 1.0)), pc, uPointer.z); + vec3 Lpos = vec3(lxy, -1.45 * uThickness); + + if (sd > 0.0) { + // Off the slab: the dark table, the lamp bleeding round the silhouette, and + // a warm contact line hugging the edge. + vec3 col = vec3(0.014, 0.013, 0.017); + col += uTint * 0.022 * exp(-length(uv - lxy) * 1.5); + col += mix(uTint, vec3(1.0), 0.30) * 0.30 * exp(-sd * 26.0); + col += uTint * 0.10 * exp(-sd * 7.0) * (0.35 + 0.65 * exp(-length(uv - lxy) * 1.2)); + col *= 1.0 - 0.42 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + fragColor = vec4(max(col, 0.0), 1.0); + return; + } + + float e = 1.7 / m; + vec3 N = normalize(vec3((halfDepth(sp - vec2(e, 0.0), t, bw) - halfDepth(sp + vec2(e, 0.0), t, bw)) * 0.55, + (halfDepth(sp - vec2(0.0, e), t, bw) - halfDepth(sp + vec2(0.0, e), t, bw)) * 0.55, + e)); + + vec3 Pw = vec3(uv, top); + vec3 L = normalize(Lpos - Pw); + + // March the real light path and measure how much of it lies inside the body. + // This is the whole point: thickness is measured, not inferred from a normal. + // Step length scaled to the local slab depth, not to a fixed worst case. With + // a constant span the jittered steps are enormous compared with the thickness + // of the ground edge, so the thin rim comes out as salt-and-pepper noise + // instead of a gradient. The small constant term lets a ray leaving a thin + // region still reach the thicker material next to it. + float span = (1.05 * uThickness * hd + 0.18 * uThickness) / max(0.20, -L.z); + float ds = span / 14.0; + float dist = 0.0; + // Start each pixel's march at a different fraction of a step. Fourteen steps + // on a lock-step grid quantise the thickness and print terraces straight into + // the transmission; jittering the phase turns that into noise the dither hides. + vec3 q = Pw + L * ds * hash(gl_FragCoord.xy * 1.37); + for (int i = 0; i < 14; i++) { + float qh = halfDepth(TILT * q.xy, t, bw); + if (q.z < -0.55 * uThickness * qh) break; + if (q.z < 0.55 * uThickness * qh) dist += ds; + q += L * ds; + } + // Floor on the optical depth. exp(-sigma*0) is 1 in every channel, so a path + // length that reaches zero at the silhouette transmits the lamp unchanged and + // rings the whole slab in white. Physically the light still has to cross the + // scattering skin, and one pixel spans a range of depths anyway. + dist = max(dist, 0.070 * uThickness); + + // Strata modulate the extinction coefficient, not the colour, so the bedding + // only shows where there is enough material for it to matter — which is why + // the layers fade out as they run into the ground edge, exactly as in a real + // cut slab. + float vein = strata(sp, t); + vec3 sigma = (1.0 - uTint * 0.94) * uAbsorb * 6.0 * vein; + vec3 trans = exp(-sigma * dist); + + // Wrapped diffuse against the back face — a half-Lambert with a wide wrap, so + // the terminator bleeds around the edge roll the way scattering media do. + float wrap = 0.55 * uScatter; + float back = clamp((dot(-N, L) + wrap) / (1.0 + wrap), 0.0, 1.0); + back *= back; + + // Forward scattering: light that keeps roughly its original direction after a + // few bounces, so thin sections right in front of the lamp glow out. + vec3 V = normalize(vec3(-uv * 0.6, 1.0)); + vec3 Lt = normalize(L + N * (0.30 * uScatter)); + float fd = clamp(dot(V, -Lt), 0.0, 1.0); + float fwd = pow(fd, 3.0 / uScatter) * 1.4 + pow(fd, 14.0) * 1.0; + + float atten = 1.0 / (1.0 + 0.55 * dot(Lpos.xy - uv, Lpos.xy - uv)); + + // Deep transmission shifts as well as darkens: an absorbing medium walks the + // hue, and in warm stone the last thing to survive is the red. Multiple + // scattering gives that floor a much longer tail than the ballistic term, so + // it gets the same sigma over a heavily shortened effective path rather than a + // constant — a constant floor is what made the first pass go *brighter* toward + // the middle, since nothing then attenuated with thickness at all. + // Diffusion is not a free pass: the multiply-scattered floor keeps losing + // energy too, so it gets its own extinction — mostly achromatic, because a + // random walk averages the channels, plus a share of the spectral sigma to + // keep the hue walking red. Giving it *only* the spectral part left the red + // channel almost flat with depth, which is why the slab read as one uniform + // sheet of amber instead of thick-and-deep against thin-and-pale. + vec3 sigmaD = vec3(0.30 * dot(sigma, vec3(0.3333))) + 0.16 * sigma; + vec3 deep = uTint * vec3(0.94, 0.70, 0.33) * exp(-sigmaD * dist); + + vec3 col = uTint * 0.008; + // Blend on the raw transmission, not on a scaled-and-clamped copy of it: the + // clamp stops the hue walking at a fixed thickness and prints a hard contour + // ring right through the middle of the slab. + col += mix(deep, trans, trans.g) * (0.55 + 0.62 * back) * atten * 1.20; + col += trans * fwd * atten * 0.42 * uScatter; + + // The ground edge glows hot and pale where almost no material is in the way. + // Kept tight, so it reads as an edge rather than as a bloom. + col += mix(uTint, vec3(1.0), 0.42) * exp(-dist * 8.0) * atten * 0.62; + + // Front side: only enough to say the surface is polished, not lit. + vec3 Kf = normalize(vec3(-0.45, 0.60, 0.66)); + col += vec3(0.72, 0.74, 0.80) * pow(max(dot(reflect(-Kf, N), V), 0.0), 60.0) * 0.12; + col += uTint * 0.025 * max(dot(N, Kf), 0.0); + // Grain, and a darker line right at the silhouette so the slab has an outline. + col *= 0.95 + 0.10 * fbm(sp * 5.0 + 12.0); + col *= 0.78 + 0.22 * smoothstep(0.0, 0.028, k); + + col = 1.0 - exp(-col * 1.20); + col *= 1.0 - 0.44 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.013; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/registry/reactomega/ui/velvet-sheen.tsx b/registry/reactomega/ui/velvet-sheen.tsx new file mode 100644 index 0000000..ef7ec82 --- /dev/null +++ b/registry/reactomega/ui/velvet-sheen.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface VelvetSheenProps { + className?: string; + /** Strength of the retroreflective sheen at grazing angles. @default 1 */ + sheen?: number; + /** Amount of fibre disorder in the nap, 0..1. @default 0.62 */ + fuzz?: number; + /** Dye colour of the pile. @default "#5b3fa8" */ + tint?: string; + /** Colour the folds fall into. @default "#07070d" */ + shadow?: string; +} + +/** + * VelvetSheen — a bolt of velvet lying in soft folds, lit by its own nap rather + * than by anything reflective. + * + * Fabric with a pile does not obey a normal specular model. Each fibre stands + * roughly upright, so light arriving almost parallel to the cloth grazes the + * whole length of the pile and scatters straight back, while light arriving + * face-on disappears down between the fibres. The BRDF here is that inversion: + * an Ashikhmin-style velvet distribution built on 1/(N·H)² over the fibre + * tangent, gated by an *inverted* Fresnel — pow(1 - N·V, 4) — so the cloth is + * brightest exactly where it turns away from you and darkest where it faces + * you. Diffuse is wrapped around the terminator with a subsurface half-Lambert, + * because dyed pile bleeds light sideways, and the fold flanks carry a fine + * fuzz normal from stretched noise plus a per-fibre density variance that + * scales the sheen. The result reads as textile because the highlight follows + * the silhouette of every fold instead of sitting on top of it. The pointer + * combs the nap. + */ +export function VelvetSheen({ + className, + sheen = 1, + fuzz = 0.62, + tint = "#5b3fa8", + shadow = "#07070d", +}: VelvetSheenProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uShadow: hexToRgb(shadow), + uSheen: sheen, + uFuzz: fuzz, + }), + [tint, shadow, sheen, fuzz], + ); + + const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm(vec2 p) { + float a = 0.5, v = 0.0; + for (int i = 0; i < 4; i++) { + v += a * vnoise(p); + p = mat2(1.6, 1.2, -1.2, 1.6) * p; + a *= 0.5; + } + return v; +} + +// Height of the draped cloth. Long soft folds along one axis plus a slow FBM +// sag, so the folds gather and release the way heavy fabric does. +float cloth(vec2 p, float t) { + // A bolt of cloth hangs in long parallel gathers. The noise only wanders the + // phase and amplitude of those gathers — added to the height directly it turns + // the drape into lumpy terrain, which is exactly what velvet never looks like. + float ph = fbm(p * 0.30 + vec2(0.0, t * 0.035)) * 2.9; + float amp = 0.72 + 0.60 * fbm(p * 0.26 + vec2(4.0, 1.0)); + float fold = sin(p.x * 3.05 + p.y * 0.42 + ph + t * 0.20) + + 0.44 * sin(p.x * 5.70 - p.y * 0.26 + ph * 0.7 - t * 0.14); + return fold * amp * 0.30; +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.45; + vec2 p = uv * 2.6; + + float e = 0.014; + float h = cloth(p, t); + vec3 N = normalize(vec3((cloth(p - vec2(e, 0.0), t) - cloth(p + vec2(e, 0.0), t)) * 3.1, + (cloth(p - vec2(0.0, e), t) - cloth(p + vec2(0.0, e), t)) * 3.1, + e * 0.72)); + + // Nap: fibres lie in ranks, so the disorder is stretched, not isotropic. + vec2 nq = uv * vec2(150.0, 420.0); + float f1 = vnoise(nq) - 0.5; + float f2 = vnoise(nq * 2.7 + 31.0) - 0.5; + vec3 fz = vec3(f1 * 1.0, f2 * 0.55, 0.0) * uFuzz * 0.16; + // The pointer combs the pile flat, which locally kills the sheen. + vec2 rel = uv - pc; + float comb = uPointer.z * exp(-dot(rel, rel) * 10.0); + vec3 Nf = normalize(N + fz - vec3(normalize(rel + 1e-5) * comb * 0.22, 0.0)); + + // Per-fibre density variance. Velvet is never uniformly bright; this is what + // separates cloth from a glowing outline. + float dens = 0.70 + 0.36 * fbm(uv * 48.0 + 5.0) + 0.16 * (vnoise(uv * vec2(120.0, 340.0)) - 0.5); + + vec3 V = normalize(vec3(-uv * 0.55, 1.0)); + vec3 L = normalize(vec3(mix(vec2(-0.62, 0.30), pc, uPointer.z) - uv * 0.4, 0.50)); + vec3 H = normalize(L + V); + + float NdV = clamp(dot(Nf, V), 0.001, 1.0); + float NdL = dot(Nf, L); + float NdH = clamp(dot(Nf, H), 0.001, 1.0); + + // Wrapped diffuse. Pile scatters sideways, so the terminator bleeds well past + // where a Lambert surface would already be black. + float wrap = 0.42; + float diff = clamp((NdL + wrap) / (1.0 + wrap), 0.0, 1.0); + diff *= diff; + + // Ashikhmin velvet lobe over the fibre tangent, gated by an inverted Fresnel. + // Both factors peak where the surface turns edge-on to the eye. + float sinTH2 = max(0.0, 1.0 - NdH * NdH); + float velvet = (sinTH2 * sinTH2) / (NdH * NdH * NdH * NdH + 1e-4); + velvet = min(velvet, 9.0); + float invFres = pow(1.0 - NdV, 5.0); + float retro = pow(clamp(dot(-V, -L) * 0.5 + 0.5, 0.0, 1.0), 2.0); + + // Fibre-scale modulation of the sheen itself, not just of the normal: the pile + // lies in ranks and the ranks glint separately. Without this the sheen bands + // come out as smooth airbrushed gradients and lose the textile read entirely. + float rank = 0.58 + 0.72 * vnoise(uv * vec2(210.0, 55.0) + 4.0) + + 0.26 * (vnoise(uv * vec2(38.0, 620.0)) - 0.5); + float sheen = uSheen * dens * rank * max(NdL + 0.30, 0.0) + * (0.055 * velvet * invFres + 1.15 * invFres * (0.28 + 0.72 * retro)); + + // Ambient occlusion from the fold depth: the bottoms of the gathers go dark + // even where they are turned toward the light. + float ao = smoothstep(-0.42, 0.60, h) * 0.62 + 0.38; + + vec3 pileLo = uShadow + uTint * 0.085; + vec3 body = mix(pileLo, uTint * 0.90, diff * ao); + vec3 sheenCol = mix(uTint * 0.55 + vec3(0.30, 0.32, 0.42), vec3(0.72, 0.74, 0.86), 0.45); + + // The pile itself is held down hard. Velvet is a dark cloth; if the body is + // bright the sheen has nothing to be brighter than and the fabric cue dies. + vec3 col = body * (0.20 + 0.40 * ao); + col += sheenCol * sheen * ao * 0.52; + col += uTint * 0.10 * comb; + + col = 1.0 - exp(-col * 1.62); + col *= 1.0 - 0.52 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.013; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; From 4507f3bc1d3ccc408d06212461e35f49e6d66984 Mon Sep 17 00:00:00 2001 From: Ed Chen <37851723+Edwson@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:10:47 +0800 Subject: [PATCH 2/8] playground: demo wrappers for the 6 new shaders --- web/components/reactomega/brushed-metal.tsx | 211 ++++++++++++ .../reactomega/diffraction-grating.tsx | 251 ++++++++++++++ web/components/reactomega/moire-weave.tsx | 174 ++++++++++ web/components/reactomega/refracted-glass.tsx | 186 ++++++++++ web/components/reactomega/translucent-wax.tsx | 320 ++++++++++++++++++ web/components/reactomega/velvet-sheen.tsx | 185 ++++++++++ 6 files changed, 1327 insertions(+) create mode 100644 web/components/reactomega/brushed-metal.tsx create mode 100644 web/components/reactomega/diffraction-grating.tsx create mode 100644 web/components/reactomega/moire-weave.tsx create mode 100644 web/components/reactomega/refracted-glass.tsx create mode 100644 web/components/reactomega/translucent-wax.tsx create mode 100644 web/components/reactomega/velvet-sheen.tsx diff --git a/web/components/reactomega/brushed-metal.tsx b/web/components/reactomega/brushed-metal.tsx new file mode 100644 index 0000000..23fc789 --- /dev/null +++ b/web/components/reactomega/brushed-metal.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface BrushedMetalProps { + className?: string; + /** `"linear"` for a straight-grain finish, `"radial"` for engine-turned. @default "linear" */ + pattern?: "linear" | "radial"; + /** How far the highlight is stretched across the grain, 0..1. @default 0.88 */ + anisotropy?: number; + /** Base roughness of the polish, 0..1. @default 0.34 */ + roughness?: number; + /** Colour the metal reflects. @default "#b9c8f0" */ + tint?: string; + /** Speed the light orbits at. @default 1 */ + speed?: number; +} + +/** + * BrushedMetal — a still, machined surface. Nothing about the metal moves; only + * the light does, and the highlight it drags is the entire subject. + * + * The grain is a direction field — constant for a linear finish, tangential + * around the centre for an engine-turned one — and the abrasive scratches are + * value noise sampled on coordinates stretched forty to one along that + * direction, so every groove runs with the grain. Lighting is an anisotropic + * GGX lobe: the roughness along the grain is held low while the roughness + * across it is pushed up by `anisotropy`, and because a microfacet + * distribution spreads reflections in the direction it is rough, the specular + * comes out as a long streak lying *perpendicular* to the brushing. That + * asymmetry is the whole tell of brushed metal, and it is computed rather than + * drawn. Smith-correlated shadowing keeps the grazing rim from blowing out, + * and the pointer takes the light over. + */ +export function BrushedMetal({ + className, + pattern = "linear", + anisotropy = 0.88, + roughness = 0.34, + tint = "#b9c8f0", + speed = 1, +}: BrushedMetalProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uRadial: pattern === "radial" ? 1 : 0, + uAniso: anisotropy, + uRough: roughness, + }), + [tint, pattern, anisotropy, roughness], + ); + + const { ref, supported } = useShader({ speed, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +const float PI = 3.14159265; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + // Quintic, not cubic: the milling term is sampled at a low frequency, and + // cubic value noise creases visibly along its lattice lines when it is. + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +// Scratch depth at a point already expressed in (along-grain, across-grain) +// coordinates. Three bands of abrasive grit, each stretched hard along the grain. +float grooves(vec2 g) { + float v = vnoise(vec2(g.x * 0.9, g.y * 40.0)) - 0.5; + v += 0.62 * (vnoise(vec2(g.x * 2.1 + 11.0, g.y * 130.0)) - 0.5); + v += 0.34 * (vnoise(vec2(g.x * 4.3 - 7.0, g.y * 420.0)) - 0.5); + return v; +} + +// Anisotropic GGX. Rough across the grain, smooth along it — a microfacet lobe +// spreads light in whichever direction it is rough, so the highlight ends up +// lying across the brushing rather than with it. +float ggxAniso(vec3 H, vec3 T, vec3 B, vec3 N, float ax, float ay) { + float ht = dot(H, T) / ax; + float hb = dot(H, B) / ay; + float hn = dot(H, N); + float w = ht * ht + hb * hb + hn * hn; + return 1.0 / (PI * ax * ay * w * w); +} + +float smithG(vec3 V, vec3 T, vec3 B, vec3 N, float ax, float ay) { + float vn = max(dot(V, N), 1e-4); + float vt = dot(V, T) * ax; + float vb = dot(V, B) * ay; + float a2 = (vt * vt + vb * vb) / (vn * vn); + return 2.0 / (1.0 + sqrt(1.0 + a2)); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.35; + + // Grain direction field. Radial mode is a turned finish, so the grain runs + // tangentially and the grooves become concentric. + vec2 rad = uv - vec2(0.06, -0.04); + float rl = max(length(rad), 1e-4); + vec2 tanDir = vec2(-rad.y, rad.x) / rl; + vec2 lin = normalize(vec2(0.995, 0.100)); + vec2 Td = normalize(mix(lin, tanDir, uRadial)); + + // A slow bow in the grain — dead-straight brushing reads as a CSS gradient. + float bow = (vnoise(uv * vec2(1.4, 2.6) + 17.0) - 0.5) * 0.16 * (1.0 - uRadial); + Td = normalize(Td + vec2(-Td.y, Td.x) * bow); + vec2 Bd = vec2(-Td.y, Td.x); + + // Grain-local coordinates: x along the brush, y across it. For a turned finish + // the brush runs *around* the centre, so the fast axis has to be the radius — + // put the angle there instead and the grooves come out as radial spokes, which + // is a completely different machining operation. + // atan2 jumps by 2*pi across its branch cut, and since the angle is the + // slow axis of the grain that jump prints a hard seam straight out from the + // spindle. Folding to |theta| removes the discontinuity entirely; the mirror + // it leaves along the other side is invisible because the noise varies barely + // at all in that direction. + vec2 g = mix(vec2(dot(uv, Td), dot(uv, Bd)), + vec2(abs(atan(rad.y, rad.x)) * 1.35, rl * 0.85), uRadial); + + float e = 1.0 / m; + float d = grooves(g); + float dx = grooves(g + vec2(0.0, e * 0.8)) - d; + // Only the across-grain derivative matters; a groove has no slope along itself. + float amp = 0.16 + 0.85 * uRough; + vec3 N = normalize(vec3(Bd * (-dx * amp / e) * 0.010, 1.0)); + + // Broad milling undulation, so large areas catch light differently. + float mill = (vnoise(g * vec2(1.6, 4.2) + 3.0) - 0.5) + + 0.5 * (vnoise(g * vec2(3.7, 9.5) - 8.0) - 0.5); + N = normalize(N + vec3(Bd * mill * 0.10, 0.0) + vec3(Td * mill * 0.03, 0.0)); + + vec3 T3 = normalize(vec3(Td, 0.0) - N * dot(N, vec3(Td, 0.0))); + vec3 B3 = normalize(cross(N, T3)); + vec3 V = normalize(vec3(-uv * 0.45, 1.0)); + + // The light orbits until the pointer claims it. + vec2 lp = mix(vec2(0.50 * cos(t * 0.9 + 0.6), 0.30 * sin(t * 0.7)), pc, uPointer.z); + vec3 L = normalize(vec3(lp - uv, 0.95)); + vec3 H = normalize(L + V); + + float ax = max(0.010, uRough * uRough * (1.0 - 0.94 * uAniso)); + float ay = max(0.020, uRough * uRough * (1.0 + 7.0 * uAniso)); + + float NdL = max(dot(N, L), 0.0); + float NdV = max(dot(N, V), 1e-3); + float D = ggxAniso(H, T3, B3, N, ax, ay); + float G = smithG(L, T3, B3, N, ax, ay) * smithG(V, T3, B3, N, ax, ay); + float F = 0.62 + 0.38 * pow(1.0 - max(dot(H, V), 0.0), 5.0); + float spec = D * G * F * NdL / (4.0 * NdV); + + // Second, much broader lobe: real brushed metal shows a wide sheen band far + // from the hot streak, and without it the plate looks like bare noise. + float ax2 = ax * 6.0 + 0.06; + float ay2 = min(1.0, ay * 2.2 + 0.30); + float sheen = ggxAniso(H, T3, B3, N, ax2, ay2) * NdL * 0.14; + + // Falloff of the light itself, so the plate has a lit end and a dark end. + float falloff = 1.0 / (1.0 + 2.6 * dot(uv - lp, uv - lp)); + + // A cool overhead gradient standing in for the room, so the plate has a body + // tone away from the streak. A milled part in a dark studio is not black. + vec3 room = mix(vec3(0.014, 0.017, 0.030), vec3(0.070, 0.082, 0.130), + smoothstep(-0.5, 0.7, dot(N, normalize(vec3(0.1, 0.9, 0.35))))); + + // The spindle centre has no defined grain direction, so ease the anisotropy + // out there rather than letting it converge into a bright knot. + float hub = mix(1.0, smoothstep(0.005, 0.055, rl), uRadial); + vec3 col = uTint * room; + col += uTint * (0.010 + 0.085 * NdL) * falloff; + col += uTint * clamp(spec, 0.0, 40.0) * 0.075 * falloff * hub; + col += uTint * sheen * falloff * 2.1 * hub; + col += vec3(1.0) * clamp(spec, 0.0, 40.0) * 0.022 * falloff * hub; + + // Anodised rim shade and a faint dirt in the grain valleys. + col *= 1.0 - 0.20 * smoothstep(0.0, 0.6, -d); + col = 1.0 - exp(-col * 1.55); + col *= 1.0 - 0.46 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.014; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/web/components/reactomega/diffraction-grating.tsx b/web/components/reactomega/diffraction-grating.tsx new file mode 100644 index 0000000..30be25e --- /dev/null +++ b/web/components/reactomega/diffraction-grating.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface DiffractionGratingProps { + className?: string; + /** Groove spacing in nanometres. A CD is 1600, a DVD 740, embossed foil ~3200. @default 2450 */ + pitch?: number; + /** How many spectral orders either side of the specular are kept. @default 3 */ + orders?: number; + /** Resolving power, 0..1 — how saturated each spectral line stays. @default 0.72 */ + sharpness?: number; + /** Colour of the metal under the grating. @default "#c9d8ff" */ + tint?: string; +} + +/** + * DiffractionGrating — the surface of a CD, or holographic foil: hard spectral + * streaks that jump position as the light moves, not a soft pastel wash. + * + * The grooves run in concentric arcs and the eye sits at a finite distance, so + * the view direction genuinely varies across the frame. From that geometry the + * shader builds the grating path difference s = d·(sinθ_in + sinθ_out) by + * projecting the light and view vectors onto the groove vector, and then simply + * solves d·sinθ = mλ for the wavelength: order m sends λ = s/m to the eye at + * this pixel, and nothing else. Solving for λ rather than integrating over a + * handful of sampled wavelengths is what makes the streaks continuous — sampled + * spectra bead into rows of coloured dots, because each sample resonates a few + * pixels away from the last. Each order is therefore a smooth ramp through the + * spectrum, cut off exactly where λ leaves the visible band. Because the pitch + * is coarse the path difference climbs steeply across the frame, which is what + * keeps each order a thin line rather than a wide band — sharp spectral lines + * read as optics, wide soft ones read as decoration. The energy is weighted the + * way a real grating weights it: the blaze falloff drops m=±2 to about a third of + * m=±1 and m=±3 to a tenth, and `sharpness` — the resolving power mλ/Δλ — + * additionally washes the high orders toward white, because the same physical + * groove count buys less resolution across a wider order and neighbouring + * wavelengths start overlapping at the eye. So the low orders are the saturated + * ones and the high orders are dim *and* pale, instead of three equal rainbows. + * Under all of it the substrate is a real surface, not a void: the pressed track + * gives a fine band-limited ruling (sinc-filtered against the pixel footprint, + * so it dissolves into its own mean rather than aliasing), a coarser sector + * banding gives structure at a scale the eye can hold, and a broad dim specular + * lobe squashed along the ruling supplies the oily sheen a disc carries + * everywhere the rainbows are not. The zeroth order is achromatic and is kept + * aside as a plain specular; the pointer takes the lamp, which walks the whole + * spectrum across the disc. + */ +export function DiffractionGrating({ + className, + pitch = 2450, + orders = 3, + sharpness = 0.72, + tint = "#c9d8ff", +}: DiffractionGratingProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uPitch: pitch, + uOrders: orders, + uSharp: sharpness, + }), + [tint, pitch, orders, sharpness], + ); + + const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +// A cosine convolved with the pixel footprint. The box filter of cos(2*pi*phi) +// over a width of w cycles is exactly sinc(w) = sin(pi*w)/(pi*w) — zero when the +// period reaches two pixels. The track structure below runs at three or four +// pixels a cycle and fans as it goes, so without this it would alias into +// crawling noise and take the spectra down with it. +float bandCos(float phi, float w) { + float a = 1.0; + if (w > 1e-4) a = clamp(sin(3.14159265 * w) / (3.14159265 * w), 0.0, 1.0); + return cos(6.2831853 * phi) * a; +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +// Rough sRGB response to a single wavelength in nanometres. Sums of gaussians +// rather than a hue ramp, so the band order and the muddy cyan-green at 500nm +// come out where a real spectrum puts them. +vec3 spectral(float l) { + vec3 c; + c.r = 1.06 * exp(-pow((l - 604.0) / 56.0, 2.0)) + + 0.46 * exp(-pow((l - 700.0) / 54.0, 2.0)) + + 0.20 * exp(-pow((l - 432.0) / 26.0, 2.0)); + c.g = 1.02 * exp(-pow((l - 542.0) / 52.0, 2.0)) + + 0.34 * exp(-pow((l - 592.0) / 38.0, 2.0)); + c.b = 1.14 * exp(-pow((l - 452.0) / 42.0, 2.0)) + + 0.32 * exp(-pow((l - 484.0) / 38.0, 2.0)); + return c; +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.4; + + // Foil relief: an extremely shallow crinkle. The path difference is a + // wavelength-scale quantity, so a normal that wanders even slightly shreds the + // orders into contour noise. Almost all of the variation has to come from the + // view geometry instead. + float e = 0.020; + float relief = vnoise(uv * 1.5 + vec2(t * 0.05, -t * 0.04)); + float rx = vnoise((uv + vec2(e, 0.0)) * 1.5 + vec2(t * 0.05, -t * 0.04)); + float ry = vnoise((uv + vec2(0.0, e)) * 1.5 + vec2(t * 0.05, -t * 0.04)); + vec3 N = normalize(vec3((relief - rx) * 0.055 / e, (relief - ry) * 0.055 / e, 1.0)); + + // Grooves in concentric arcs about a centre well outside the frame: near + // parallel, fanning slightly. That keeps the path difference monotonic across + // the frame, which is the only way the orders separate into clean streaks. + vec2 rad = uv - vec2(-3.1, -1.35); + vec2 gDir = normalize(rad); + float swirl = (vnoise(uv * 1.15 + 9.0) - 0.5) * 0.16; + gDir = normalize(gDir + vec2(-gDir.y, gDir.x) * swirl); + vec3 G = normalize(vec3(gDir, 0.0) - N * dot(N, vec3(gDir, 0.0))); + + // Track structure. The grooves that do the diffracting are a wavelength or two + // apart — far below a pixel, and drawing them would only alias. What you + // actually see on a disc is the coarser banding of the pressed track: hundreds + // of grooves to a visible line. Concentric about the same centre as the + // grating vector, because it is the same ruling, and band-limited because it + // runs at three or four pixels a cycle and fans as it goes. + float rl = length(rad); + float gph = rl * 74.0 + 1.4 * vnoise(uv * 2.2 + 3.0); + float groove = 0.5 + 0.5 * bandCos(gph, fwidth(gph)); + // A far coarser second banding — the pressed sectors — so the surface has + // structure at a scale the eye can hold as well as one it can only resolve. + float sect = 0.5 + 0.5 * bandCos(rl * 5.5 - 0.3, fwidth(rl * 5.5)); + + // Finite eye distance: the view direction is what makes s position-dependent. + vec3 V = normalize(vec3(-uv, 0.78)); + vec2 lxy = mix(vec2(0.30 + 0.55 * cos(t * 0.5 + 1.2), 0.34 * sin(t * 0.38)), pc, uPointer.z); + vec3 L = normalize(vec3(lxy - uv, 0.62)); + + // d * (sin(theta_in) + sin(theta_out)), both angles projected onto the groove + // vector. This single scalar is the entire grating equation. + float s = uPitch * (dot(V, G) + dot(L, G)); + + float NdL = max(dot(N, L), 0.0); + float atten = 1.0 / (1.0 + 1.2 * dot(uv - lxy, uv - lxy)); + + // Rate of change of the path difference, in nanometres per pixel. It sets how + // wide the band edges have to be feathered to stay smooth at any zoom. + float ws = max(fwidth(s), 1e-4); + float purity = clamp(uSharp, 0.0, 1.0); + + vec3 fan = vec3(0.0); + for (int mi = 1; mi <= 4; mi++) { + float mm = float(mi); + if (mm > uOrders + 0.5) break; + // The grating equation, solved for wavelength instead of for position. + float lam = abs(s) / mm; + float wl = ws / mm; + // Order m only exists here if the wavelength it wants is one we can see. + float band = smoothstep(0.0, 2.0 * wl + 5.0, lam - 398.0) + * smoothstep(0.0, 2.0 * wl + 5.0, 712.0 - lam); + // Blaze falloff: a real grating throws most of its energy into the low + // orders, and steeply. Three equally bright bands is the single thing that + // makes a grating read as a rainbow gradient instead of as optics. + float eff = 1.0 / (1.0 + 2.2 * (mm - 1.0) * (mm - 1.0)); + // Finite resolving power R = mN. The *same* physical groove count buys less + // resolution per unit wavelength as m rises relative to the width of the + // order, so the high orders both dim and wash toward white — they overlap + // themselves. Dimming alone would leave them fully saturated and still + // reading as ribbon. + float pur = purity / (1.0 + 0.90 * (mm - 1.0)); + vec3 sc = spectral(lam); + sc = mix(vec3(dot(sc, vec3(0.32, 0.55, 0.13))) * 1.32, sc, 0.24 + 0.58 * pur); + fan += sc * band * eff; + } + fan *= 1.18; + + // Zeroth order: ordinary mirror specular off the foil, plus the anisotropic + // smear a grooved surface gives it along the groove direction. + vec3 H = normalize(L + V); + float NdH = max(dot(N, H), 0.0); + float along = dot(H, G); + float spec = pow(NdH, 900.0) * 1.6 + + pow(NdH, 90.0) * 0.10 * exp(-along * along * 14.0); + + // Dark polycarbonate over aluminium. The substrate has to read as a *surface*: + // an empty black field between the orders is what left the earlier pass + // looking like three neon ribbons floating on nothing, because a spectrum with + // no object under it is just a gradient. + vec3 base = uTint * (0.030 + 0.060 * NdL); + base += uTint * 0.046 * pow(1.0 - abs(dot(V, N)), 2.4); + // Broad low specular lobe. Very wide, very dim, anisotropically squashed along + // the ruling: the oily sheen a disc carries everywhere the rainbows are not. + // This single term is what the spectra end up sitting on. + base += uTint * 0.38 * pow(NdH, 4.5) * (0.26 + 0.74 * exp(-along * along * 2.0)) * atten; + base += uTint * 0.085 * exp(-along * along * 3.0) * atten; + // The track modulates everything reflective, and hardest at grazing incidence + // where the ridges shadow one another. + base *= 0.70 + 0.56 * groove; + // Structure at a scale the eye can actually hold, as well as one it can only + // just resolve. With only the fine ruling, everywhere the spectra are not goes + // back to being a flat field — which was the original complaint about the + // substrate, and the fine banding alone does not answer it. + base *= 0.84 + 0.30 * sect; + base *= 0.90 + 0.22 * vnoise(uv * 1.3 + 17.0); + base += uTint * 0.014 * sect * groove; + + vec3 col = base; + // The spectra come off the ridges, so they carry the ruling too — faintly, or + // the fine banding starts competing with the orders for attention. + col += fan * mix(vec3(1.0), uTint, 0.18) * atten * (0.26 + 0.98 * NdL) + * (0.82 + 0.26 * groove); + col += vec3(1.0) * spec * atten * 0.42 * (0.62 + 0.52 * groove); + // Faint second-surface haze so the black between orders is not empty. + col += uTint * 0.024 * exp(-length(uv - lxy) * 1.6); + + col = 1.0 - exp(-col * 1.22); + col *= 1.0 - 0.36 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/web/components/reactomega/moire-weave.tsx b/web/components/reactomega/moire-weave.tsx new file mode 100644 index 0000000..df6c811 --- /dev/null +++ b/web/components/reactomega/moire-weave.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface MoireWeaveProps { + className?: string; + /** Lattice period in device pixels. Below about 2.5 the filter takes over. @default 8 */ + pitch?: number; + /** Angle between the two lattices, in degrees. Small angles give huge fringes. @default 4.5 */ + angle?: number; + /** `true` weaves the two thread sets over and under, `false` leaves flat line screens. @default true */ + weave?: boolean; + /** Colour of the lit threads. @default "#a8bcff" */ + tint?: string; +} + +/** + * MoireWeave — two rigid high-frequency lattices laid over each other at a few + * degrees, where the enormous soft fringes are interference between them and + * not a pattern anyone drew. + * + * Each lattice is a pair of cosine thread screens with an exact phase, so the + * beat visible across the frame is genuinely the difference frequency k1 - k2: + * shrink the angle and the fringes grow without bound, which is the signature + * of real moiré. The lattices sit on a slightly tilted plane, which means the + * period measured in pixels compresses toward the top of the frame and runs + * straight at the sampling limit — so every cosine is band-limited before it is + * used. Each thread is convolved with the pixel footprint analytically, the box + * filter of cos(2πφ) being sinc(w) with w = fwidth(φ) in cycles per pixel: the + * amplitude decays to exactly zero as the period reaches two pixels and the + * lattice dissolves into its own mean grey instead of boiling into noise. In + * weave mode a third band-limited cosine on φ₁+φ₂ decides which thread set + * passes over at each crossing. The pointer swells the local pitch. + */ +export function MoireWeave({ + className, + pitch = 8, + angle = 4.5, + weave = true, + tint = "#a8bcff", +}: MoireWeaveProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uPitch: pitch, + uAngle: angle, + uWeave: weave ? 1 : 0, + }), + [tint, pitch, angle, weave], + ); + + const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +const float PI = 3.14159265; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +// A cosine convolved with the pixel footprint. The box filter of cos(2*pi*phi) +// over a width of w cycles is exactly sinc(w) = sin(pi*w)/(pi*w) — zero when the +// period hits two pixels. This single line is the difference between moire and +// a screenful of crawling noise. +float bandCos(float phi, float w) { + float a = 1.0; + if (w > 1e-4) a = clamp(sin(PI * w) / (PI * w), 0.0, 1.0); + return cos(2.0 * PI * phi) * a; +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.25; + + // A mild tilt away from the viewer. This is not decoration: it forces the + // period in pixels to sweep through the whole range up to Nyquist, so the + // filter is doing visible work in every frame. + float z = 1.0 + 2.35 * (uv.y + 0.5); + vec2 P = (gl_FragCoord.xy - 0.5 * uResolution) * z; + + // The pointer swells the local pitch — the fringes rearrange around it, + // because a pitch change is a frequency change and the beat follows. + vec2 rel = uv - pc; + float swell = 1.0 - uPointer.z * 0.30 * exp(-dot(rel, rel) * 8.0); + + float f = 1.0 / max(2.0, uPitch * swell); + // Only the relative angle is animated. The fringe scale goes as 1/angle, so a + // half-degree drift is a very large change in what you see. + float a1 = radians(-0.5 * uAngle + 1.1 * sin(t * 0.5)) + 0.06 * sin(t * 0.31); + float a2 = radians(0.5 * uAngle + 1.1 * sin(t * 0.5 + 2.2)) + 0.06 * sin(t * 0.31); + vec2 k1 = f * vec2(cos(a1), sin(a1)); + vec2 k2 = f * 1.008 * vec2(cos(a2), sin(a2)); + + // Warp and weft of each lattice. + float p1 = dot(P, k1); + float q1 = dot(P, vec2(-k1.y, k1.x)); + float p2 = dot(P, k2); + float q2 = dot(P, vec2(-k2.y, k2.x)); + + float w1 = fwidth(p1), v1 = fwidth(q1); + float w2 = fwidth(p2), v2 = fwidth(q2); + + float A = bandCos(p1, w1), Ab = bandCos(q1, v1); + float B = bandCos(p2, w2), Bb = bandCos(q2, v2); + + // Over/under at each crossing, from a band-limited cosine on the sum phase. + float ck1 = 0.5 + 0.5 * bandCos((p1 + q1) * 0.5, fwidth((p1 + q1) * 0.5)); + float ck2 = 0.5 + 0.5 * bandCos((p2 + q2) * 0.5, fwidth((p2 + q2) * 0.5)); + + // Woven: the over/under decides which thread set is visible at each crossing. + // Unwoven: plain single-direction line screens, which is the textbook pairing + // and gives much cleaner fringes because only one frequency beats per lattice. + float l1 = mix(0.5 + 0.5 * A, mix(0.5 + 0.5 * Ab, 0.5 + 0.5 * A, ck1), uWeave); + float l2 = mix(0.5 + 0.5 * B, mix(0.5 + 0.5 * Bb, 0.5 + 0.5 * B, ck2), uWeave); + + // Superposition. Two overlaid screens multiply their transmittances; the beat + // is emergent, and this is where it comes from. + float sup = l1 * l2; + + // The same beat written out analytically at the difference frequency. Used + // only as a lighting envelope, so the fringes still read once the lattices + // themselves have been filtered away to grey near the horizon. + vec2 kd = k1 - k2; + float beat = 0.5 + 0.5 * bandCos(dot(P, kd), fwidth(dot(P, kd))); + vec2 kd2 = k1 - vec2(-k2.y, k2.x); + float beat2 = 0.5 + 0.5 * bandCos(dot(P, kd2), fwidth(dot(P, kd2))); + float env = mix(beat, beat2, 0.42); + + // Thread shading: a cylindrical cross-section catches light off to one side, + // which is what stops a woven surface looking like printed squares. + float lit = 0.5 + 0.5 * bandCos(p1 - 0.22, w1); + float lit2 = 0.5 + 0.5 * bandCos(q2 + 0.22, v2); + + vec3 warm = uTint; + vec3 cool = vec3(0.09, 0.11, 0.30); + + vec3 col = vec3(0.012, 0.014, 0.028); + col += mix(cool * 0.45, warm, smoothstep(0.06, 0.72, sup)) * (0.10 + 1.20 * pow(sup, 1.20)); + // Fringe lighting: crests of the beat get the specular, troughs go blue-black. + col *= 0.24 + 1.50 * pow(env, 1.9); + col += warm * pow(env, 4.0) * 0.42; + col += vec3(0.85, 0.90, 1.0) * pow(sup, 3.4) * pow(env, 3.0) * 0.14; + col += warm * 0.16 * lit * lit2 * env; + + // A broad key so the frame has a lit corner rather than uniform coverage. + col *= 0.55 + 0.85 * exp(-length(uv - vec2(-0.30, 0.16)) * 1.5); + + col = 1.0 - exp(-col * 1.70); + col *= 1.0 - 0.38 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/web/components/reactomega/refracted-glass.tsx b/web/components/reactomega/refracted-glass.tsx new file mode 100644 index 0000000..1718470 --- /dev/null +++ b/web/components/reactomega/refracted-glass.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface RefractedGlassProps { + className?: string; + /** Optical depth of the slab — how far the bent ray travels before it exits. @default 0.34 */ + thickness?: number; + /** Refractive index of the body. 1.5 is crown glass, 1.9 reads as sapphire. @default 1.52 */ + ior?: number; + /** Spread between the red and blue indices. 0 is achromatic, 1 is showy. @default 1 */ + dispersion?: number; + /** Width of the bevelled edge as a fraction of the panel, 0..1. @default 0.34 */ + bevel?: number; + /** Absorption colour of the glass body. @default "#9fc0ff" */ + tint?: string; +} + +/** + * RefractedGlass — a thick bevelled slab of glass laid over a procedural + * backdrop, refracting it rather than blurring it. + * + * The panel is a rounded-box SDF whose interior distance is lifted into a + * circular fillet, so the surface normal swings from straight-up in the middle + * to almost horizontal at the rim. The view ray is then refracted through that + * normal with Snell's law — separately for three indices, red low and blue + * high — and each channel samples the backdrop at its own exit point. Because + * the three exit points only diverge where the normal is steep, dispersion + * appears exactly where real glass shows it: hugging the bevel, absent across + * the flat. A Schlick Fresnel term on the same normal lights the rim, the + * fillet gathers a converging band of light a third of the way up its slope, + * and the transmitted colour is attenuated by the body tint. The pointer + * drags the reflected highlight across the panel. + */ +export function RefractedGlass({ + className, + thickness = 0.34, + ior = 1.52, + dispersion = 1, + bevel = 0.34, + tint = "#9fc0ff", +}: RefractedGlassProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uThickness: thickness, + uIor: ior, + uDispersion: dispersion, + uBevel: bevel, + }), + [tint, thickness, ior, dispersion, bevel], + ); + + const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +// What the glass is sitting on. Deliberately built from soft blooms *and* hard +// diagonal bars: the blooms sell depth, but only an edge makes dispersion legible. +vec3 backdrop(vec2 q, float t) { + vec3 c = vec3(0.016, 0.020, 0.042); + c += vec3(0.26, 0.40, 0.92) * 0.62 * exp(-length((q - vec2(-0.52, 0.26)) * vec2(1.0, 1.25)) * 2.6); + c += vec3(0.52, 0.30, 0.86) * 0.44 * exp(-length((q - vec2(0.54, -0.30)) * vec2(1.0, 1.3)) * 3.0); + c += vec3(0.16, 0.52, 0.70) * 0.22 * exp(-length(q - vec2(0.10, 0.52)) * 3.4); + float b = sin((q.x * 0.62 + q.y * 1.55) * 8.5 - t * 0.42); + c += vec3(0.70, 0.80, 1.00) * 0.52 * smoothstep(0.86, 0.995, b); + float b2 = sin((q.x * 1.30 - q.y * 0.70) * 5.0 + t * 0.28); + c += vec3(0.34, 0.54, 1.00) * 0.30 * smoothstep(0.88, 1.00, b2); + return c; +} + +float sdRoundBox(vec2 p, vec2 b, float r) { + vec2 q = abs(p) - b + r; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; +} + +// Interior distance lifted into a quarter-circle fillet. sqrt(1-(1-k)^2) has an +// infinite slope at the rim, which is precisely what a real bevel does to a normal. +float slabHeight(vec2 p, float bw) { + float k = clamp(-sdRoundBox(p, vec2(0.655, 0.352), 0.085) / bw, 0.0, 1.0); + return sqrt(max(0.0, 1.0 - (1.0 - k) * (1.0 - k))); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime; + float bw = max(0.02, uBevel * 0.30); + float sd = sdRoundBox(uv, vec2(0.655, 0.352), 0.085); + + if (sd > 0.0) { + // Outside the panel: the raw backdrop, plus the shadow the slab casts and a + // thin sliver of light leaking out along the ground contact. + vec3 col = backdrop(uv, t) * (1.0 - 0.62 * exp(-sd * 7.0)); + col += uTint * 0.16 * exp(-sd * 60.0); + col *= 1.0 - 0.40 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + fragColor = vec4(max(col, 0.0), 1.0); + return; + } + + float e = 1.6 / m; + float h = slabHeight(uv, bw); + vec3 N = normalize(vec3((slabHeight(uv - vec2(e, 0.0), bw) - slabHeight(uv + vec2(e, 0.0), bw)) * 0.42, + (slabHeight(uv - vec2(0.0, e), bw) - slabHeight(uv + vec2(0.0, e), bw)) * 0.42, + e)); + + vec3 I = vec3(0.0, 0.0, -1.0); + float d0 = uDispersion * 0.125; + // Cauchy ordering: blue is bent hardest, so the blue fringe always lands + // further in from the rim than the red one. + float travel = uThickness * (0.70 + 0.30 * h); + vec2 oR = vec2(0.0), oG = vec2(0.0), oB = vec2(0.0); + vec3 tR = refract(I, N, 1.0 / max(1.02, uIor - d0)); + vec3 tG = refract(I, N, 1.0 / max(1.02, uIor)); + vec3 tB = refract(I, N, 1.0 / max(1.02, uIor + d0)); + if (tR.z < -0.02) oR = tR.xy * (travel / -tR.z); + if (tG.z < -0.02) oG = tG.xy * (travel / -tG.z); + if (tB.z < -0.02) oB = tB.xy * (travel / -tB.z); + + // Two taps per channel a little apart along the refraction direction: the + // cheapest thing that reads as "solid glass" rather than "a warped picture". + float frost = 0.006 + 0.030 * (1.0 - h); + vec3 s0 = vec3(backdrop(uv + oR, t).r, backdrop(uv + oG, t).g, backdrop(uv + oB, t).b); + vec3 s1 = vec3(backdrop(uv + oR * 1.22 + frost, t).r, + backdrop(uv + oG * 1.22 - frost, t).g, + backdrop(uv + oB * 1.22 + frost * 0.5, t).b); + vec3 through = mix(s0, s1, 0.34); + + // Beer-Lambert through the body: thick glass is not just darker, it is tinted. + through *= exp(-(1.0 - uTint) * travel * 2.1); + + float cosI = clamp(N.z, 0.0, 1.0); + float F = 0.055 + 0.945 * pow(1.0 - cosI, 5.0); + + // Reflected environment: a vertical sky ramp is enough, because the only + // place the reflection vector swings far off axis is on the bevel anyway. + vec3 R = reflect(I, N); + vec3 env = mix(vec3(0.05, 0.06, 0.11), vec3(0.42, 0.54, 0.86), smoothstep(-0.6, 0.9, R.y)) + + vec3(0.30, 0.34, 0.50) * smoothstep(0.2, 1.0, -R.x) * 0.5; + + vec3 Lp = vec3(mix(vec2(-0.46, 0.40), pc, uPointer.z), 0.85); + vec3 L = normalize(Lp - vec3(uv, h * uThickness)); + float spec = pow(max(dot(R, L), 0.0), 46.0) * 1.5 + pow(max(dot(R, L), 0.0), 6.0) * 0.14; + + vec3 col = mix(through, env, F) + spec * (0.5 + 0.5 * uTint); + + // The fillet is a lens; a third of the way up its slope the rays it turns all + // pile into one band. That band is what makes a bevel look expensive. + float k = clamp(-sd / bw, 0.0, 1.0); + float conv = exp(-pow((k - 0.30) / 0.13, 2.0)) * (1.0 - 0.55 * abs(uv.y) / 0.36); + col += uTint * conv * 0.30; + col += vec3(1.0) * exp(-pow((k - 0.06) / 0.05, 2.0)) * 0.085; + + // Interior sheen so the flat centre is not dead: a very broad grazing term. + col += uTint * 0.05 * pow(1.0 - cosI, 1.6); + + col = 1.0 - exp(-col * 1.34); + col *= 1.0 - 0.30 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/web/components/reactomega/translucent-wax.tsx b/web/components/reactomega/translucent-wax.tsx new file mode 100644 index 0000000..195ffa9 --- /dev/null +++ b/web/components/reactomega/translucent-wax.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface TranslucentWaxProps { + className?: string; + /** Depth of the slab. Thicker material lets less of the backlight through. @default 1 */ + thickness?: number; + /** Width of the forward-scattering lobe and how far light bleeds sideways. @default 1 */ + scatter?: number; + /** Colour the material transmits — what survives the absorption. @default "#f7d2a8" */ + tint?: string; + /** Beer-Lambert extinction coefficient. Higher goes waxy, lower goes glassy. @default 2.45 */ + absorption?: number; + /** Width of the ground edge as a fraction of the slab, 0..1. @default 0.52 */ + bevel?: number; +} + +/** + * TranslucentWax — a ground slab of backlit alabaster, honey onyx cut thin + * enough to pass light. Almost everything you see has been through the material + * rather than off it. + * + * The body is a rounded-rectangle slab with a wide ground edge, and that + * boundary is doing most of the work: a translucent solid is legible only by the + * contrast between a glowing thin rim and a choked interior, so the form has to + * be one whose thickness varies in a way the eye can read as a shape. A blob + * cannot do that — its silhouette carries no information — whereas a slab says + * "large through the middle, small at the edge" before any light is traced. The + * ground edge is deliberately wide and its depth ramp very nearly linear, like a + * chamfer rather than a fillet: a fillet reaches full depth within a few pixels + * of the silhouette, so the pale rim exists but is too narrow to see and the slab + * collapses back into one flat sheet with a hot outline. For + * every pixel the shader marches the real light path, fourteen steps from the + * front surface toward the source, accumulating the distance that stays between + * the slab's lower and upper skins. Beer-Lambert then attenuates each channel by + * exp(-σ·d), with σ taken as the complement of the tint and modulated by a + * banded strata field, so the ground edge passes a pale cream and the centre + * chokes down through amber to a deep ember, in that order, for the same reason + * real onyx does. The veining is banded along the slab rather than isotropic: + * strata read as stone, wandering noise reads as putty. A wrapped half-Lambert + * against the back face lets the terminator bleed around the edge roll, and a + * forward-scattering lobe blooms where the lamp sits directly behind a thin + * section. Front lighting is deliberately almost absent: a little polish + * specular, nothing more. The lamp is behind the stone and follows the pointer. + */ +export function TranslucentWax({ + className, + thickness = 1, + scatter = 1, + tint = "#f7d2a8", + absorption = 2.45, + bevel = 0.52, +}: TranslucentWaxProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uThickness: thickness, + uScatter: scatter, + uAbsorb: absorption, + uBevel: bevel, + }), + [tint, thickness, scatter, absorption, bevel], + ); + + const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +// Quintic interpolant rather than the usual smoothstep. The occupancy field is +// integrated along a light path, and cubic value noise has a discontinuous +// second derivative at every lattice line — which shows up in the transmission +// as faint polygonal creases across the stone. +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm(vec2 p) { + float a = 0.5, v = 0.0; + for (int i = 0; i < 4; i++) { + v += a * vnoise(p); + p = mat2(1.6, 1.2, -1.2, 1.6) * p; + a *= 0.5; + } + return v; +} + +float sdRoundBox(vec2 p, vec2 b, float r) { + vec2 q = abs(p) - b + r; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; +} + +float smax(float a, float b, float k) { + float h = clamp(0.5 + 0.5 * (a - b) / k, 0.0, 1.0); + return mix(b, a, h) + k * h * (1.0 - h); +} + +// The same field, but with the interior corner rounded. A box distance field has +// a gradient discontinuity along each diagonal, and because the ground edge here +// is wide those four creases run a long way in and meet near the middle — the +// depth field's own medial axis, printed across the slab as an envelope-flap X. +// Softening only the interior branch removes them without moving the silhouette: +// the outer branch, length(max(q,0)), is what sets the boundary and is untouched, +// and the k/4 bias smax adds along the diagonal is subtracted straight back off. +float sdSlabSoft(vec2 p, vec2 b, float r) { + vec2 q = abs(p) - b + r; + const float K = 0.13; + return min(smax(q.x, q.y, K) - 0.25 * K, 0.0) + length(max(q, 0.0)) - r; +} + +const vec2 SLAB = vec2(0.575, 0.325); +const float SLAB_R = 0.115; +// A few degrees off axis. A slab squared to the frame reads as a UI panel; the +// same slab tilted reads as an object that was placed there. +const mat2 TILT = mat2(0.99255, -0.12187, 0.12187, 0.99255); + +// Bedding planes. The band coordinate runs across the short axis of the slab and +// is warped by a stretched fbm — anisotropic on purpose, because the whole point +// is that the layers stay layers. Isotropic noise here is what made the earlier +// pass read as putty rather than as a cut stone. +float strata(vec2 sp, float t) { + vec2 w = sp * vec2(0.9, 2.3) + vec2(t * 0.055, 0.0); + float warp = fbm(w) - 0.5; + float u = sp.y * 7.6 + sp.x * 1.30 + warp * 2.0; + float band = 0.82 + 0.30 * sin(u * 2.1) + 0.17 * sin(u * 5.3 + 1.7) + 0.09 * sin(u * 11.0); + // Fine grain on top, small enough that it never competes with the banding. + band += 0.10 * (fbm(sp * 7.0 + 11.0) - 0.5); + return max(band, 0.28); +} + +// Interior fraction of the slab: 0 outside, 1 across the flat. The ground edge +// is where it ramps, and how wide that ramp is decides how much glowing rim +// there is to look at. +float slabK(vec2 sp, float bw) { + return max(-sdSlabSoft(sp, SLAB, SLAB_R), 0.0) / bw; +} + +// Cross-section against interior fraction. This is the single most important +// number in the file, because the *width of the thickness ramp* is what the eye +// reads as "light is coming through a solid thing". A circular fillet, or any +// power below 1, reaches full depth within a few pixels of the silhouette: the +// pale rim then exists but is two pixels wide, and the slab reads as one flat +// sheet of amber with a hot outline. A chamfer — depth rising very nearly +// linearly across a wide ground edge — spreads the whole pale-to-amber-to-ember +// ramp over sixty pixels, which is the only reason the gradient is legible. +// +// It saturates exponentially instead of being clamped. A clamp at full depth +// puts a kink in the depth field along the whole locus where it first bites, and +// because the normal is a difference of this function that kink prints as a hard +// rectangle drawn inside the slab — the plateau's own outline, which is not a +// feature of any real stone. +float profile(float x) { + return 1.0 - exp(-1.6 * pow(x, 1.15)); +} + +// Half-depth of the slab at this point, including a little relief in the +// bedding so the interior thickness is not perfectly constant. The relief is +// banded for the same reason the absorption is. +float halfDepth(vec2 sp, float t, float bw) { + float prof = profile(slabK(sp, bw)); + float lay = 0.5 + 0.5 * sin(sp.y * 7.4 + 1.9 * (fbm(sp * vec2(0.8, 2.0) + 4.0) - 0.5) * 3.0); + return prof * (0.90 + 0.10 * lay); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.5; + vec2 sp = TILT * uv; + float bw = max(0.03, uBevel * 0.30); + + float sd = sdRoundBox(sp, SLAB, SLAB_R); + float k = slabK(sp, bw); + float hd = halfDepth(sp, t, bw); + float top = 0.55 * uThickness * hd; + + // The lamp is behind the slab and drifts; the pointer takes it over. Kept well + // off centre: a lamp behind the middle lights the slab radially, and radial + // symmetry is what makes backlit things look like lamps instead of like stone + // on a light box. + vec2 lxy = mix(vec2(-0.30 + 0.44 * cos(t * 0.80), 0.24 * sin(t * 0.63 + 1.0)), pc, uPointer.z); + vec3 Lpos = vec3(lxy, -1.45 * uThickness); + + if (sd > 0.0) { + // Off the slab: the dark table, the lamp bleeding round the silhouette, and + // a warm contact line hugging the edge. + vec3 col = vec3(0.014, 0.013, 0.017); + col += uTint * 0.022 * exp(-length(uv - lxy) * 1.5); + col += mix(uTint, vec3(1.0), 0.30) * 0.30 * exp(-sd * 26.0); + col += uTint * 0.10 * exp(-sd * 7.0) * (0.35 + 0.65 * exp(-length(uv - lxy) * 1.2)); + col *= 1.0 - 0.42 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + fragColor = vec4(max(col, 0.0), 1.0); + return; + } + + float e = 1.7 / m; + vec3 N = normalize(vec3((halfDepth(sp - vec2(e, 0.0), t, bw) - halfDepth(sp + vec2(e, 0.0), t, bw)) * 0.55, + (halfDepth(sp - vec2(0.0, e), t, bw) - halfDepth(sp + vec2(0.0, e), t, bw)) * 0.55, + e)); + + vec3 Pw = vec3(uv, top); + vec3 L = normalize(Lpos - Pw); + + // March the real light path and measure how much of it lies inside the body. + // This is the whole point: thickness is measured, not inferred from a normal. + // Step length scaled to the local slab depth, not to a fixed worst case. With + // a constant span the jittered steps are enormous compared with the thickness + // of the ground edge, so the thin rim comes out as salt-and-pepper noise + // instead of a gradient. The small constant term lets a ray leaving a thin + // region still reach the thicker material next to it. + float span = (1.05 * uThickness * hd + 0.18 * uThickness) / max(0.20, -L.z); + float ds = span / 14.0; + float dist = 0.0; + // Start each pixel's march at a different fraction of a step. Fourteen steps + // on a lock-step grid quantise the thickness and print terraces straight into + // the transmission; jittering the phase turns that into noise the dither hides. + vec3 q = Pw + L * ds * hash(gl_FragCoord.xy * 1.37); + for (int i = 0; i < 14; i++) { + float qh = halfDepth(TILT * q.xy, t, bw); + if (q.z < -0.55 * uThickness * qh) break; + if (q.z < 0.55 * uThickness * qh) dist += ds; + q += L * ds; + } + // Floor on the optical depth. exp(-sigma*0) is 1 in every channel, so a path + // length that reaches zero at the silhouette transmits the lamp unchanged and + // rings the whole slab in white. Physically the light still has to cross the + // scattering skin, and one pixel spans a range of depths anyway. + dist = max(dist, 0.070 * uThickness); + + // Strata modulate the extinction coefficient, not the colour, so the bedding + // only shows where there is enough material for it to matter — which is why + // the layers fade out as they run into the ground edge, exactly as in a real + // cut slab. + float vein = strata(sp, t); + vec3 sigma = (1.0 - uTint * 0.94) * uAbsorb * 6.0 * vein; + vec3 trans = exp(-sigma * dist); + + // Wrapped diffuse against the back face — a half-Lambert with a wide wrap, so + // the terminator bleeds around the edge roll the way scattering media do. + float wrap = 0.55 * uScatter; + float back = clamp((dot(-N, L) + wrap) / (1.0 + wrap), 0.0, 1.0); + back *= back; + + // Forward scattering: light that keeps roughly its original direction after a + // few bounces, so thin sections right in front of the lamp glow out. + vec3 V = normalize(vec3(-uv * 0.6, 1.0)); + vec3 Lt = normalize(L + N * (0.30 * uScatter)); + float fd = clamp(dot(V, -Lt), 0.0, 1.0); + float fwd = pow(fd, 3.0 / uScatter) * 1.4 + pow(fd, 14.0) * 1.0; + + float atten = 1.0 / (1.0 + 0.55 * dot(Lpos.xy - uv, Lpos.xy - uv)); + + // Deep transmission shifts as well as darkens: an absorbing medium walks the + // hue, and in warm stone the last thing to survive is the red. Multiple + // scattering gives that floor a much longer tail than the ballistic term, so + // it gets the same sigma over a heavily shortened effective path rather than a + // constant — a constant floor is what made the first pass go *brighter* toward + // the middle, since nothing then attenuated with thickness at all. + // Diffusion is not a free pass: the multiply-scattered floor keeps losing + // energy too, so it gets its own extinction — mostly achromatic, because a + // random walk averages the channels, plus a share of the spectral sigma to + // keep the hue walking red. Giving it *only* the spectral part left the red + // channel almost flat with depth, which is why the slab read as one uniform + // sheet of amber instead of thick-and-deep against thin-and-pale. + vec3 sigmaD = vec3(0.30 * dot(sigma, vec3(0.3333))) + 0.16 * sigma; + vec3 deep = uTint * vec3(0.94, 0.70, 0.33) * exp(-sigmaD * dist); + + vec3 col = uTint * 0.008; + // Blend on the raw transmission, not on a scaled-and-clamped copy of it: the + // clamp stops the hue walking at a fixed thickness and prints a hard contour + // ring right through the middle of the slab. + col += mix(deep, trans, trans.g) * (0.55 + 0.62 * back) * atten * 1.20; + col += trans * fwd * atten * 0.42 * uScatter; + + // The ground edge glows hot and pale where almost no material is in the way. + // Kept tight, so it reads as an edge rather than as a bloom. + col += mix(uTint, vec3(1.0), 0.42) * exp(-dist * 8.0) * atten * 0.62; + + // Front side: only enough to say the surface is polished, not lit. + vec3 Kf = normalize(vec3(-0.45, 0.60, 0.66)); + col += vec3(0.72, 0.74, 0.80) * pow(max(dot(reflect(-Kf, N), V), 0.0), 60.0) * 0.12; + col += uTint * 0.025 * max(dot(N, Kf), 0.0); + // Grain, and a darker line right at the silhouette so the slab has an outline. + col *= 0.95 + 0.10 * fbm(sp * 5.0 + 12.0); + col *= 0.78 + 0.22 * smoothstep(0.0, 0.028, k); + + col = 1.0 - exp(-col * 1.20); + col *= 1.0 - 0.44 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.013; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/web/components/reactomega/velvet-sheen.tsx b/web/components/reactomega/velvet-sheen.tsx new file mode 100644 index 0000000..ef7ec82 --- /dev/null +++ b/web/components/reactomega/velvet-sheen.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface VelvetSheenProps { + className?: string; + /** Strength of the retroreflective sheen at grazing angles. @default 1 */ + sheen?: number; + /** Amount of fibre disorder in the nap, 0..1. @default 0.62 */ + fuzz?: number; + /** Dye colour of the pile. @default "#5b3fa8" */ + tint?: string; + /** Colour the folds fall into. @default "#07070d" */ + shadow?: string; +} + +/** + * VelvetSheen — a bolt of velvet lying in soft folds, lit by its own nap rather + * than by anything reflective. + * + * Fabric with a pile does not obey a normal specular model. Each fibre stands + * roughly upright, so light arriving almost parallel to the cloth grazes the + * whole length of the pile and scatters straight back, while light arriving + * face-on disappears down between the fibres. The BRDF here is that inversion: + * an Ashikhmin-style velvet distribution built on 1/(N·H)² over the fibre + * tangent, gated by an *inverted* Fresnel — pow(1 - N·V, 4) — so the cloth is + * brightest exactly where it turns away from you and darkest where it faces + * you. Diffuse is wrapped around the terminator with a subsurface half-Lambert, + * because dyed pile bleeds light sideways, and the fold flanks carry a fine + * fuzz normal from stretched noise plus a per-fibre density variance that + * scales the sheen. The result reads as textile because the highlight follows + * the silhouette of every fold instead of sitting on top of it. The pointer + * combs the nap. + */ +export function VelvetSheen({ + className, + sheen = 1, + fuzz = 0.62, + tint = "#5b3fa8", + shadow = "#07070d", +}: VelvetSheenProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uShadow: hexToRgb(shadow), + uSheen: sheen, + uFuzz: fuzz, + }), + [tint, shadow, sheen, fuzz], + ); + + const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm(vec2 p) { + float a = 0.5, v = 0.0; + for (int i = 0; i < 4; i++) { + v += a * vnoise(p); + p = mat2(1.6, 1.2, -1.2, 1.6) * p; + a *= 0.5; + } + return v; +} + +// Height of the draped cloth. Long soft folds along one axis plus a slow FBM +// sag, so the folds gather and release the way heavy fabric does. +float cloth(vec2 p, float t) { + // A bolt of cloth hangs in long parallel gathers. The noise only wanders the + // phase and amplitude of those gathers — added to the height directly it turns + // the drape into lumpy terrain, which is exactly what velvet never looks like. + float ph = fbm(p * 0.30 + vec2(0.0, t * 0.035)) * 2.9; + float amp = 0.72 + 0.60 * fbm(p * 0.26 + vec2(4.0, 1.0)); + float fold = sin(p.x * 3.05 + p.y * 0.42 + ph + t * 0.20) + + 0.44 * sin(p.x * 5.70 - p.y * 0.26 + ph * 0.7 - t * 0.14); + return fold * amp * 0.30; +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.45; + vec2 p = uv * 2.6; + + float e = 0.014; + float h = cloth(p, t); + vec3 N = normalize(vec3((cloth(p - vec2(e, 0.0), t) - cloth(p + vec2(e, 0.0), t)) * 3.1, + (cloth(p - vec2(0.0, e), t) - cloth(p + vec2(0.0, e), t)) * 3.1, + e * 0.72)); + + // Nap: fibres lie in ranks, so the disorder is stretched, not isotropic. + vec2 nq = uv * vec2(150.0, 420.0); + float f1 = vnoise(nq) - 0.5; + float f2 = vnoise(nq * 2.7 + 31.0) - 0.5; + vec3 fz = vec3(f1 * 1.0, f2 * 0.55, 0.0) * uFuzz * 0.16; + // The pointer combs the pile flat, which locally kills the sheen. + vec2 rel = uv - pc; + float comb = uPointer.z * exp(-dot(rel, rel) * 10.0); + vec3 Nf = normalize(N + fz - vec3(normalize(rel + 1e-5) * comb * 0.22, 0.0)); + + // Per-fibre density variance. Velvet is never uniformly bright; this is what + // separates cloth from a glowing outline. + float dens = 0.70 + 0.36 * fbm(uv * 48.0 + 5.0) + 0.16 * (vnoise(uv * vec2(120.0, 340.0)) - 0.5); + + vec3 V = normalize(vec3(-uv * 0.55, 1.0)); + vec3 L = normalize(vec3(mix(vec2(-0.62, 0.30), pc, uPointer.z) - uv * 0.4, 0.50)); + vec3 H = normalize(L + V); + + float NdV = clamp(dot(Nf, V), 0.001, 1.0); + float NdL = dot(Nf, L); + float NdH = clamp(dot(Nf, H), 0.001, 1.0); + + // Wrapped diffuse. Pile scatters sideways, so the terminator bleeds well past + // where a Lambert surface would already be black. + float wrap = 0.42; + float diff = clamp((NdL + wrap) / (1.0 + wrap), 0.0, 1.0); + diff *= diff; + + // Ashikhmin velvet lobe over the fibre tangent, gated by an inverted Fresnel. + // Both factors peak where the surface turns edge-on to the eye. + float sinTH2 = max(0.0, 1.0 - NdH * NdH); + float velvet = (sinTH2 * sinTH2) / (NdH * NdH * NdH * NdH + 1e-4); + velvet = min(velvet, 9.0); + float invFres = pow(1.0 - NdV, 5.0); + float retro = pow(clamp(dot(-V, -L) * 0.5 + 0.5, 0.0, 1.0), 2.0); + + // Fibre-scale modulation of the sheen itself, not just of the normal: the pile + // lies in ranks and the ranks glint separately. Without this the sheen bands + // come out as smooth airbrushed gradients and lose the textile read entirely. + float rank = 0.58 + 0.72 * vnoise(uv * vec2(210.0, 55.0) + 4.0) + + 0.26 * (vnoise(uv * vec2(38.0, 620.0)) - 0.5); + float sheen = uSheen * dens * rank * max(NdL + 0.30, 0.0) + * (0.055 * velvet * invFres + 1.15 * invFres * (0.28 + 0.72 * retro)); + + // Ambient occlusion from the fold depth: the bottoms of the gathers go dark + // even where they are turned toward the light. + float ao = smoothstep(-0.42, 0.60, h) * 0.62 + 0.38; + + vec3 pileLo = uShadow + uTint * 0.085; + vec3 body = mix(pileLo, uTint * 0.90, diff * ao); + vec3 sheenCol = mix(uTint * 0.55 + vec3(0.30, 0.32, 0.42), vec3(0.72, 0.74, 0.86), 0.45); + + // The pile itself is held down hard. Velvet is a dark cloth; if the body is + // bright the sheen has nothing to be brighter than and the fabric cue dies. + vec3 col = body * (0.20 + 0.40 * ao); + col += sheenCol * sheen * ao * 0.52; + col += uTint * 0.10 * comb; + + col = 1.0 - exp(-col * 1.62); + col *= 1.0 - 0.52 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.013; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; From 277b19b4b5fa3944b6b389e3c38ac902a0929444 Mon Sep 17 00:00:00 2001 From: Ed Chen <37851723+Edwson@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:11:25 +0800 Subject: [PATCH 3/8] registry: shadcn registry items for the 6 new shaders --- public/r/brushed-metal.json | 31 +++++++++++++++++++++++++++++++ public/r/diffraction-grating.json | 31 +++++++++++++++++++++++++++++++ public/r/moire-weave.json | 31 +++++++++++++++++++++++++++++++ public/r/refracted-glass.json | 31 +++++++++++++++++++++++++++++++ public/r/translucent-wax.json | 31 +++++++++++++++++++++++++++++++ public/r/velvet-sheen.json | 31 +++++++++++++++++++++++++++++++ 6 files changed, 186 insertions(+) create mode 100644 public/r/brushed-metal.json create mode 100644 public/r/diffraction-grating.json create mode 100644 public/r/moire-weave.json create mode 100644 public/r/refracted-glass.json create mode 100644 public/r/translucent-wax.json create mode 100644 public/r/velvet-sheen.json diff --git a/public/r/brushed-metal.json b/public/r/brushed-metal.json new file mode 100644 index 0000000..3ccab0a --- /dev/null +++ b/public/r/brushed-metal.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "brushed-metal", + "type": "registry:component", + "title": "Brushed Metal", + "description": "Machined metal, still and precise — the opposite register to liquid-metal's flow. An anisotropic GGX lobe with Smith-correlated shadowing runs over a brush-direction field, so the highlight stretches perpendicular to the grain. Linear or radial (engine-turned) finishes.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/brushed-metal.tsx", + "type": "registry:component", + "target": "components/reactomega/brushed-metal.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface BrushedMetalProps {\n className?: string;\n /** `\"linear\"` for a straight-grain finish, `\"radial\"` for engine-turned. @default \"linear\" */\n pattern?: \"linear\" | \"radial\";\n /** How far the highlight is stretched across the grain, 0..1. @default 0.88 */\n anisotropy?: number;\n /** Base roughness of the polish, 0..1. @default 0.34 */\n roughness?: number;\n /** Colour the metal reflects. @default \"#b9c8f0\" */\n tint?: string;\n /** Speed the light orbits at. @default 1 */\n speed?: number;\n}\n\n/**\n * BrushedMetal — a still, machined surface. Nothing about the metal moves; only\n * the light does, and the highlight it drags is the entire subject.\n *\n * The grain is a direction field — constant for a linear finish, tangential\n * around the centre for an engine-turned one — and the abrasive scratches are\n * value noise sampled on coordinates stretched forty to one along that\n * direction, so every groove runs with the grain. Lighting is an anisotropic\n * GGX lobe: the roughness along the grain is held low while the roughness\n * across it is pushed up by `anisotropy`, and because a microfacet\n * distribution spreads reflections in the direction it is rough, the specular\n * comes out as a long streak lying *perpendicular* to the brushing. That\n * asymmetry is the whole tell of brushed metal, and it is computed rather than\n * drawn. Smith-correlated shadowing keeps the grazing rim from blowing out,\n * and the pointer takes the light over.\n */\nexport function BrushedMetal({\n className,\n pattern = \"linear\",\n anisotropy = 0.88,\n roughness = 0.34,\n tint = \"#b9c8f0\",\n speed = 1,\n}: BrushedMetalProps) {\n const uniforms = useMemo(\n () => ({\n uTint: hexToRgb(tint),\n uRadial: pattern === \"radial\" ? 1 : 0,\n uAniso: anisotropy,\n uRough: roughness,\n }),\n [tint, pattern, anisotropy, roughness],\n );\n\n const { ref, supported } = useShader({ speed, uniforms, fragment: FRAG });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nconst float PI = 3.14159265;\n\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat vnoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n // Quintic, not cubic: the milling term is sampled at a low frequency, and\n // cubic value noise creases visibly along its lattice lines when it is.\n vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);\n return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n}\n\n// Scratch depth at a point already expressed in (along-grain, across-grain)\n// coordinates. Three bands of abrasive grit, each stretched hard along the grain.\nfloat grooves(vec2 g) {\n float v = vnoise(vec2(g.x * 0.9, g.y * 40.0)) - 0.5;\n v += 0.62 * (vnoise(vec2(g.x * 2.1 + 11.0, g.y * 130.0)) - 0.5);\n v += 0.34 * (vnoise(vec2(g.x * 4.3 - 7.0, g.y * 420.0)) - 0.5);\n return v;\n}\n\n// Anisotropic GGX. Rough across the grain, smooth along it — a microfacet lobe\n// spreads light in whichever direction it is rough, so the highlight ends up\n// lying across the brushing rather than with it.\nfloat ggxAniso(vec3 H, vec3 T, vec3 B, vec3 N, float ax, float ay) {\n float ht = dot(H, T) / ax;\n float hb = dot(H, B) / ay;\n float hn = dot(H, N);\n float w = ht * ht + hb * hb + hn * hn;\n return 1.0 / (PI * ax * ay * w * w);\n}\n\nfloat smithG(vec3 V, vec3 T, vec3 B, vec3 N, float ax, float ay) {\n float vn = max(dot(V, N), 1e-4);\n float vt = dot(V, T) * ax;\n float vb = dot(V, B) * ay;\n float a2 = (vt * vt + vb * vb) / (vn * vn);\n return 2.0 / (1.0 + sqrt(1.0 + a2));\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n\n float t = uTime * 0.35;\n\n // Grain direction field. Radial mode is a turned finish, so the grain runs\n // tangentially and the grooves become concentric.\n vec2 rad = uv - vec2(0.06, -0.04);\n float rl = max(length(rad), 1e-4);\n vec2 tanDir = vec2(-rad.y, rad.x) / rl;\n vec2 lin = normalize(vec2(0.995, 0.100));\n vec2 Td = normalize(mix(lin, tanDir, uRadial));\n\n // A slow bow in the grain — dead-straight brushing reads as a CSS gradient.\n float bow = (vnoise(uv * vec2(1.4, 2.6) + 17.0) - 0.5) * 0.16 * (1.0 - uRadial);\n Td = normalize(Td + vec2(-Td.y, Td.x) * bow);\n vec2 Bd = vec2(-Td.y, Td.x);\n\n // Grain-local coordinates: x along the brush, y across it. For a turned finish\n // the brush runs *around* the centre, so the fast axis has to be the radius —\n // put the angle there instead and the grooves come out as radial spokes, which\n // is a completely different machining operation.\n // atan2 jumps by 2*pi across its branch cut, and since the angle is the\n // slow axis of the grain that jump prints a hard seam straight out from the\n // spindle. Folding to |theta| removes the discontinuity entirely; the mirror\n // it leaves along the other side is invisible because the noise varies barely\n // at all in that direction.\n vec2 g = mix(vec2(dot(uv, Td), dot(uv, Bd)),\n vec2(abs(atan(rad.y, rad.x)) * 1.35, rl * 0.85), uRadial);\n\n float e = 1.0 / m;\n float d = grooves(g);\n float dx = grooves(g + vec2(0.0, e * 0.8)) - d;\n // Only the across-grain derivative matters; a groove has no slope along itself.\n float amp = 0.16 + 0.85 * uRough;\n vec3 N = normalize(vec3(Bd * (-dx * amp / e) * 0.010, 1.0));\n\n // Broad milling undulation, so large areas catch light differently.\n float mill = (vnoise(g * vec2(1.6, 4.2) + 3.0) - 0.5)\n + 0.5 * (vnoise(g * vec2(3.7, 9.5) - 8.0) - 0.5);\n N = normalize(N + vec3(Bd * mill * 0.10, 0.0) + vec3(Td * mill * 0.03, 0.0));\n\n vec3 T3 = normalize(vec3(Td, 0.0) - N * dot(N, vec3(Td, 0.0)));\n vec3 B3 = normalize(cross(N, T3));\n vec3 V = normalize(vec3(-uv * 0.45, 1.0));\n\n // The light orbits until the pointer claims it.\n vec2 lp = mix(vec2(0.50 * cos(t * 0.9 + 0.6), 0.30 * sin(t * 0.7)), pc, uPointer.z);\n vec3 L = normalize(vec3(lp - uv, 0.95));\n vec3 H = normalize(L + V);\n\n float ax = max(0.010, uRough * uRough * (1.0 - 0.94 * uAniso));\n float ay = max(0.020, uRough * uRough * (1.0 + 7.0 * uAniso));\n\n float NdL = max(dot(N, L), 0.0);\n float NdV = max(dot(N, V), 1e-3);\n float D = ggxAniso(H, T3, B3, N, ax, ay);\n float G = smithG(L, T3, B3, N, ax, ay) * smithG(V, T3, B3, N, ax, ay);\n float F = 0.62 + 0.38 * pow(1.0 - max(dot(H, V), 0.0), 5.0);\n float spec = D * G * F * NdL / (4.0 * NdV);\n\n // Second, much broader lobe: real brushed metal shows a wide sheen band far\n // from the hot streak, and without it the plate looks like bare noise.\n float ax2 = ax * 6.0 + 0.06;\n float ay2 = min(1.0, ay * 2.2 + 0.30);\n float sheen = ggxAniso(H, T3, B3, N, ax2, ay2) * NdL * 0.14;\n\n // Falloff of the light itself, so the plate has a lit end and a dark end.\n float falloff = 1.0 / (1.0 + 2.6 * dot(uv - lp, uv - lp));\n\n // A cool overhead gradient standing in for the room, so the plate has a body\n // tone away from the streak. A milled part in a dark studio is not black.\n vec3 room = mix(vec3(0.014, 0.017, 0.030), vec3(0.070, 0.082, 0.130),\n smoothstep(-0.5, 0.7, dot(N, normalize(vec3(0.1, 0.9, 0.35)))));\n\n // The spindle centre has no defined grain direction, so ease the anisotropy\n // out there rather than letting it converge into a bright knot.\n float hub = mix(1.0, smoothstep(0.005, 0.055, rl), uRadial);\n vec3 col = uTint * room;\n col += uTint * (0.010 + 0.085 * NdL) * falloff;\n col += uTint * clamp(spec, 0.0, 40.0) * 0.075 * falloff * hub;\n col += uTint * sheen * falloff * 2.1 * hub;\n col += vec3(1.0) * clamp(spec, 0.0, 40.0) * 0.022 * falloff * hub;\n\n // Anodised rim shade and a faint dirt in the grain valleys.\n col *= 1.0 - 0.20 * smoothstep(0.0, 0.6, -d);\n col = 1.0 - exp(-col * 1.55);\n col *= 1.0 - 0.46 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.014;\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "metal", + "anisotropic", + "specular", + "background" + ] + } +} diff --git a/public/r/diffraction-grating.json b/public/r/diffraction-grating.json new file mode 100644 index 0000000..3f73e07 --- /dev/null +++ b/public/r/diffraction-grating.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "diffraction-grating", + "type": "registry:component", + "title": "Diffraction Grating", + "description": "The optics behind a CD surface: the grating equation places spectral orders by solving d·sinθ = mλ for wavelength per order, giving sharp rainbow lines rather than broad fringes. Higher orders are dimmer and wash toward white, over a band-limited pressed ruling.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/diffraction-grating.tsx", + "type": "registry:component", + "target": "components/reactomega/diffraction-grating.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface DiffractionGratingProps {\n className?: string;\n /** Groove spacing in nanometres. A CD is 1600, a DVD 740, embossed foil ~3200. @default 2450 */\n pitch?: number;\n /** How many spectral orders either side of the specular are kept. @default 3 */\n orders?: number;\n /** Resolving power, 0..1 — how saturated each spectral line stays. @default 0.72 */\n sharpness?: number;\n /** Colour of the metal under the grating. @default \"#c9d8ff\" */\n tint?: string;\n}\n\n/**\n * DiffractionGrating — the surface of a CD, or holographic foil: hard spectral\n * streaks that jump position as the light moves, not a soft pastel wash.\n *\n * The grooves run in concentric arcs and the eye sits at a finite distance, so\n * the view direction genuinely varies across the frame. From that geometry the\n * shader builds the grating path difference s = d·(sinθ_in + sinθ_out) by\n * projecting the light and view vectors onto the groove vector, and then simply\n * solves d·sinθ = mλ for the wavelength: order m sends λ = s/m to the eye at\n * this pixel, and nothing else. Solving for λ rather than integrating over a\n * handful of sampled wavelengths is what makes the streaks continuous — sampled\n * spectra bead into rows of coloured dots, because each sample resonates a few\n * pixels away from the last. Each order is therefore a smooth ramp through the\n * spectrum, cut off exactly where λ leaves the visible band. Because the pitch\n * is coarse the path difference climbs steeply across the frame, which is what\n * keeps each order a thin line rather than a wide band — sharp spectral lines\n * read as optics, wide soft ones read as decoration. The energy is weighted the\n * way a real grating weights it: the blaze falloff drops m=±2 to about a third of\n * m=±1 and m=±3 to a tenth, and `sharpness` — the resolving power mλ/Δλ —\n * additionally washes the high orders toward white, because the same physical\n * groove count buys less resolution across a wider order and neighbouring\n * wavelengths start overlapping at the eye. So the low orders are the saturated\n * ones and the high orders are dim *and* pale, instead of three equal rainbows.\n * Under all of it the substrate is a real surface, not a void: the pressed track\n * gives a fine band-limited ruling (sinc-filtered against the pixel footprint,\n * so it dissolves into its own mean rather than aliasing), a coarser sector\n * banding gives structure at a scale the eye can hold, and a broad dim specular\n * lobe squashed along the ruling supplies the oily sheen a disc carries\n * everywhere the rainbows are not. The zeroth order is achromatic and is kept\n * aside as a plain specular; the pointer takes the lamp, which walks the whole\n * spectrum across the disc.\n */\nexport function DiffractionGrating({\n className,\n pitch = 2450,\n orders = 3,\n sharpness = 0.72,\n tint = \"#c9d8ff\",\n}: DiffractionGratingProps) {\n const uniforms = useMemo(\n () => ({\n uTint: hexToRgb(tint),\n uPitch: pitch,\n uOrders: orders,\n uSharp: sharpness,\n }),\n [tint, pitch, orders, sharpness],\n );\n\n const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\n// A cosine convolved with the pixel footprint. The box filter of cos(2*pi*phi)\n// over a width of w cycles is exactly sinc(w) = sin(pi*w)/(pi*w) — zero when the\n// period reaches two pixels. The track structure below runs at three or four\n// pixels a cycle and fans as it goes, so without this it would alias into\n// crawling noise and take the spectra down with it.\nfloat bandCos(float phi, float w) {\n float a = 1.0;\n if (w > 1e-4) a = clamp(sin(3.14159265 * w) / (3.14159265 * w), 0.0, 1.0);\n return cos(6.2831853 * phi) * a;\n}\n\nfloat vnoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n}\n\n// Rough sRGB response to a single wavelength in nanometres. Sums of gaussians\n// rather than a hue ramp, so the band order and the muddy cyan-green at 500nm\n// come out where a real spectrum puts them.\nvec3 spectral(float l) {\n vec3 c;\n c.r = 1.06 * exp(-pow((l - 604.0) / 56.0, 2.0))\n + 0.46 * exp(-pow((l - 700.0) / 54.0, 2.0))\n + 0.20 * exp(-pow((l - 432.0) / 26.0, 2.0));\n c.g = 1.02 * exp(-pow((l - 542.0) / 52.0, 2.0))\n + 0.34 * exp(-pow((l - 592.0) / 38.0, 2.0));\n c.b = 1.14 * exp(-pow((l - 452.0) / 42.0, 2.0))\n + 0.32 * exp(-pow((l - 484.0) / 38.0, 2.0));\n return c;\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n\n float t = uTime * 0.4;\n\n // Foil relief: an extremely shallow crinkle. The path difference is a\n // wavelength-scale quantity, so a normal that wanders even slightly shreds the\n // orders into contour noise. Almost all of the variation has to come from the\n // view geometry instead.\n float e = 0.020;\n float relief = vnoise(uv * 1.5 + vec2(t * 0.05, -t * 0.04));\n float rx = vnoise((uv + vec2(e, 0.0)) * 1.5 + vec2(t * 0.05, -t * 0.04));\n float ry = vnoise((uv + vec2(0.0, e)) * 1.5 + vec2(t * 0.05, -t * 0.04));\n vec3 N = normalize(vec3((relief - rx) * 0.055 / e, (relief - ry) * 0.055 / e, 1.0));\n\n // Grooves in concentric arcs about a centre well outside the frame: near\n // parallel, fanning slightly. That keeps the path difference monotonic across\n // the frame, which is the only way the orders separate into clean streaks.\n vec2 rad = uv - vec2(-3.1, -1.35);\n vec2 gDir = normalize(rad);\n float swirl = (vnoise(uv * 1.15 + 9.0) - 0.5) * 0.16;\n gDir = normalize(gDir + vec2(-gDir.y, gDir.x) * swirl);\n vec3 G = normalize(vec3(gDir, 0.0) - N * dot(N, vec3(gDir, 0.0)));\n\n // Track structure. The grooves that do the diffracting are a wavelength or two\n // apart — far below a pixel, and drawing them would only alias. What you\n // actually see on a disc is the coarser banding of the pressed track: hundreds\n // of grooves to a visible line. Concentric about the same centre as the\n // grating vector, because it is the same ruling, and band-limited because it\n // runs at three or four pixels a cycle and fans as it goes.\n float rl = length(rad);\n float gph = rl * 74.0 + 1.4 * vnoise(uv * 2.2 + 3.0);\n float groove = 0.5 + 0.5 * bandCos(gph, fwidth(gph));\n // A far coarser second banding — the pressed sectors — so the surface has\n // structure at a scale the eye can hold as well as one it can only resolve.\n float sect = 0.5 + 0.5 * bandCos(rl * 5.5 - 0.3, fwidth(rl * 5.5));\n\n // Finite eye distance: the view direction is what makes s position-dependent.\n vec3 V = normalize(vec3(-uv, 0.78));\n vec2 lxy = mix(vec2(0.30 + 0.55 * cos(t * 0.5 + 1.2), 0.34 * sin(t * 0.38)), pc, uPointer.z);\n vec3 L = normalize(vec3(lxy - uv, 0.62));\n\n // d * (sin(theta_in) + sin(theta_out)), both angles projected onto the groove\n // vector. This single scalar is the entire grating equation.\n float s = uPitch * (dot(V, G) + dot(L, G));\n\n float NdL = max(dot(N, L), 0.0);\n float atten = 1.0 / (1.0 + 1.2 * dot(uv - lxy, uv - lxy));\n\n // Rate of change of the path difference, in nanometres per pixel. It sets how\n // wide the band edges have to be feathered to stay smooth at any zoom.\n float ws = max(fwidth(s), 1e-4);\n float purity = clamp(uSharp, 0.0, 1.0);\n\n vec3 fan = vec3(0.0);\n for (int mi = 1; mi <= 4; mi++) {\n float mm = float(mi);\n if (mm > uOrders + 0.5) break;\n // The grating equation, solved for wavelength instead of for position.\n float lam = abs(s) / mm;\n float wl = ws / mm;\n // Order m only exists here if the wavelength it wants is one we can see.\n float band = smoothstep(0.0, 2.0 * wl + 5.0, lam - 398.0)\n * smoothstep(0.0, 2.0 * wl + 5.0, 712.0 - lam);\n // Blaze falloff: a real grating throws most of its energy into the low\n // orders, and steeply. Three equally bright bands is the single thing that\n // makes a grating read as a rainbow gradient instead of as optics.\n float eff = 1.0 / (1.0 + 2.2 * (mm - 1.0) * (mm - 1.0));\n // Finite resolving power R = mN. The *same* physical groove count buys less\n // resolution per unit wavelength as m rises relative to the width of the\n // order, so the high orders both dim and wash toward white — they overlap\n // themselves. Dimming alone would leave them fully saturated and still\n // reading as ribbon.\n float pur = purity / (1.0 + 0.90 * (mm - 1.0));\n vec3 sc = spectral(lam);\n sc = mix(vec3(dot(sc, vec3(0.32, 0.55, 0.13))) * 1.32, sc, 0.24 + 0.58 * pur);\n fan += sc * band * eff;\n }\n fan *= 1.18;\n\n // Zeroth order: ordinary mirror specular off the foil, plus the anisotropic\n // smear a grooved surface gives it along the groove direction.\n vec3 H = normalize(L + V);\n float NdH = max(dot(N, H), 0.0);\n float along = dot(H, G);\n float spec = pow(NdH, 900.0) * 1.6\n + pow(NdH, 90.0) * 0.10 * exp(-along * along * 14.0);\n\n // Dark polycarbonate over aluminium. The substrate has to read as a *surface*:\n // an empty black field between the orders is what left the earlier pass\n // looking like three neon ribbons floating on nothing, because a spectrum with\n // no object under it is just a gradient.\n vec3 base = uTint * (0.030 + 0.060 * NdL);\n base += uTint * 0.046 * pow(1.0 - abs(dot(V, N)), 2.4);\n // Broad low specular lobe. Very wide, very dim, anisotropically squashed along\n // the ruling: the oily sheen a disc carries everywhere the rainbows are not.\n // This single term is what the spectra end up sitting on.\n base += uTint * 0.38 * pow(NdH, 4.5) * (0.26 + 0.74 * exp(-along * along * 2.0)) * atten;\n base += uTint * 0.085 * exp(-along * along * 3.0) * atten;\n // The track modulates everything reflective, and hardest at grazing incidence\n // where the ridges shadow one another.\n base *= 0.70 + 0.56 * groove;\n // Structure at a scale the eye can actually hold, as well as one it can only\n // just resolve. With only the fine ruling, everywhere the spectra are not goes\n // back to being a flat field — which was the original complaint about the\n // substrate, and the fine banding alone does not answer it.\n base *= 0.84 + 0.30 * sect;\n base *= 0.90 + 0.22 * vnoise(uv * 1.3 + 17.0);\n base += uTint * 0.014 * sect * groove;\n\n vec3 col = base;\n // The spectra come off the ridges, so they carry the ruling too — faintly, or\n // the fine banding starts competing with the orders for attention.\n col += fan * mix(vec3(1.0), uTint, 0.18) * atten * (0.26 + 0.98 * NdL)\n * (0.82 + 0.26 * groove);\n col += vec3(1.0) * spec * atten * 0.42 * (0.62 + 0.52 * groove);\n // Faint second-surface haze so the black between orders is not empty.\n col += uTint * 0.024 * exp(-length(uv - lxy) * 1.6);\n\n col = 1.0 - exp(-col * 1.22);\n col *= 1.0 - 0.36 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012;\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "diffraction", + "spectrum", + "iridescent", + "background" + ] + } +} diff --git a/public/r/moire-weave.json b/public/r/moire-weave.json new file mode 100644 index 0000000..7b82cfd --- /dev/null +++ b/public/r/moire-weave.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "moire-weave", + "type": "registry:component", + "title": "Moiré Weave", + "description": "Two rigid lattices at a small relative angle, beating against each other at their true difference frequency. Every cosine is band-limited by an exact box filter, so the pattern dissolves into its own mean grey as the period approaches two pixels instead of aliasing into noise.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/moire-weave.tsx", + "type": "registry:component", + "target": "components/reactomega/moire-weave.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface MoireWeaveProps {\n className?: string;\n /** Lattice period in device pixels. Below about 2.5 the filter takes over. @default 8 */\n pitch?: number;\n /** Angle between the two lattices, in degrees. Small angles give huge fringes. @default 4.5 */\n angle?: number;\n /** `true` weaves the two thread sets over and under, `false` leaves flat line screens. @default true */\n weave?: boolean;\n /** Colour of the lit threads. @default \"#a8bcff\" */\n tint?: string;\n}\n\n/**\n * MoireWeave — two rigid high-frequency lattices laid over each other at a few\n * degrees, where the enormous soft fringes are interference between them and\n * not a pattern anyone drew.\n *\n * Each lattice is a pair of cosine thread screens with an exact phase, so the\n * beat visible across the frame is genuinely the difference frequency k1 - k2:\n * shrink the angle and the fringes grow without bound, which is the signature\n * of real moiré. The lattices sit on a slightly tilted plane, which means the\n * period measured in pixels compresses toward the top of the frame and runs\n * straight at the sampling limit — so every cosine is band-limited before it is\n * used. Each thread is convolved with the pixel footprint analytically, the box\n * filter of cos(2πφ) being sinc(w) with w = fwidth(φ) in cycles per pixel: the\n * amplitude decays to exactly zero as the period reaches two pixels and the\n * lattice dissolves into its own mean grey instead of boiling into noise. In\n * weave mode a third band-limited cosine on φ₁+φ₂ decides which thread set\n * passes over at each crossing. The pointer swells the local pitch.\n */\nexport function MoireWeave({\n className,\n pitch = 8,\n angle = 4.5,\n weave = true,\n tint = \"#a8bcff\",\n}: MoireWeaveProps) {\n const uniforms = useMemo(\n () => ({\n uTint: hexToRgb(tint),\n uPitch: pitch,\n uAngle: angle,\n uWeave: weave ? 1 : 0,\n }),\n [tint, pitch, angle, weave],\n );\n\n const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nconst float PI = 3.14159265;\n\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\n// A cosine convolved with the pixel footprint. The box filter of cos(2*pi*phi)\n// over a width of w cycles is exactly sinc(w) = sin(pi*w)/(pi*w) — zero when the\n// period hits two pixels. This single line is the difference between moire and\n// a screenful of crawling noise.\nfloat bandCos(float phi, float w) {\n float a = 1.0;\n if (w > 1e-4) a = clamp(sin(PI * w) / (PI * w), 0.0, 1.0);\n return cos(2.0 * PI * phi) * a;\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n\n float t = uTime * 0.25;\n\n // A mild tilt away from the viewer. This is not decoration: it forces the\n // period in pixels to sweep through the whole range up to Nyquist, so the\n // filter is doing visible work in every frame.\n float z = 1.0 + 2.35 * (uv.y + 0.5);\n vec2 P = (gl_FragCoord.xy - 0.5 * uResolution) * z;\n\n // The pointer swells the local pitch — the fringes rearrange around it,\n // because a pitch change is a frequency change and the beat follows.\n vec2 rel = uv - pc;\n float swell = 1.0 - uPointer.z * 0.30 * exp(-dot(rel, rel) * 8.0);\n\n float f = 1.0 / max(2.0, uPitch * swell);\n // Only the relative angle is animated. The fringe scale goes as 1/angle, so a\n // half-degree drift is a very large change in what you see.\n float a1 = radians(-0.5 * uAngle + 1.1 * sin(t * 0.5)) + 0.06 * sin(t * 0.31);\n float a2 = radians(0.5 * uAngle + 1.1 * sin(t * 0.5 + 2.2)) + 0.06 * sin(t * 0.31);\n vec2 k1 = f * vec2(cos(a1), sin(a1));\n vec2 k2 = f * 1.008 * vec2(cos(a2), sin(a2));\n\n // Warp and weft of each lattice.\n float p1 = dot(P, k1);\n float q1 = dot(P, vec2(-k1.y, k1.x));\n float p2 = dot(P, k2);\n float q2 = dot(P, vec2(-k2.y, k2.x));\n\n float w1 = fwidth(p1), v1 = fwidth(q1);\n float w2 = fwidth(p2), v2 = fwidth(q2);\n\n float A = bandCos(p1, w1), Ab = bandCos(q1, v1);\n float B = bandCos(p2, w2), Bb = bandCos(q2, v2);\n\n // Over/under at each crossing, from a band-limited cosine on the sum phase.\n float ck1 = 0.5 + 0.5 * bandCos((p1 + q1) * 0.5, fwidth((p1 + q1) * 0.5));\n float ck2 = 0.5 + 0.5 * bandCos((p2 + q2) * 0.5, fwidth((p2 + q2) * 0.5));\n\n // Woven: the over/under decides which thread set is visible at each crossing.\n // Unwoven: plain single-direction line screens, which is the textbook pairing\n // and gives much cleaner fringes because only one frequency beats per lattice.\n float l1 = mix(0.5 + 0.5 * A, mix(0.5 + 0.5 * Ab, 0.5 + 0.5 * A, ck1), uWeave);\n float l2 = mix(0.5 + 0.5 * B, mix(0.5 + 0.5 * Bb, 0.5 + 0.5 * B, ck2), uWeave);\n\n // Superposition. Two overlaid screens multiply their transmittances; the beat\n // is emergent, and this is where it comes from.\n float sup = l1 * l2;\n\n // The same beat written out analytically at the difference frequency. Used\n // only as a lighting envelope, so the fringes still read once the lattices\n // themselves have been filtered away to grey near the horizon.\n vec2 kd = k1 - k2;\n float beat = 0.5 + 0.5 * bandCos(dot(P, kd), fwidth(dot(P, kd)));\n vec2 kd2 = k1 - vec2(-k2.y, k2.x);\n float beat2 = 0.5 + 0.5 * bandCos(dot(P, kd2), fwidth(dot(P, kd2)));\n float env = mix(beat, beat2, 0.42);\n\n // Thread shading: a cylindrical cross-section catches light off to one side,\n // which is what stops a woven surface looking like printed squares.\n float lit = 0.5 + 0.5 * bandCos(p1 - 0.22, w1);\n float lit2 = 0.5 + 0.5 * bandCos(q2 + 0.22, v2);\n\n vec3 warm = uTint;\n vec3 cool = vec3(0.09, 0.11, 0.30);\n\n vec3 col = vec3(0.012, 0.014, 0.028);\n col += mix(cool * 0.45, warm, smoothstep(0.06, 0.72, sup)) * (0.10 + 1.20 * pow(sup, 1.20));\n // Fringe lighting: crests of the beat get the specular, troughs go blue-black.\n col *= 0.24 + 1.50 * pow(env, 1.9);\n col += warm * pow(env, 4.0) * 0.42;\n col += vec3(0.85, 0.90, 1.0) * pow(sup, 3.4) * pow(env, 3.0) * 0.14;\n col += warm * 0.16 * lit * lit2 * env;\n\n // A broad key so the frame has a lit corner rather than uniform coverage.\n col *= 0.55 + 0.85 * exp(-length(uv - vec2(-0.30, 0.16)) * 1.5);\n\n col = 1.0 - exp(-col * 1.70);\n col *= 1.0 - 0.38 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012;\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "moire", + "interference", + "weave", + "background" + ] + } +} diff --git a/public/r/refracted-glass.json b/public/r/refracted-glass.json new file mode 100644 index 0000000..e46e201 --- /dev/null +++ b/public/r/refracted-glass.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "refracted-glass", + "type": "registry:component", + "title": "Refracted Glass", + "description": "A thick bevelled glass panel over a procedural backdrop. The view ray is refracted through the slab with a different index per RGB channel, so the bevel carries real chromatic dispersion, and a Schlick Fresnel term brightens the rim where the surface turns away.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/refracted-glass.tsx", + "type": "registry:component", + "target": "components/reactomega/refracted-glass.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface RefractedGlassProps {\n className?: string;\n /** Optical depth of the slab — how far the bent ray travels before it exits. @default 0.34 */\n thickness?: number;\n /** Refractive index of the body. 1.5 is crown glass, 1.9 reads as sapphire. @default 1.52 */\n ior?: number;\n /** Spread between the red and blue indices. 0 is achromatic, 1 is showy. @default 1 */\n dispersion?: number;\n /** Width of the bevelled edge as a fraction of the panel, 0..1. @default 0.34 */\n bevel?: number;\n /** Absorption colour of the glass body. @default \"#9fc0ff\" */\n tint?: string;\n}\n\n/**\n * RefractedGlass — a thick bevelled slab of glass laid over a procedural\n * backdrop, refracting it rather than blurring it.\n *\n * The panel is a rounded-box SDF whose interior distance is lifted into a\n * circular fillet, so the surface normal swings from straight-up in the middle\n * to almost horizontal at the rim. The view ray is then refracted through that\n * normal with Snell's law — separately for three indices, red low and blue\n * high — and each channel samples the backdrop at its own exit point. Because\n * the three exit points only diverge where the normal is steep, dispersion\n * appears exactly where real glass shows it: hugging the bevel, absent across\n * the flat. A Schlick Fresnel term on the same normal lights the rim, the\n * fillet gathers a converging band of light a third of the way up its slope,\n * and the transmitted colour is attenuated by the body tint. The pointer\n * drags the reflected highlight across the panel.\n */\nexport function RefractedGlass({\n className,\n thickness = 0.34,\n ior = 1.52,\n dispersion = 1,\n bevel = 0.34,\n tint = \"#9fc0ff\",\n}: RefractedGlassProps) {\n const uniforms = useMemo(\n () => ({\n uTint: hexToRgb(tint),\n uThickness: thickness,\n uIor: ior,\n uDispersion: dispersion,\n uBevel: bevel,\n }),\n [tint, thickness, ior, dispersion, bevel],\n );\n\n const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\n// What the glass is sitting on. Deliberately built from soft blooms *and* hard\n// diagonal bars: the blooms sell depth, but only an edge makes dispersion legible.\nvec3 backdrop(vec2 q, float t) {\n vec3 c = vec3(0.016, 0.020, 0.042);\n c += vec3(0.26, 0.40, 0.92) * 0.62 * exp(-length((q - vec2(-0.52, 0.26)) * vec2(1.0, 1.25)) * 2.6);\n c += vec3(0.52, 0.30, 0.86) * 0.44 * exp(-length((q - vec2(0.54, -0.30)) * vec2(1.0, 1.3)) * 3.0);\n c += vec3(0.16, 0.52, 0.70) * 0.22 * exp(-length(q - vec2(0.10, 0.52)) * 3.4);\n float b = sin((q.x * 0.62 + q.y * 1.55) * 8.5 - t * 0.42);\n c += vec3(0.70, 0.80, 1.00) * 0.52 * smoothstep(0.86, 0.995, b);\n float b2 = sin((q.x * 1.30 - q.y * 0.70) * 5.0 + t * 0.28);\n c += vec3(0.34, 0.54, 1.00) * 0.30 * smoothstep(0.88, 1.00, b2);\n return c;\n}\n\nfloat sdRoundBox(vec2 p, vec2 b, float r) {\n vec2 q = abs(p) - b + r;\n return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;\n}\n\n// Interior distance lifted into a quarter-circle fillet. sqrt(1-(1-k)^2) has an\n// infinite slope at the rim, which is precisely what a real bevel does to a normal.\nfloat slabHeight(vec2 p, float bw) {\n float k = clamp(-sdRoundBox(p, vec2(0.655, 0.352), 0.085) / bw, 0.0, 1.0);\n return sqrt(max(0.0, 1.0 - (1.0 - k) * (1.0 - k)));\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n\n float t = uTime;\n float bw = max(0.02, uBevel * 0.30);\n float sd = sdRoundBox(uv, vec2(0.655, 0.352), 0.085);\n\n if (sd > 0.0) {\n // Outside the panel: the raw backdrop, plus the shadow the slab casts and a\n // thin sliver of light leaking out along the ground contact.\n vec3 col = backdrop(uv, t) * (1.0 - 0.62 * exp(-sd * 7.0));\n col += uTint * 0.16 * exp(-sd * 60.0);\n col *= 1.0 - 0.40 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012;\n fragColor = vec4(max(col, 0.0), 1.0);\n return;\n }\n\n float e = 1.6 / m;\n float h = slabHeight(uv, bw);\n vec3 N = normalize(vec3((slabHeight(uv - vec2(e, 0.0), bw) - slabHeight(uv + vec2(e, 0.0), bw)) * 0.42,\n (slabHeight(uv - vec2(0.0, e), bw) - slabHeight(uv + vec2(0.0, e), bw)) * 0.42,\n e));\n\n vec3 I = vec3(0.0, 0.0, -1.0);\n float d0 = uDispersion * 0.125;\n // Cauchy ordering: blue is bent hardest, so the blue fringe always lands\n // further in from the rim than the red one.\n float travel = uThickness * (0.70 + 0.30 * h);\n vec2 oR = vec2(0.0), oG = vec2(0.0), oB = vec2(0.0);\n vec3 tR = refract(I, N, 1.0 / max(1.02, uIor - d0));\n vec3 tG = refract(I, N, 1.0 / max(1.02, uIor));\n vec3 tB = refract(I, N, 1.0 / max(1.02, uIor + d0));\n if (tR.z < -0.02) oR = tR.xy * (travel / -tR.z);\n if (tG.z < -0.02) oG = tG.xy * (travel / -tG.z);\n if (tB.z < -0.02) oB = tB.xy * (travel / -tB.z);\n\n // Two taps per channel a little apart along the refraction direction: the\n // cheapest thing that reads as \"solid glass\" rather than \"a warped picture\".\n float frost = 0.006 + 0.030 * (1.0 - h);\n vec3 s0 = vec3(backdrop(uv + oR, t).r, backdrop(uv + oG, t).g, backdrop(uv + oB, t).b);\n vec3 s1 = vec3(backdrop(uv + oR * 1.22 + frost, t).r,\n backdrop(uv + oG * 1.22 - frost, t).g,\n backdrop(uv + oB * 1.22 + frost * 0.5, t).b);\n vec3 through = mix(s0, s1, 0.34);\n\n // Beer-Lambert through the body: thick glass is not just darker, it is tinted.\n through *= exp(-(1.0 - uTint) * travel * 2.1);\n\n float cosI = clamp(N.z, 0.0, 1.0);\n float F = 0.055 + 0.945 * pow(1.0 - cosI, 5.0);\n\n // Reflected environment: a vertical sky ramp is enough, because the only\n // place the reflection vector swings far off axis is on the bevel anyway.\n vec3 R = reflect(I, N);\n vec3 env = mix(vec3(0.05, 0.06, 0.11), vec3(0.42, 0.54, 0.86), smoothstep(-0.6, 0.9, R.y))\n + vec3(0.30, 0.34, 0.50) * smoothstep(0.2, 1.0, -R.x) * 0.5;\n\n vec3 Lp = vec3(mix(vec2(-0.46, 0.40), pc, uPointer.z), 0.85);\n vec3 L = normalize(Lp - vec3(uv, h * uThickness));\n float spec = pow(max(dot(R, L), 0.0), 46.0) * 1.5 + pow(max(dot(R, L), 0.0), 6.0) * 0.14;\n\n vec3 col = mix(through, env, F) + spec * (0.5 + 0.5 * uTint);\n\n // The fillet is a lens; a third of the way up its slope the rays it turns all\n // pile into one band. That band is what makes a bevel look expensive.\n float k = clamp(-sd / bw, 0.0, 1.0);\n float conv = exp(-pow((k - 0.30) / 0.13, 2.0)) * (1.0 - 0.55 * abs(uv.y) / 0.36);\n col += uTint * conv * 0.30;\n col += vec3(1.0) * exp(-pow((k - 0.06) / 0.05, 2.0)) * 0.085;\n\n // Interior sheen so the flat centre is not dead: a very broad grazing term.\n col += uTint * 0.05 * pow(1.0 - cosI, 1.6);\n\n col = 1.0 - exp(-col * 1.34);\n col *= 1.0 - 0.30 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012;\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "glass", + "refraction", + "dispersion", + "background" + ] + } +} diff --git a/public/r/translucent-wax.json b/public/r/translucent-wax.json new file mode 100644 index 0000000..cf4570b --- /dev/null +++ b/public/r/translucent-wax.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "translucent-wax", + "type": "registry:component", + "title": "Translucent Wax", + "description": "Light travelling through a solid rather than off it: a back-lit slab of banded stone. The interior light path is marched to accumulate thickness, attenuated by Beer–Lambert with wrapped diffuse and a forward-scatter lobe, so the ground edge glows and the body goes deep.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/translucent-wax.tsx", + "type": "registry:component", + "target": "components/reactomega/translucent-wax.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface TranslucentWaxProps {\n className?: string;\n /** Depth of the slab. Thicker material lets less of the backlight through. @default 1 */\n thickness?: number;\n /** Width of the forward-scattering lobe and how far light bleeds sideways. @default 1 */\n scatter?: number;\n /** Colour the material transmits — what survives the absorption. @default \"#f7d2a8\" */\n tint?: string;\n /** Beer-Lambert extinction coefficient. Higher goes waxy, lower goes glassy. @default 2.45 */\n absorption?: number;\n /** Width of the ground edge as a fraction of the slab, 0..1. @default 0.52 */\n bevel?: number;\n}\n\n/**\n * TranslucentWax — a ground slab of backlit alabaster, honey onyx cut thin\n * enough to pass light. Almost everything you see has been through the material\n * rather than off it.\n *\n * The body is a rounded-rectangle slab with a wide ground edge, and that\n * boundary is doing most of the work: a translucent solid is legible only by the\n * contrast between a glowing thin rim and a choked interior, so the form has to\n * be one whose thickness varies in a way the eye can read as a shape. A blob\n * cannot do that — its silhouette carries no information — whereas a slab says\n * \"large through the middle, small at the edge\" before any light is traced. The\n * ground edge is deliberately wide and its depth ramp very nearly linear, like a\n * chamfer rather than a fillet: a fillet reaches full depth within a few pixels\n * of the silhouette, so the pale rim exists but is too narrow to see and the slab\n * collapses back into one flat sheet with a hot outline. For\n * every pixel the shader marches the real light path, fourteen steps from the\n * front surface toward the source, accumulating the distance that stays between\n * the slab's lower and upper skins. Beer-Lambert then attenuates each channel by\n * exp(-σ·d), with σ taken as the complement of the tint and modulated by a\n * banded strata field, so the ground edge passes a pale cream and the centre\n * chokes down through amber to a deep ember, in that order, for the same reason\n * real onyx does. The veining is banded along the slab rather than isotropic:\n * strata read as stone, wandering noise reads as putty. A wrapped half-Lambert\n * against the back face lets the terminator bleed around the edge roll, and a\n * forward-scattering lobe blooms where the lamp sits directly behind a thin\n * section. Front lighting is deliberately almost absent: a little polish\n * specular, nothing more. The lamp is behind the stone and follows the pointer.\n */\nexport function TranslucentWax({\n className,\n thickness = 1,\n scatter = 1,\n tint = \"#f7d2a8\",\n absorption = 2.45,\n bevel = 0.52,\n}: TranslucentWaxProps) {\n const uniforms = useMemo(\n () => ({\n uTint: hexToRgb(tint),\n uThickness: thickness,\n uScatter: scatter,\n uAbsorb: absorption,\n uBevel: bevel,\n }),\n [tint, thickness, scatter, absorption, bevel],\n );\n\n const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\n// Quintic interpolant rather than the usual smoothstep. The occupancy field is\n// integrated along a light path, and cubic value noise has a discontinuous\n// second derivative at every lattice line — which shows up in the transmission\n// as faint polygonal creases across the stone.\nfloat vnoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);\n return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float a = 0.5, v = 0.0;\n for (int i = 0; i < 4; i++) {\n v += a * vnoise(p);\n p = mat2(1.6, 1.2, -1.2, 1.6) * p;\n a *= 0.5;\n }\n return v;\n}\n\nfloat sdRoundBox(vec2 p, vec2 b, float r) {\n vec2 q = abs(p) - b + r;\n return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;\n}\n\nfloat smax(float a, float b, float k) {\n float h = clamp(0.5 + 0.5 * (a - b) / k, 0.0, 1.0);\n return mix(b, a, h) + k * h * (1.0 - h);\n}\n\n// The same field, but with the interior corner rounded. A box distance field has\n// a gradient discontinuity along each diagonal, and because the ground edge here\n// is wide those four creases run a long way in and meet near the middle — the\n// depth field's own medial axis, printed across the slab as an envelope-flap X.\n// Softening only the interior branch removes them without moving the silhouette:\n// the outer branch, length(max(q,0)), is what sets the boundary and is untouched,\n// and the k/4 bias smax adds along the diagonal is subtracted straight back off.\nfloat sdSlabSoft(vec2 p, vec2 b, float r) {\n vec2 q = abs(p) - b + r;\n const float K = 0.13;\n return min(smax(q.x, q.y, K) - 0.25 * K, 0.0) + length(max(q, 0.0)) - r;\n}\n\nconst vec2 SLAB = vec2(0.575, 0.325);\nconst float SLAB_R = 0.115;\n// A few degrees off axis. A slab squared to the frame reads as a UI panel; the\n// same slab tilted reads as an object that was placed there.\nconst mat2 TILT = mat2(0.99255, -0.12187, 0.12187, 0.99255);\n\n// Bedding planes. The band coordinate runs across the short axis of the slab and\n// is warped by a stretched fbm — anisotropic on purpose, because the whole point\n// is that the layers stay layers. Isotropic noise here is what made the earlier\n// pass read as putty rather than as a cut stone.\nfloat strata(vec2 sp, float t) {\n vec2 w = sp * vec2(0.9, 2.3) + vec2(t * 0.055, 0.0);\n float warp = fbm(w) - 0.5;\n float u = sp.y * 7.6 + sp.x * 1.30 + warp * 2.0;\n float band = 0.82 + 0.30 * sin(u * 2.1) + 0.17 * sin(u * 5.3 + 1.7) + 0.09 * sin(u * 11.0);\n // Fine grain on top, small enough that it never competes with the banding.\n band += 0.10 * (fbm(sp * 7.0 + 11.0) - 0.5);\n return max(band, 0.28);\n}\n\n// Interior fraction of the slab: 0 outside, 1 across the flat. The ground edge\n// is where it ramps, and how wide that ramp is decides how much glowing rim\n// there is to look at.\nfloat slabK(vec2 sp, float bw) {\n return max(-sdSlabSoft(sp, SLAB, SLAB_R), 0.0) / bw;\n}\n\n// Cross-section against interior fraction. This is the single most important\n// number in the file, because the *width of the thickness ramp* is what the eye\n// reads as \"light is coming through a solid thing\". A circular fillet, or any\n// power below 1, reaches full depth within a few pixels of the silhouette: the\n// pale rim then exists but is two pixels wide, and the slab reads as one flat\n// sheet of amber with a hot outline. A chamfer — depth rising very nearly\n// linearly across a wide ground edge — spreads the whole pale-to-amber-to-ember\n// ramp over sixty pixels, which is the only reason the gradient is legible.\n//\n// It saturates exponentially instead of being clamped. A clamp at full depth\n// puts a kink in the depth field along the whole locus where it first bites, and\n// because the normal is a difference of this function that kink prints as a hard\n// rectangle drawn inside the slab — the plateau's own outline, which is not a\n// feature of any real stone.\nfloat profile(float x) {\n return 1.0 - exp(-1.6 * pow(x, 1.15));\n}\n\n// Half-depth of the slab at this point, including a little relief in the\n// bedding so the interior thickness is not perfectly constant. The relief is\n// banded for the same reason the absorption is.\nfloat halfDepth(vec2 sp, float t, float bw) {\n float prof = profile(slabK(sp, bw));\n float lay = 0.5 + 0.5 * sin(sp.y * 7.4 + 1.9 * (fbm(sp * vec2(0.8, 2.0) + 4.0) - 0.5) * 3.0);\n return prof * (0.90 + 0.10 * lay);\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n\n float t = uTime * 0.5;\n vec2 sp = TILT * uv;\n float bw = max(0.03, uBevel * 0.30);\n\n float sd = sdRoundBox(sp, SLAB, SLAB_R);\n float k = slabK(sp, bw);\n float hd = halfDepth(sp, t, bw);\n float top = 0.55 * uThickness * hd;\n\n // The lamp is behind the slab and drifts; the pointer takes it over. Kept well\n // off centre: a lamp behind the middle lights the slab radially, and radial\n // symmetry is what makes backlit things look like lamps instead of like stone\n // on a light box.\n vec2 lxy = mix(vec2(-0.30 + 0.44 * cos(t * 0.80), 0.24 * sin(t * 0.63 + 1.0)), pc, uPointer.z);\n vec3 Lpos = vec3(lxy, -1.45 * uThickness);\n\n if (sd > 0.0) {\n // Off the slab: the dark table, the lamp bleeding round the silhouette, and\n // a warm contact line hugging the edge.\n vec3 col = vec3(0.014, 0.013, 0.017);\n col += uTint * 0.022 * exp(-length(uv - lxy) * 1.5);\n col += mix(uTint, vec3(1.0), 0.30) * 0.30 * exp(-sd * 26.0);\n col += uTint * 0.10 * exp(-sd * 7.0) * (0.35 + 0.65 * exp(-length(uv - lxy) * 1.2));\n col *= 1.0 - 0.42 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012;\n fragColor = vec4(max(col, 0.0), 1.0);\n return;\n }\n\n float e = 1.7 / m;\n vec3 N = normalize(vec3((halfDepth(sp - vec2(e, 0.0), t, bw) - halfDepth(sp + vec2(e, 0.0), t, bw)) * 0.55,\n (halfDepth(sp - vec2(0.0, e), t, bw) - halfDepth(sp + vec2(0.0, e), t, bw)) * 0.55,\n e));\n\n vec3 Pw = vec3(uv, top);\n vec3 L = normalize(Lpos - Pw);\n\n // March the real light path and measure how much of it lies inside the body.\n // This is the whole point: thickness is measured, not inferred from a normal.\n // Step length scaled to the local slab depth, not to a fixed worst case. With\n // a constant span the jittered steps are enormous compared with the thickness\n // of the ground edge, so the thin rim comes out as salt-and-pepper noise\n // instead of a gradient. The small constant term lets a ray leaving a thin\n // region still reach the thicker material next to it.\n float span = (1.05 * uThickness * hd + 0.18 * uThickness) / max(0.20, -L.z);\n float ds = span / 14.0;\n float dist = 0.0;\n // Start each pixel's march at a different fraction of a step. Fourteen steps\n // on a lock-step grid quantise the thickness and print terraces straight into\n // the transmission; jittering the phase turns that into noise the dither hides.\n vec3 q = Pw + L * ds * hash(gl_FragCoord.xy * 1.37);\n for (int i = 0; i < 14; i++) {\n float qh = halfDepth(TILT * q.xy, t, bw);\n if (q.z < -0.55 * uThickness * qh) break;\n if (q.z < 0.55 * uThickness * qh) dist += ds;\n q += L * ds;\n }\n // Floor on the optical depth. exp(-sigma*0) is 1 in every channel, so a path\n // length that reaches zero at the silhouette transmits the lamp unchanged and\n // rings the whole slab in white. Physically the light still has to cross the\n // scattering skin, and one pixel spans a range of depths anyway.\n dist = max(dist, 0.070 * uThickness);\n\n // Strata modulate the extinction coefficient, not the colour, so the bedding\n // only shows where there is enough material for it to matter — which is why\n // the layers fade out as they run into the ground edge, exactly as in a real\n // cut slab.\n float vein = strata(sp, t);\n vec3 sigma = (1.0 - uTint * 0.94) * uAbsorb * 6.0 * vein;\n vec3 trans = exp(-sigma * dist);\n\n // Wrapped diffuse against the back face — a half-Lambert with a wide wrap, so\n // the terminator bleeds around the edge roll the way scattering media do.\n float wrap = 0.55 * uScatter;\n float back = clamp((dot(-N, L) + wrap) / (1.0 + wrap), 0.0, 1.0);\n back *= back;\n\n // Forward scattering: light that keeps roughly its original direction after a\n // few bounces, so thin sections right in front of the lamp glow out.\n vec3 V = normalize(vec3(-uv * 0.6, 1.0));\n vec3 Lt = normalize(L + N * (0.30 * uScatter));\n float fd = clamp(dot(V, -Lt), 0.0, 1.0);\n float fwd = pow(fd, 3.0 / uScatter) * 1.4 + pow(fd, 14.0) * 1.0;\n\n float atten = 1.0 / (1.0 + 0.55 * dot(Lpos.xy - uv, Lpos.xy - uv));\n\n // Deep transmission shifts as well as darkens: an absorbing medium walks the\n // hue, and in warm stone the last thing to survive is the red. Multiple\n // scattering gives that floor a much longer tail than the ballistic term, so\n // it gets the same sigma over a heavily shortened effective path rather than a\n // constant — a constant floor is what made the first pass go *brighter* toward\n // the middle, since nothing then attenuated with thickness at all.\n // Diffusion is not a free pass: the multiply-scattered floor keeps losing\n // energy too, so it gets its own extinction — mostly achromatic, because a\n // random walk averages the channels, plus a share of the spectral sigma to\n // keep the hue walking red. Giving it *only* the spectral part left the red\n // channel almost flat with depth, which is why the slab read as one uniform\n // sheet of amber instead of thick-and-deep against thin-and-pale.\n vec3 sigmaD = vec3(0.30 * dot(sigma, vec3(0.3333))) + 0.16 * sigma;\n vec3 deep = uTint * vec3(0.94, 0.70, 0.33) * exp(-sigmaD * dist);\n\n vec3 col = uTint * 0.008;\n // Blend on the raw transmission, not on a scaled-and-clamped copy of it: the\n // clamp stops the hue walking at a fixed thickness and prints a hard contour\n // ring right through the middle of the slab.\n col += mix(deep, trans, trans.g) * (0.55 + 0.62 * back) * atten * 1.20;\n col += trans * fwd * atten * 0.42 * uScatter;\n\n // The ground edge glows hot and pale where almost no material is in the way.\n // Kept tight, so it reads as an edge rather than as a bloom.\n col += mix(uTint, vec3(1.0), 0.42) * exp(-dist * 8.0) * atten * 0.62;\n\n // Front side: only enough to say the surface is polished, not lit.\n vec3 Kf = normalize(vec3(-0.45, 0.60, 0.66));\n col += vec3(0.72, 0.74, 0.80) * pow(max(dot(reflect(-Kf, N), V), 0.0), 60.0) * 0.12;\n col += uTint * 0.025 * max(dot(N, Kf), 0.0);\n // Grain, and a darker line right at the silhouette so the slab has an outline.\n col *= 0.95 + 0.10 * fbm(sp * 5.0 + 12.0);\n col *= 0.78 + 0.22 * smoothstep(0.0, 0.028, k);\n\n col = 1.0 - exp(-col * 1.20);\n col *= 1.0 - 0.44 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.013;\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "subsurface", + "stone", + "translucent", + "background" + ] + } +} diff --git a/public/r/velvet-sheen.json b/public/r/velvet-sheen.json new file mode 100644 index 0000000..1bb5e39 --- /dev/null +++ b/public/r/velvet-sheen.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "velvet-sheen", + "type": "registry:component", + "title": "Velvet Sheen", + "description": "Soft fabric: an Ashikhmin velvet lobe gated by an inverted Fresnel, so the cloth is brightest at grazing angles and dark facing the viewer. Fibre rank modulates the sheen itself, and the drape comes from wandering the phase and amplitude of a gather train rather than adding noise to the height.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/velvet-sheen.tsx", + "type": "registry:component", + "target": "components/reactomega/velvet-sheen.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface VelvetSheenProps {\n className?: string;\n /** Strength of the retroreflective sheen at grazing angles. @default 1 */\n sheen?: number;\n /** Amount of fibre disorder in the nap, 0..1. @default 0.62 */\n fuzz?: number;\n /** Dye colour of the pile. @default \"#5b3fa8\" */\n tint?: string;\n /** Colour the folds fall into. @default \"#07070d\" */\n shadow?: string;\n}\n\n/**\n * VelvetSheen — a bolt of velvet lying in soft folds, lit by its own nap rather\n * than by anything reflective.\n *\n * Fabric with a pile does not obey a normal specular model. Each fibre stands\n * roughly upright, so light arriving almost parallel to the cloth grazes the\n * whole length of the pile and scatters straight back, while light arriving\n * face-on disappears down between the fibres. The BRDF here is that inversion:\n * an Ashikhmin-style velvet distribution built on 1/(N·H)² over the fibre\n * tangent, gated by an *inverted* Fresnel — pow(1 - N·V, 4) — so the cloth is\n * brightest exactly where it turns away from you and darkest where it faces\n * you. Diffuse is wrapped around the terminator with a subsurface half-Lambert,\n * because dyed pile bleeds light sideways, and the fold flanks carry a fine\n * fuzz normal from stretched noise plus a per-fibre density variance that\n * scales the sheen. The result reads as textile because the highlight follows\n * the silhouette of every fold instead of sitting on top of it. The pointer\n * combs the nap.\n */\nexport function VelvetSheen({\n className,\n sheen = 1,\n fuzz = 0.62,\n tint = \"#5b3fa8\",\n shadow = \"#07070d\",\n}: VelvetSheenProps) {\n const uniforms = useMemo(\n () => ({\n uTint: hexToRgb(tint),\n uShadow: hexToRgb(shadow),\n uSheen: sheen,\n uFuzz: fuzz,\n }),\n [tint, shadow, sheen, fuzz],\n );\n\n const { ref, supported } = useShader({ speed: 1, uniforms, fragment: FRAG });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat vnoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float a = 0.5, v = 0.0;\n for (int i = 0; i < 4; i++) {\n v += a * vnoise(p);\n p = mat2(1.6, 1.2, -1.2, 1.6) * p;\n a *= 0.5;\n }\n return v;\n}\n\n// Height of the draped cloth. Long soft folds along one axis plus a slow FBM\n// sag, so the folds gather and release the way heavy fabric does.\nfloat cloth(vec2 p, float t) {\n // A bolt of cloth hangs in long parallel gathers. The noise only wanders the\n // phase and amplitude of those gathers — added to the height directly it turns\n // the drape into lumpy terrain, which is exactly what velvet never looks like.\n float ph = fbm(p * 0.30 + vec2(0.0, t * 0.035)) * 2.9;\n float amp = 0.72 + 0.60 * fbm(p * 0.26 + vec2(4.0, 1.0));\n float fold = sin(p.x * 3.05 + p.y * 0.42 + ph + t * 0.20)\n + 0.44 * sin(p.x * 5.70 - p.y * 0.26 + ph * 0.7 - t * 0.14);\n return fold * amp * 0.30;\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n\n float t = uTime * 0.45;\n vec2 p = uv * 2.6;\n\n float e = 0.014;\n float h = cloth(p, t);\n vec3 N = normalize(vec3((cloth(p - vec2(e, 0.0), t) - cloth(p + vec2(e, 0.0), t)) * 3.1,\n (cloth(p - vec2(0.0, e), t) - cloth(p + vec2(0.0, e), t)) * 3.1,\n e * 0.72));\n\n // Nap: fibres lie in ranks, so the disorder is stretched, not isotropic.\n vec2 nq = uv * vec2(150.0, 420.0);\n float f1 = vnoise(nq) - 0.5;\n float f2 = vnoise(nq * 2.7 + 31.0) - 0.5;\n vec3 fz = vec3(f1 * 1.0, f2 * 0.55, 0.0) * uFuzz * 0.16;\n // The pointer combs the pile flat, which locally kills the sheen.\n vec2 rel = uv - pc;\n float comb = uPointer.z * exp(-dot(rel, rel) * 10.0);\n vec3 Nf = normalize(N + fz - vec3(normalize(rel + 1e-5) * comb * 0.22, 0.0));\n\n // Per-fibre density variance. Velvet is never uniformly bright; this is what\n // separates cloth from a glowing outline.\n float dens = 0.70 + 0.36 * fbm(uv * 48.0 + 5.0) + 0.16 * (vnoise(uv * vec2(120.0, 340.0)) - 0.5);\n\n vec3 V = normalize(vec3(-uv * 0.55, 1.0));\n vec3 L = normalize(vec3(mix(vec2(-0.62, 0.30), pc, uPointer.z) - uv * 0.4, 0.50));\n vec3 H = normalize(L + V);\n\n float NdV = clamp(dot(Nf, V), 0.001, 1.0);\n float NdL = dot(Nf, L);\n float NdH = clamp(dot(Nf, H), 0.001, 1.0);\n\n // Wrapped diffuse. Pile scatters sideways, so the terminator bleeds well past\n // where a Lambert surface would already be black.\n float wrap = 0.42;\n float diff = clamp((NdL + wrap) / (1.0 + wrap), 0.0, 1.0);\n diff *= diff;\n\n // Ashikhmin velvet lobe over the fibre tangent, gated by an inverted Fresnel.\n // Both factors peak where the surface turns edge-on to the eye.\n float sinTH2 = max(0.0, 1.0 - NdH * NdH);\n float velvet = (sinTH2 * sinTH2) / (NdH * NdH * NdH * NdH + 1e-4);\n velvet = min(velvet, 9.0);\n float invFres = pow(1.0 - NdV, 5.0);\n float retro = pow(clamp(dot(-V, -L) * 0.5 + 0.5, 0.0, 1.0), 2.0);\n\n // Fibre-scale modulation of the sheen itself, not just of the normal: the pile\n // lies in ranks and the ranks glint separately. Without this the sheen bands\n // come out as smooth airbrushed gradients and lose the textile read entirely.\n float rank = 0.58 + 0.72 * vnoise(uv * vec2(210.0, 55.0) + 4.0)\n + 0.26 * (vnoise(uv * vec2(38.0, 620.0)) - 0.5);\n float sheen = uSheen * dens * rank * max(NdL + 0.30, 0.0)\n * (0.055 * velvet * invFres + 1.15 * invFres * (0.28 + 0.72 * retro));\n\n // Ambient occlusion from the fold depth: the bottoms of the gathers go dark\n // even where they are turned toward the light.\n float ao = smoothstep(-0.42, 0.60, h) * 0.62 + 0.38;\n\n vec3 pileLo = uShadow + uTint * 0.085;\n vec3 body = mix(pileLo, uTint * 0.90, diff * ao);\n vec3 sheenCol = mix(uTint * 0.55 + vec3(0.30, 0.32, 0.42), vec3(0.72, 0.74, 0.86), 0.45);\n\n // The pile itself is held down hard. Velvet is a dark cloth; if the body is\n // bright the sheen has nothing to be brighter than and the fabric cue dies.\n vec3 col = body * (0.20 + 0.40 * ao);\n col += sheenCol * sheen * ao * 0.52;\n col += uTint * 0.10 * comb;\n\n col = 1.0 - exp(-col * 1.62);\n col *= 1.0 - 0.52 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.013;\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "fabric", + "velvet", + "sheen", + "background" + ] + } +} From dcf64d6b0aafe5760a8a214da26afc1cf1740150 Mon Sep 17 00:00:00 2001 From: Ed Chen <37851723+Edwson@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:11:56 +0800 Subject: [PATCH 4/8] =?UTF-8?q?docs:=20How=20to=20use=20page=20=E2=80=94?= =?UTF-8?q?=20MCP-first=20install=20and=20deployment=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/app/how-to-use/page.tsx | 338 ++++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 web/app/how-to-use/page.tsx diff --git a/web/app/how-to-use/page.tsx b/web/app/how-to-use/page.tsx new file mode 100644 index 0000000..187ad97 --- /dev/null +++ b/web/app/how-to-use/page.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { Nav } from "@/components/site/nav"; +import { CopyButton } from "@/components/site/copy-button"; +import { DEMOS } from "@/components/site/demos"; + +/* A code block that is copyable, because a docs page nobody can copy from is a + * screenshot. `label` names the block in its header; the body is what it copies. */ +function Code({ children, label }: { children: string; label?: string }) { + const body = children.trim(); + const pre = useRef(null); + /* A cut-off line reads as broken text unless something says "there is more + * to the right". Only shown when the block actually overflows. */ + const [clipped, setClipped] = useState(false); + useEffect(() => { + const el = pre.current; + if (!el) return; + const measure = () => setClipped(el.scrollWidth > el.clientWidth + 1 && el.scrollLeft < el.scrollWidth - el.clientWidth - 1); + measure(); + el.addEventListener("scroll", measure, { passive: true }); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => { + el.removeEventListener("scroll", measure); + ro.disconnect(); + }; + }, [body]); + + return ( +
+
+ {label ?? "shell"} + +
+
+
+          {body}
+        
+
+
+
+ ); +} + +function Step({ n, title, children, accent }: { n: string; title: string; children: React.ReactNode; accent?: boolean }) { + return ( +
+
+ + {n} + +

{title}

+ {accent ? ( + + recommended + + ) : null} +
+
{children}
+
+ ); +} + +const MCP_CONFIG = `{ + "mcpServers": { + "reactomega": { + "command": "npx", + "args": ["-y", "github:Edwson/ReactOmega", "reactomega-mcp"] + } + } +}`; + +export default function HowToUsePage() { + const shaderCount = DEMOS.filter((d) => d.category === "shader").length; + + return ( +
+
+ ); +} From a519fe89062fc86c09b0052f3a8a9af35612505b Mon Sep 17 00:00:00 2001 From: Ed Chen <37851723+Edwson@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:12:19 +0800 Subject: [PATCH 5/8] playground: How to use nav link, 6 shader demos, optional copy-button label --- web/components/site/copy-button.tsx | 6 ++++-- web/components/site/demos.tsx | 12 ++++++++++++ web/components/site/nav.tsx | 3 +++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/web/components/site/copy-button.tsx b/web/components/site/copy-button.tsx index 8e8f8ce..2a5a282 100644 --- a/web/components/site/copy-button.tsx +++ b/web/components/site/copy-button.tsx @@ -3,7 +3,9 @@ import { useState } from "react"; import { cn } from "@/lib/utils"; -export function CopyButton({ text, label, className }: { text: string; label?: string; className?: string }) { +/* `label` defaults to the copied text; pass null for a bare "copy" affordance + * when the surrounding chrome already names what is being copied. */ +export function CopyButton({ text, label, className }: { text: string; label?: string | null; className?: string }) { const [copied, setCopied] = useState(false); return ( ); diff --git a/web/components/site/demos.tsx b/web/components/site/demos.tsx index bcc191a..036b418 100644 --- a/web/components/site/demos.tsx +++ b/web/components/site/demos.tsx @@ -38,6 +38,12 @@ import { Caustics } from "@/components/reactomega/caustics"; import { HalftoneGradient } from "@/components/reactomega/halftone-gradient"; import { VolumetricRays } from "@/components/reactomega/volumetric-rays"; import { CurlFlow } from "@/components/reactomega/curl-flow"; +import { RefractedGlass } from "@/components/reactomega/refracted-glass"; +import { BrushedMetal } from "@/components/reactomega/brushed-metal"; +import { MoireWeave } from "@/components/reactomega/moire-weave"; +import { VelvetSheen } from "@/components/reactomega/velvet-sheen"; +import { TranslucentWax } from "@/components/reactomega/translucent-wax"; +import { DiffractionGrating } from "@/components/reactomega/diffraction-grating"; import { InertiaCursor } from "@/components/reactomega/inertia-cursor"; import { ElasticCursor } from "@/components/reactomega/elastic-cursor"; import { ImageTrail } from "@/components/reactomega/image-trail"; @@ -164,6 +170,12 @@ export const DEMOS: Demo[] = [ { name: "halftone-gradient", title: "Halftone Gradient", category: "shader", fill: true, node: }, { name: "volumetric-rays", title: "Volumetric Rays", category: "shader", fill: true, node: }, { name: "curl-flow", title: "Curl Flow", category: "shader", fill: true, node: }, + { name: "refracted-glass", title: "Refracted Glass", category: "shader", fill: true, node: }, + { name: "brushed-metal", title: "Brushed Metal", category: "shader", fill: true, node: }, + { name: "moire-weave", title: "Moiré Weave", category: "shader", fill: true, node: }, + { name: "velvet-sheen", title: "Velvet Sheen", category: "shader", fill: true, node: }, + { name: "translucent-wax", title: "Translucent Wax", category: "shader", fill: true, node: }, + { name: "diffraction-grating", title: "Diffraction Grating", category: "shader", fill: true, node: }, // ---- cursor & pointer ---- { name: "inertia-cursor", title: "Inertia Cursor", category: "cursor", fill: true, node: }, diff --git a/web/components/site/nav.tsx b/web/components/site/nav.tsx index cd83181..e895d9a 100644 --- a/web/components/site/nav.tsx +++ b/web/components/site/nav.tsx @@ -11,6 +11,9 @@ export function Nav() { Components + + How to use + Date: Fri, 7 Aug 2026 11:12:42 +0800 Subject: [PATCH 6/8] registry: meta is the single source of truth for 57 components / 12 shaders --- registry/meta.json | 110 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/registry/meta.json b/registry/meta.json index 1bc4ab2..3b2dc5d 100644 --- a/registry/meta.json +++ b/registry/meta.json @@ -1,6 +1,6 @@ { "$comment": "Single source of truth for the ReactOmega registry. scripts/build-registry.mjs reads this + the component source files to generate registry.json, public/r/*.json (shadcn-compatible), and llms.txt. 'uses' lists internal registry dependencies (primitives). 'dependencies' lists npm packages a consumer must install.", - "version": "1.1.0", + "version": "1.2.0", "primitives": { "utils": { "title": "cn() utility", @@ -872,6 +872,114 @@ "utils", "use-prefers-reduced-motion" ] + }, + "refracted-glass": { + "title": "Refracted Glass", + "category": "shader", + "description": "A thick bevelled glass panel over a procedural backdrop. The view ray is refracted through the slab with a different index per RGB channel, so the bevel carries real chromatic dispersion, and a Schlick Fresnel term brightens the rim where the surface turns away.", + "tags": [ + "shader", + "webgl", + "glass", + "refraction", + "dispersion", + "background" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] + }, + "brushed-metal": { + "title": "Brushed Metal", + "category": "shader", + "description": "Machined metal, still and precise \u2014 the opposite register to liquid-metal's flow. An anisotropic GGX lobe with Smith-correlated shadowing runs over a brush-direction field, so the highlight stretches perpendicular to the grain. Linear or radial (engine-turned) finishes.", + "tags": [ + "shader", + "webgl", + "metal", + "anisotropic", + "specular", + "background" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] + }, + "moire-weave": { + "title": "Moir\u00e9 Weave", + "category": "shader", + "description": "Two rigid lattices at a small relative angle, beating against each other at their true difference frequency. Every cosine is band-limited by an exact box filter, so the pattern dissolves into its own mean grey as the period approaches two pixels instead of aliasing into noise.", + "tags": [ + "shader", + "webgl", + "moire", + "interference", + "weave", + "background" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] + }, + "velvet-sheen": { + "title": "Velvet Sheen", + "category": "shader", + "description": "Soft fabric: an Ashikhmin velvet lobe gated by an inverted Fresnel, so the cloth is brightest at grazing angles and dark facing the viewer. Fibre rank modulates the sheen itself, and the drape comes from wandering the phase and amplitude of a gather train rather than adding noise to the height.", + "tags": [ + "shader", + "webgl", + "fabric", + "velvet", + "sheen", + "background" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] + }, + "translucent-wax": { + "title": "Translucent Wax", + "category": "shader", + "description": "Light travelling through a solid rather than off it: a back-lit slab of banded stone. The interior light path is marched to accumulate thickness, attenuated by Beer\u2013Lambert with wrapped diffuse and a forward-scatter lobe, so the ground edge glows and the body goes deep.", + "tags": [ + "shader", + "webgl", + "subsurface", + "stone", + "translucent", + "background" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] + }, + "diffraction-grating": { + "title": "Diffraction Grating", + "category": "shader", + "description": "The optics behind a CD surface: the grating equation places spectral orders by solving d\u00b7sin\u03b8 = m\u03bb for wavelength per order, giving sharp rainbow lines rather than broad fringes. Higher orders are dimmer and wash toward white, over a band-limited pressed ruling.", + "tags": [ + "shader", + "webgl", + "diffraction", + "spectrum", + "iridescent", + "background" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] } } } From 57ba06238f8a47208b1b4cf9fb2427267115ae0b Mon Sep 17 00:00:00 2001 From: Ed Chen <37851723+Edwson@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:13:03 +0800 Subject: [PATCH 7/8] deploy: cPanel bundle notes for 1.2.0 --- deploy/DEPLOY.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index d4a1dd2..215c1c9 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -1,7 +1,7 @@ # Deploy ReactOmega → https://edwson.com/ReactOmega/ -`ReactOmega-cpanel.zip` is a **fully static** build of the new ReactOmega playground -(31 live components). No Node server, no database — it's plain HTML/CSS/JS that Apache +`ReactOmega-cpanel.zip` is a **fully static** build of the ReactOmega playground +(**51 live components**, v1.1.0 — including six raw-WebGL2 shaders). No Node server, no database — it's plain HTML/CSS/JS that Apache serves directly. The Inter font is **self-hosted inside the build**, so the page makes **zero external requests** at runtime. @@ -42,7 +42,7 @@ _next/ ← hashed JS/CSS + self-hosted fonts - Open **https://edwson.com/ReactOmega/** → the ReactΩ landing page (Inter wordmark, "Motion · Interaction · Physics"). -- Open **https://edwson.com/ReactOmega/components/** → the live gallery, all 31 demos. +- Open **https://edwson.com/ReactOmega/components/** → the live gallery, all 51 demos. - Hard-refresh once (**Cmd/Ctrl + Shift + R**) so the browser drops any cached old build. --- From a54fc0b7bcd0207bdf48df3d87aacd7b4da47e43 Mon Sep 17 00:00:00 2001 From: Ed Chen <37851723+Edwson@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:13:28 +0800 Subject: [PATCH 8/8] =?UTF-8?q?release:=201.2.0=20=E2=80=94=20generated=20?= =?UTF-8?q?registry,=20llms.txt,=20README,=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 27 +++++++++ README.md | 18 ++++-- llms.txt | 8 ++- package.json | 6 +- registry.json | 164 +++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 215 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0611530..1a529fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ Format: [Keep a Changelog](https://keepachangelog.com/) · [SemVer](https://semver.org/). +## [1.2.0] — 2026-08-07 — "Materials" + +Six more shaders (51 → **57**), taking **Shaders & Light from 6 to 12**. The set is now a +materials library: every entry is a different optical family rather than another noise field. + +### Added +- **`refracted-glass`** — a thick bevelled slab. The view ray is refracted with a separate index + per RGB channel (Cauchy-ordered, blue bent hardest), so the bevel carries genuine chromatic + dispersion; a Schlick Fresnel term lifts the rim and a Beer–Lambert body tint darkens with depth. +- **`brushed-metal`** — anisotropic GGX with Smith-correlated shadowing over a brush-direction + field: roughness stays low along the grain and is pushed hard across it, so the highlight + stretches perpendicular to the brushing. `pattern: "linear" | "radial"` for engine-turned. +- **`moire-weave`** — two rigid lattices whose beat is the actual difference frequency. Every + cosine is band-limited by an exact box filter (`sinc(w)`, `w = fwidth(φ)` in cycles per pixel), + so amplitude reaches zero at a two-pixel period and the pattern dissolves into its own mean + instead of boiling. +- **`velvet-sheen`** — an Ashikhmin velvet lobe gated by an inverted Fresnel, so the cloth is + brightest at grazing angles and dark facing the viewer. Drape comes from wandering the phase and + amplitude of a gather train; adding noise to the height read as stone, not cloth. +- **`translucent-wax`** — light through a solid: the interior light path is marched to accumulate + thickness, then attenuated by Beer–Lambert with wrapped diffuse and a forward-scatter lobe. The + ground edge glows and the body goes deep, which is the cue that sells the translucency. +- **`diffraction-grating`** — the grating equation solved for wavelength per order + (`d·sinθ = mλ`) rather than integrated over sampled wavelengths, which is what keeps the spectral + lines continuous instead of beading. Blaze falloff dims higher orders and `sharpness` acts as + resolving power, so they wash toward white rather than simply fading. + ## [1.1.0] — 2026-07-28 — "Surfaces" Twenty new components (31 → **51**) and a second primitive. The theme is surfaces you can diff --git a/README.md b/README.md index c7d67f7..b44acc8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **motion & interaction** components you copy into your project — installable in one line by **humans _and_ AI agents**. -![Version](https://img.shields.io/badge/version-1.1.0-7c5cff) +![Version](https://img.shields.io/badge/version-1.2.0-7c5cff) ![License](https://img.shields.io/badge/license-MIT-blue) ![Deps](https://img.shields.io/badge/runtime%20deps-none-3c873a) ![A11y](https://img.shields.io/badge/reduced--motion-safe-22d3ee) @@ -60,7 +60,7 @@ npx -y github:Edwson/ReactOmega list --category text # filter by category ## Run the playground -**Live:** **https://edwson.github.io/ReactOmega/** — every one of the 51 components rendered live, +**Live:** **https://edwson.github.io/ReactOmega/** — every one of the 57 components rendered live, with a copy-paste install command on each card. The playground source lives in [`web/`](web) (Next.js + Tailwind). To run it locally: @@ -70,7 +70,7 @@ npm install npm run dev # → http://localhost:3000 ``` -## Components (v1.1) — 51 +## Components (v1.2) — 57 **Text Animations** (10) — novel + deeply customizable kinetic typography `split-text` · `shiny-text` · `gradient-text` · `count-up` · `variable-proximity` (per-letter @@ -94,7 +94,7 @@ at any speed) · `pixel-trail` (sub-cell path walking — a fast flick still lig frame-rate independent) · `parallax-layers` (progress normalised to the section's own centre) · `scroll-scene` (pins a section, exposes 0→1 scrub progress via render prop, CSS variable, or callback) -**Shaders & Light** (6) — *new in 1.1* — **hand-written GLSL on raw WebGL2, still zero deps** +**Shaders & Light** (12) — **hand-written GLSL on raw WebGL2, still zero deps** `liquid-metal` (domain-warped FBM, normals from finite differences, swept polish bands) · `thin-film` (two-beam interference at three wavelengths — real fringe order, not a hue rotation) · `caustics` (folded coordinates accumulating reciprocal distance) · `halftone-gradient` (angled @@ -103,6 +103,16 @@ per-channel dot screens with √-corrected dot area, or a computed 4×4 Bayer ma (FBM as a stream function; velocity is the perpendicular of its gradient, so the field is divergence-free by construction) +*New in 1.2 — a materials set, each a different optical family:* `refracted-glass` (per-channel +IOR through a bevelled slab — real chromatic dispersion, Schlick Fresnel rim) · `brushed-metal` +(anisotropic GGX with Smith shadowing; the highlight stretches perpendicular to the grain, linear +or engine-turned) · `moire-weave` (two lattices beating at their true difference frequency, +band-limited by an exact box filter so it dissolves rather than aliases) · `velvet-sheen` +(Ashikhmin velvet lobe under an inverted Fresnel — brightest at grazing angles) · +`translucent-wax` (marched interior light path, Beer–Lambert through a back-lit slab of banded +stone) · `diffraction-grating` (the grating equation solved per order, so spectral lines are sharp +and higher orders wash toward white) + **Physics & Art** (12) — real physics — tactile, designed, never cosmic `spring-mesh` (press an elastic lattice, waves ripple & settle) · `cloth` (a verlet fabric you grab & wave) · `metaballs` (gooey fluid that merges around the pointer) · `rope` (a verlet rope you grab diff --git a/llms.txt b/llms.txt index 9e50fc6..6ecffca 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # ReactOmega -> AI-native React component registry — premium, accessible, reduced-motion-safe motion & interaction components. v1.1.0. Install any component in one line via the ReactOmega CLI, shadcn, or an MCP server. Source of truth: /registry.json. +> AI-native React component registry — premium, accessible, reduced-motion-safe motion & interaction components. v1.2.0. Install any component in one line via the ReactOmega CLI, shadcn, or an MCP server. Source of truth: /registry.json. ## Install a component ``` @@ -49,6 +49,12 @@ Every component is a self-contained file copied into your project (you own the c - `halftone-gradient` — A drifting multi-stop gradient rendered through a print screen — either angled per-channel dots with radius proportional to the square root of tone, or a computed 4x4 ordered Bayer matrix. The screen opens up under the pointer. - `volumetric-rays` — God rays from a pointer-driven light source, integrated the classic way: march back toward the light sampling a drifting haze field and attenuating each step, with decay normalised by sample count so quality and brightness stay independent. - `curl-flow` — Silk-like flowing bands from a curl-noise field. FBM acts as a stream function and the velocity is the perpendicular of its gradient, so the field is divergence-free by construction; coordinates advect along it and shade as anisotropic streamlines. The pointer injects a vortex. +- `refracted-glass` — A thick bevelled glass panel over a procedural backdrop. The view ray is refracted through the slab with a different index per RGB channel, so the bevel carries real chromatic dispersion, and a Schlick Fresnel term brightens the rim where the surface turns away. +- `brushed-metal` — Machined metal, still and precise — the opposite register to liquid-metal's flow. An anisotropic GGX lobe with Smith-correlated shadowing runs over a brush-direction field, so the highlight stretches perpendicular to the grain. Linear or radial (engine-turned) finishes. +- `moire-weave` — Two rigid lattices at a small relative angle, beating against each other at their true difference frequency. Every cosine is band-limited by an exact box filter, so the pattern dissolves into its own mean grey as the period approaches two pixels instead of aliasing into noise. +- `velvet-sheen` — Soft fabric: an Ashikhmin velvet lobe gated by an inverted Fresnel, so the cloth is brightest at grazing angles and dark facing the viewer. Fibre rank modulates the sheen itself, and the drape comes from wandering the phase and amplitude of a gather train rather than adding noise to the height. +- `translucent-wax` — Light travelling through a solid rather than off it: a back-lit slab of banded stone. The interior light path is marched to accumulate thickness, attenuated by Beer–Lambert with wrapped diffuse and a forward-scatter lobe, so the ground edge glows and the body goes deep. +- `diffraction-grating` — The optics behind a CD surface: the grating equation places spectral orders by solving d·sinθ = mλ for wavelength per order, giving sharp rainbow lines rather than broad fringes. Higher orders are dimmer and wash toward white, over a band-limited pressed ruling. ## Physics & Art - `spring-mesh` — An elastic lattice: every node is sprung to rest and coupled to its neighbors, so pressing the pointer sends real waves rippling outward that settle naturally. diff --git a/package.json b/package.json index 503016d..15f5fb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reactomega", - "version": "1.1.0", + "version": "1.2.0", "description": "The AI-native React component registry \u2014 premium, accessible, reduced-motion-safe motion, interaction & physics components, installable by humans and AI agents via a CLI, shadcn, or an MCP server.", "keywords": [ "react", @@ -21,7 +21,9 @@ "shader", "glsl", "cursor", - "scroll" + "scroll", + "materials", + "shaders" ], "license": "MIT", "author": "Ed Chen (https://www.edwson.com)", diff --git a/registry.json b/registry.json index b17e8bb..96ffa29 100644 --- a/registry.json +++ b/registry.json @@ -1,7 +1,7 @@ { "$schema": "https://ui.shadcn.com/schema/registry.json", "name": "reactomega", - "version": "1.1.0", + "version": "1.2.0", "homepage": "https://github.com/Edwson/ReactOmega", "base": "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r", "items": [ @@ -1350,6 +1350,168 @@ "target": "components/reactomega/circular-gallery.tsx" } ] + }, + { + "name": "refracted-glass", + "type": "registry:component", + "title": "Refracted Glass", + "description": "A thick bevelled glass panel over a procedural backdrop. The view ray is refracted through the slab with a different index per RGB channel, so the bevel carries real chromatic dispersion, and a Schlick Fresnel term brightens the rim where the surface turns away.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "glass", + "refraction", + "dispersion", + "background" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/refracted-glass.tsx", + "type": "registry:component", + "target": "components/reactomega/refracted-glass.tsx" + } + ] + }, + { + "name": "brushed-metal", + "type": "registry:component", + "title": "Brushed Metal", + "description": "Machined metal, still and precise — the opposite register to liquid-metal's flow. An anisotropic GGX lobe with Smith-correlated shadowing runs over a brush-direction field, so the highlight stretches perpendicular to the grain. Linear or radial (engine-turned) finishes.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "metal", + "anisotropic", + "specular", + "background" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/brushed-metal.tsx", + "type": "registry:component", + "target": "components/reactomega/brushed-metal.tsx" + } + ] + }, + { + "name": "moire-weave", + "type": "registry:component", + "title": "Moiré Weave", + "description": "Two rigid lattices at a small relative angle, beating against each other at their true difference frequency. Every cosine is band-limited by an exact box filter, so the pattern dissolves into its own mean grey as the period approaches two pixels instead of aliasing into noise.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "moire", + "interference", + "weave", + "background" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/moire-weave.tsx", + "type": "registry:component", + "target": "components/reactomega/moire-weave.tsx" + } + ] + }, + { + "name": "velvet-sheen", + "type": "registry:component", + "title": "Velvet Sheen", + "description": "Soft fabric: an Ashikhmin velvet lobe gated by an inverted Fresnel, so the cloth is brightest at grazing angles and dark facing the viewer. Fibre rank modulates the sheen itself, and the drape comes from wandering the phase and amplitude of a gather train rather than adding noise to the height.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "fabric", + "velvet", + "sheen", + "background" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/velvet-sheen.tsx", + "type": "registry:component", + "target": "components/reactomega/velvet-sheen.tsx" + } + ] + }, + { + "name": "translucent-wax", + "type": "registry:component", + "title": "Translucent Wax", + "description": "Light travelling through a solid rather than off it: a back-lit slab of banded stone. The interior light path is marched to accumulate thickness, attenuated by Beer–Lambert with wrapped diffuse and a forward-scatter lobe, so the ground edge glows and the body goes deep.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "subsurface", + "stone", + "translucent", + "background" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/translucent-wax.tsx", + "type": "registry:component", + "target": "components/reactomega/translucent-wax.tsx" + } + ] + }, + { + "name": "diffraction-grating", + "type": "registry:component", + "title": "Diffraction Grating", + "description": "The optics behind a CD surface: the grating equation places spectral orders by solving d·sinθ = mλ for wavelength per order, giving sharp rainbow lines rather than broad fringes. Higher orders are dimmer and wash toward white, over a band-limited pressed ruling.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "diffraction", + "spectrum", + "iridescent", + "background" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/diffraction-grating.tsx", + "type": "registry:component", + "target": "components/reactomega/diffraction-grating.tsx" + } + ] } ] }