diff --git a/.changeset/liquid-gradient.md b/.changeset/liquid-gradient.md new file mode 100644 index 00000000..dc9320d5 --- /dev/null +++ b/.changeset/liquid-gradient.md @@ -0,0 +1,7 @@ +--- +'@camp-dev/shaders': minor +--- + +Add `LiquidGradient`, a Source that streaks bands of color along slow, curling currents, like paint stirred into liquid. Mount it inside a `ShaderScene`. `stops`, `colorSpace`, and `hueInterpolation` pick the colors, as they do on `LinearGradient`. `angle` and `repeat` lay the bands out before the current moves them, and `softness` sets how the colors meet, from hard bands at 0 to full blends at 1. `swirl` sets how far the current carries the colors, `scale` sets roughly how many eddies span the canvas's height, and `speed` sets how fast the current drifts. Each of those six props takes a static value or an animation signal. `seed` picks a different layout with the same look. A literal `speed={0}` holds the current still and lets the scene park. It works in Mode 1 only. + +`LiquidGradient` draws the current, a noise field, and its palette into textures before the scene pass, and redraws each texture only when a prop it depends on changes. Where the renderer cannot draw into a half-float target, on WebGL2 without `EXT_color_buffer_float`, every read works its value out from the noise or the palette instead. The result looks the same and costs more GPU time. diff --git a/GLOSSARY.md b/GLOSSARY.md index d86e789f..f7dd2647 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -14,6 +14,10 @@ _Avoid_: Base component, generator, layer A component that acts on the image beneath it in a scene rather than drawing its own, such as Grain, Dither, or LedWall. An Effect composes over any Source or Effect mounted before it. _Avoid_: Overlay, post-process pass, post-process layer, filter +**UV warp**: +An Effect's change to where the image beneath it is sampled, such as Dither's pixel snap or CursorRipple's refraction. A UV warp bends only what Sources drew. +_Avoid_: Warp on its own, base UV transform + **Input**: A value that changes over time and is not drawn, such as the cursor, scroll, or canvas size. An Input is fed to a component as an animation signal. _Avoid_: Cursor hook, entry point, scene-level field diff --git a/apps/docs-tests/visual/liquid-gradient.spec.ts b/apps/docs-tests/visual/liquid-gradient.spec.ts new file mode 100644 index 00000000..359ae0b5 --- /dev/null +++ b/apps/docs-tests/visual/liquid-gradient.spec.ts @@ -0,0 +1,8 @@ +import { expect, test } from './fixtures'; +import { waitForShader } from './helpers'; + +test('LiquidGradient — default story', async ({ page }) => { + await page.goto('/components/liquid-gradient?visualTest=1'); + await waitForShader(page); + await expect(page.locator('canvas').first()).toHaveScreenshot('liquid-gradient-default.png'); +}); diff --git a/apps/docs-tests/visual/liquid-gradient.spec.ts-snapshots/liquid-gradient-default-chromium-darwin.png b/apps/docs-tests/visual/liquid-gradient.spec.ts-snapshots/liquid-gradient-default-chromium-darwin.png new file mode 100644 index 00000000..34793ccd Binary files /dev/null and b/apps/docs-tests/visual/liquid-gradient.spec.ts-snapshots/liquid-gradient-default-chromium-darwin.png differ diff --git a/apps/docs-tests/visual/liquid-gradient.spec.ts-snapshots/liquid-gradient-default-chromium-linux.png b/apps/docs-tests/visual/liquid-gradient.spec.ts-snapshots/liquid-gradient-default-chromium-linux.png new file mode 100644 index 00000000..c719c114 Binary files /dev/null and b/apps/docs-tests/visual/liquid-gradient.spec.ts-snapshots/liquid-gradient-default-chromium-linux.png differ diff --git a/apps/docs/public/posters/liquid-gradient.jpg b/apps/docs/public/posters/liquid-gradient.jpg new file mode 100644 index 00000000..629bef95 Binary files /dev/null and b/apps/docs/public/posters/liquid-gradient.jpg differ diff --git a/apps/docs/public/posters/liquid-gradient.thumb.webp b/apps/docs/public/posters/liquid-gradient.thumb.webp new file mode 100644 index 00000000..2f9e27c6 Binary files /dev/null and b/apps/docs/public/posters/liquid-gradient.thumb.webp differ diff --git a/apps/docs/src/app/components/demo-registry.tsx b/apps/docs/src/app/components/demo-registry.tsx index 3105b8d5..f049342a 100644 --- a/apps/docs/src/app/components/demo-registry.tsx +++ b/apps/docs/src/app/components/demo-registry.tsx @@ -21,6 +21,7 @@ import { GodRaysIsland } from './god-rays/demo'; import { GrainIsland } from './grain/demo'; import { LedWallIsland } from './led-wall/demo'; import { LinearGradientIsland } from './linear-gradient/demo'; +import { LiquidGradientIsland } from './liquid-gradient/demo'; import { MeshGradientIsland } from './mesh-gradient/demo'; import { RadialGradientIsland } from './radial-gradient/demo'; import { RadialWipeIsland } from './radial-wipe/demo'; @@ -248,6 +249,12 @@ export const COMPONENT_PAGES: Record = { Island: LinearGradientIsland, usageSnippet: ` +`, + }, + 'liquid-gradient': { + Island: LiquidGradientIsland, + usageSnippet: ` + `, }, 'mesh-gradient': { diff --git a/apps/docs/src/app/components/liquid-gradient/demo.tsx b/apps/docs/src/app/components/liquid-gradient/demo.tsx new file mode 100644 index 00000000..da90272c --- /dev/null +++ b/apps/docs/src/app/components/liquid-gradient/demo.tsx @@ -0,0 +1,110 @@ +'use client'; + +/** + * LiquidGradient demo island: the interactive slice of the LiquidGradient + * page — control store, shader preview, and control panel for the bands, + * the current that streaks them, mixing, and the color stops. The shared + * components/[slug] template renders this between its static header and + * prose sections. + */ +import dynamic from 'next/dynamic'; +import { useMemo } from 'react'; + +import { + COLOR_SPACE_OPTIONS, + ColorInput, + ControlPanel, + ControlsProvider, + createControlStore, + DemoLayout, + HUE_ARC_OPTIONS, + ListInput, + NumberInput, + Section, + SelectInput, + SliderInput, + useSnapshot, +} from '@/components/controls'; +import { DemoPoster } from '@/components/DemoPoster'; +import { createStop, newStopIndex } from '@/lib/stops'; +import { VisualTestPause } from '@/lib/visualTestHooks'; + +import { INITIAL, MAX_STOPS, MIN_STOPS } from './params'; +import type { Params, Stop } from './params'; + +const LiquidGradientScene = dynamic(() => import('./scene'), { ssr: false }); + +function LiquidGradientDemo() { + const params = useSnapshot(); + + return ( + + + + + + ); +} + +function LiquidGradientControls() { + return ( + +
+ + + +
+
+ + + + +
+
+ + +
+ + createItem={createStop} + insertIndex={newStopIndex} + itemLabel="stop" + label="Color stops" + max={MAX_STOPS} + min={MIN_STOPS} + path="stops" + > + {() => ( + <> + + + + )} + +
+ ); +} + +export function LiquidGradientIsland() { + const store = useMemo(() => createControlStore(INITIAL), []); + + return ( + + }> +
+ +
+
+
+ ); +} diff --git a/apps/docs/src/app/components/liquid-gradient/params.ts b/apps/docs/src/app/components/liquid-gradient/params.ts new file mode 100644 index 00000000..9f2b93c2 --- /dev/null +++ b/apps/docs/src/app/components/liquid-gradient/params.ts @@ -0,0 +1,43 @@ +import type { ColorSpace, HueInterpolation } from '@camp-dev/shaders'; + +import { paletteOklch } from '../../../lib/palette'; + +export interface Stop { + color: string; + position: number; +} + +export interface Params { + angle: number; + repeat: number; + softness: number; + swirl: number; + scale: number; + speed: number; + seed: number; + colorSpace: ColorSpace; + hueInterpolation: HueInterpolation; + stops: Stop[]; +} + +export const MIN_STOPS = 1; +export const MAX_STOPS = 6; + +// Values mirror the component defaults in liquid-gradient.tsx. Keep the two +// in sync. +export const INITIAL: Params = { + angle: 0, + repeat: 2, + softness: 1, + swirl: 0.9, + scale: 1.5, + speed: 0.15, + seed: 0, + colorSpace: 'oklab', + hueInterpolation: 'shorter', + stops: [ + { color: paletteOklch.blue[3], position: 0 }, + { color: paletteOklch.violet[6], position: 0.5 }, + { color: paletteOklch.purple[9], position: 1 }, + ], +}; diff --git a/apps/docs/src/app/components/liquid-gradient/scene.tsx b/apps/docs/src/app/components/liquid-gradient/scene.tsx new file mode 100644 index 00000000..6272c3eb --- /dev/null +++ b/apps/docs/src/app/components/liquid-gradient/scene.tsx @@ -0,0 +1,33 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { LiquidGradient, ShaderScene } from '@camp-dev/shaders'; + +import { INITIAL, type Params } from './params'; + +export default function LiquidGradientScene({ + params = INITIAL, + children, +}: { + params?: Params; + children?: ReactNode; +} = {}) { + return ( + + + {children} + + ); +} diff --git a/apps/docs/src/app/dev/liquid-gradient-benchmark/benchmark-scene.tsx b/apps/docs/src/app/dev/liquid-gradient-benchmark/benchmark-scene.tsx new file mode 100644 index 00000000..2fba3575 --- /dev/null +++ b/apps/docs/src/app/dev/liquid-gradient-benchmark/benchmark-scene.tsx @@ -0,0 +1,83 @@ +'use client'; + +// Benchmark for LiquidGradient with ShaderMonitor's GPU readout (SHA-223). +// `?size=card` renders it in the Reactive card's 312 by 309 CSS-pixel +// window, where it must stay under 0.4 ms at 2x; `?size=full` renders it at +// 1440 by 900. `?dpr=2` caps the canvas at two pixels per CSS pixel, and +// anything else at one. `?still` sets speed to 0, the Reactive card's +// parked case, while the scene keeps drawing. `?baseline` renders a +// LinearGradient instead: it reads about 0.12 ms in the card window at 2x +// on the M1 Max, so a higher reading means another tab or app is sharing +// the GPU and every reading beside it is inflated (the skewed-readout +// gotcha in docs/agents/tsl.md). The links are full page loads, so each +// measurement starts from a fresh renderer. +import { useState } from 'react'; + +import { + LinearGradient, + LiquidGradient, + ShaderMonitor, + ShaderScene, + useStaticSceneHint, +} from '@camp-dev/shaders'; + +const SIZES = { + card: { width: 312, height: 309 }, + full: { width: 1440, height: 900 }, +} as const; + +type SizeName = keyof typeof SIZES; + +interface Settings { + size: SizeName; + pixelRatio: number; + still: boolean; + baseline: boolean; +} + +const readSettings = (): Settings => { + const search = new URLSearchParams(window.location.search); + + return { + size: search.get('size') === 'full' ? 'full' : 'card', + pixelRatio: search.get('dpr') === '2' ? 2 : 1, + still: search.has('still'), + baseline: search.has('baseline'), + }; +}; + +/** + * Votes the scene animated, so a still gradient keeps drawing frames and the + * GPU readout averages many frames instead of settling on one. + */ +function KeepDrawing() { + useStaticSceneHint(false); + + return null; +} + +export default function BenchmarkScene() { + const [{ size, pixelRatio, still, baseline }] = useState(readSettings); + const { width, height } = SIZES[size]; + const query = (overrides: Partial>) => + `?size=${overrides.size ?? size}&dpr=${overrides.dpr ?? pixelRatio}`; + + return ( +
+

+ {baseline ? 'LinearGradient baseline' : 'LiquidGradient'} + {still ? ' (still)' : ''} at {width} × {height} CSS px, pixel ratio capped at {pixelRatio}x.{' '} + card · full ·{' '} + 1x · 2x ·{' '} + still · baseline +

+
+ + {baseline ? : } + + + +
+
+ ); +} diff --git a/apps/docs/src/app/dev/liquid-gradient-benchmark/page.dev.tsx b/apps/docs/src/app/dev/liquid-gradient-benchmark/page.dev.tsx new file mode 100644 index 00000000..b58159fc --- /dev/null +++ b/apps/docs/src/app/dev/liquid-gradient-benchmark/page.dev.tsx @@ -0,0 +1,12 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +// three/webgpu references `self` at module load and cannot SSR, so the +// benchmark scene is loaded client-only. Dev route: invisible to production +// builds. +const BenchmarkScene = dynamic(() => import('./benchmark-scene'), { ssr: false }); + +export default function LiquidGradientBenchmarkPage() { + return ; +} diff --git a/apps/docs/src/content/components.ts b/apps/docs/src/content/components.ts index 7f7fe0ef..bf2d849e 100644 --- a/apps/docs/src/content/components.ts +++ b/apps/docs/src/content/components.ts @@ -83,6 +83,11 @@ export const COMPONENTS = { description: 'Animated linear gradient. The simplest, foundational Shaders component.', category: 'gradients', }, + 'liquid-gradient': { + description: + "Colors streaked along slow, curling currents, like dye in moving water. Takes LinearGradient's stops and angle.", + category: 'gradients', + }, 'mesh-gradient': { description: 'Warped 4-color fluid gradient with time-cycling palette and film grain. Linear/Stripe marketing-page style.', diff --git a/packages/shaders/src/components/index.ts b/packages/shaders/src/components/index.ts index 59148915..7fedb7bc 100644 --- a/packages/shaders/src/components/index.ts +++ b/packages/shaders/src/components/index.ts @@ -49,6 +49,9 @@ export type { LedWallProps } from './led-wall/led-wall.js'; export { LinearGradient } from './linear-gradient/linear-gradient.js'; export type { LinearGradientProps } from './linear-gradient/linear-gradient.js'; +export { LiquidGradient } from './liquid-gradient/liquid-gradient.js'; +export type { LiquidGradientProps } from './liquid-gradient/liquid-gradient.js'; + export { MeshGradient } from './mesh-gradient/mesh-gradient.js'; export type { MeshGradientProps } from './mesh-gradient/mesh-gradient.js'; diff --git a/packages/shaders/src/components/liquid-gradient/bands.ts b/packages/shaders/src/components/liquid-gradient/bands.ts new file mode 100644 index 00000000..b212c05a --- /dev/null +++ b/packages/shaders/src/components/liquid-gradient/bands.ts @@ -0,0 +1,315 @@ +// How LiquidGradient colors each band coordinate along the trace: the +// stops eased in with smoothstep, and the bands' average color, which +// streaks thinner than a pixel fade to. A cached palette keeps color-space +// conversion out of the tracing loop. The shader (./shader.tsx) measures +// how much of the ramp one pixel spans, its footprint, with fwidth() (how +// much a value changes between neighboring pixels), and the easing keeps +// every blend at least that wide. +import type { ShaderNodeObject } from 'three/tsl'; +import { + clamp, + float, + Fn, + Loop, + max, + smoothstep, + texture, + type uniform, + uv, + vec2, + vec3, + vec4, +} from 'three/tsl'; +import { HalfFloatType, LinearFilter, NearestFilter, type Node } from 'three/webgpu'; + +import { + colorRamp, + type ColorRampStop, + type ColorSpace, + createTexturePass, + type HueInterpolation, + type TSLNode, +} from '../../engine.js'; +import type { ShaderContextValue } from '../../react/context/shader-context.js'; + +// The narrowest a stop's blend may get, as a fraction of its segment. It +// keeps smoothstep's two edges apart, because smoothstep(a, a, x) divides +// by zero. Far below a pixel at any canvas size. +const MIN_BLEND_WIDTH = 1e-4; + +// How many points along the ramp the average pass reads. Hard bands need +// enough to land each stop's edge close to where it is: 64 places it within +// 1/128 of the ramp. +const AVERAGE_SAMPLES = 64; + +// The inline fallback reads fewer, because every pixel pays for them. +const INLINE_AVERAGE_SAMPLES = 16; + +/** The palette and how to blend it, shared by every coloring step. */ +export interface BandPalette { + stops: ColorRampStop[]; + colorSpace: ColorSpace; + hueInterpolation: HueInterpolation; +} + +// --------------------------------------------- +// The stops, eased in +// --------------------------------------------- + +/** + * The band coordinate, reshaped so colorRamp's straight blend between two + * stops becomes an eased one. colorRamp blends linearly across each segment + * (the stretch between two neighboring stops), so the color's rate of change + * jumps at every stop, and the eye reads that jump as a faint crease. Easing + * each segment through smoothstep flattens the blend to zero slope at both + * of its stops, so neighboring segments meet without a crease. + * + * The blend fills the middle `width` of each segment. At softness 1 that is + * the whole segment: smoothstep(0, 1, x), the S-curve. At softness 0 it + * would be a hard step at the segment's middle, so each stop owns a flat + * band, as FractalNoise's softness 0 does. The blend never gets narrower + * than one pixel's footprint, though, so a hard edge still spans a pixel + * and reads smooth instead of stair-stepped. + * + * The reshaped coordinate is a sum over the segments. Each segment adds its + * eased share of its own span: none before the coordinate reaches it, all + * of it once the coordinate has passed it. Inside segment j everything + * before it adds up to its start, so the sum lands at + * start_j + span_j x ease(x), and colorRamp turns that back into an eased + * blend between stop j and stop j + 1. + */ +function easeStops( + coordinate: TSLNode, + stops: ColorRampStop[], + softness: TSLNode, + footprint: TSLNode, +): ShaderNodeObject { + const first = stops[0]; + + if (first === undefined) return float(coordinate); + + let eased = float(first.position); + + // Mirrors colorRamp's own walk: adjacent pairs, skipping a pair with no + // span to blend across. + for (let i = 1; i < stops.length; i += 1) { + const start = stops[i - 1]?.position ?? 0; + const span = (stops[i]?.position ?? 0) - start; + + if (span <= 0) continue; + + // The footprint is in whole passes of the ramp, so dividing by the + // span gives it as a fraction of this segment. + const width = clamp(max(softness, float(footprint).div(span)), MIN_BLEND_WIDTH, 1); + const halfWidth = width.mul(0.5); + const segmentX = float(coordinate).sub(start).div(span); + + // smoothstep is 0 below its first edge and 1 above its second, so x + // before the segment adds nothing and x past it adds the whole span. + eased = eased.add( + smoothstep(float(0.5).sub(halfWidth), halfWidth.add(0.5), segmentX).mul(span), + ); + } + + return eased; +} + +/** The color of a band coordinate, its stops eased in. */ +function bandColor( + coordinate: TSLNode, + { stops, colorSpace, hueInterpolation }: BandPalette, + softness: TSLNode, + footprint: TSLNode, +): ShaderNodeObject { + return colorRamp( + easeStops(coordinate, stops, softness, footprint), + stops, + colorSpace, + hueInterpolation, + ); +} + +// --------------------------------------------- +// Cache the palette for reads along a trace +// --------------------------------------------- + +// Samples across one pass of the ramp, one per texel (pixel of a texture). +// The endpoints occupy texel centers, so clamped reads preserve the first +// and last colors. 1024 RGBA texels of half floats, 16-bit floats, use +// 8 KiB. More samples reduce interpolation error between texels. +const RAMP_TEXELS = 1024; + +/** A cached ramp, with the same inputs as bandColor and an owned texture. */ +export interface RampPass { + colorAt: (coordinate: TSLNode, softness: TSLNode, footprint: TSLNode) => ShaderNodeObject; + dispose: () => void; +} + +/** + * Cache color-space conversion once per palette, so every trace sample + * reads RGB instead of evaluating the full colorRamp. Stop easing still + * runs at the read, because its width depends on the pixel's footprint. + */ +export function createRampPass(shaderContext: ShaderContextValue, palette: BandPalette): RampPass { + const inline: RampPass = { + colorAt: (coordinate, softness, footprint) => + bandColor(coordinate, palette, softness, footprint), + dispose: () => { + // The inline path owns no texture or pre-pass. + }, + }; + + // A narrow stop interval needs more samples than this table can give it. + // Keep the exact calculation there, and for constant or linear palettes + // whose conversion costs too little to justify a texture read. + const hasNarrowInterval = palette.stops.some((stop, index) => { + const previous = palette.stops[index - 1]; + const span = previous === undefined ? 0 : stop.position - previous.position; + + return span > 0 && span < 4 / (RAMP_TEXELS - 1); + }); + + if (palette.stops.length < 2 || palette.colorSpace === 'linear' || hasNarrowInterval) + return inline; + + // Each texel's place on the ramp: texel i of n holds the color at i / (n - 1), + // so the first and last texels hold the end stops exactly. + const texelCoordinate = uv() + .x.mul(RAMP_TEXELS) + .sub(0.5) + .div(RAMP_TEXELS - 1); + const pass = createTexturePass( + shaderContext.renderer.three, + vec4( + colorRamp(texelCoordinate, palette.stops, palette.colorSpace, palette.hueInterpolation), + 1, + ), + { width: RAMP_TEXELS, height: 1, type: HalfFloatType, filter: LinearFilter }, + ); + const rampTexture = pass.texture; + + if (rampTexture === null) return inline; + + // Palette edits rebuild the material and this cache together. A pre-pass, + // a draw the output stage runs before the scene pass, draws it once, + // before the scene first samples it, then reuses it even as softness + // moves. + let drawn = false; + const removePrePass = shaderContext.registerPrePass(() => { + if (drawn) return; + pass.render(); + drawn = true; + }); + + return { + colorAt: (coordinate, softness, footprint) => { + const eased = easeStops(coordinate, palette.stops, softness, footprint); + const sampleX = clamp(eased, 0, 1) + .mul(RAMP_TEXELS - 1) + .add(0.5) + .div(RAMP_TEXELS); + + return texture(rampTexture, vec2(sampleX, 0.5)).rgb; + }, + dispose() { + removePrePass(); + pass.dispose(); + }, + }; +} + +// --------------------------------------------- +// The bands' average color +// --------------------------------------------- + +/** + * The average color of one whole pass of the bands: the color a pixel should + * show once it spans every stop, as when the flow stretches a thread thinner + * than a pixel. Reads the eased ramp at evenly spaced points and averages + * them in linear light, the way light averages inside a pixel. Averaging the + * stop colors alone misses the ramp's blend curve: on a dark-to-light + * palette it landed 0.1 off in OKLab, enough to show as a halo where the + * threads fade. + */ +function averageBandColor(palette: BandPalette, softness: TSLNode, sampleCount: number) { + return Fn(() => { + const total = vec3(0, 0, 0).toVar(); + + Loop(sampleCount, ({ i }: { i: ShaderNodeObject }) => { + // Each sample sits at the middle of its share of the ramp, and with no + // footprint the eased blend is softness's alone. + const coordinate = float(i).add(0.5).div(sampleCount); + + total.addAssign(bandColor(coordinate, palette, softness, float(0))); + }); + + return total.div(sampleCount); + })(); +} + +/** The average pass's handle: the average color, and how to release it. */ +export interface AveragePass { + color: ShaderNodeObject; + dispose: () => void; +} + +/** + * Draws the bands' average color into a one-texel texture, so a pixel reads + * one texel instead of sampling the ramp 64 times. The average depends on + * the palette and on softness, never on the pixel. A palette change rebuilds + * the material and this pass with it, so the pass redraws only when softness + * has moved since its last draw. Where the renderer can't draw half floats, + * every pixel works it out from fewer samples instead, and `dispose` has + * nothing to release. + * + * The caller creates and disposes the pass in one effect, so a Strict Mode + * remount builds a fresh one (the Strict Mode gotcha in docs/agents/tsl.md). + */ +export function createAveragePass( + shaderContext: ShaderContextValue, + palette: BandPalette, + softness: ReturnType>, +): AveragePass { + // A half float keeps the dark end of linear light, which an 8-bit texel + // rounds into visible steps. One texel needs no filtering. + const pass = createTexturePass( + shaderContext.renderer.three, + vec4(averageBandColor(palette, softness, AVERAGE_SAMPLES), 1), + { width: 1, height: 1, type: HalfFloatType, filter: NearestFilter }, + ); + const averageTexture = pass.texture; + + if (averageTexture === null) { + return { + color: averageBandColor(palette, softness, INLINE_AVERAGE_SAMPLES), + dispose: () => { + // Nothing was allocated. + }, + }; + } + + // A pre-pass draws before the scene pass in the same frame, so a softness + // change reaches the average on the frame it lands (the draw-order gotcha + // in docs/agents/tsl.md). Each draw is its own render pass, and a pass + // costs time to begin and end even for one texel: drawing every frame + // read 0.04 ms in the Reactive card's window at 2x, a tenth of its + // budget. So the pre-pass checks softness on the CPU first and skips the + // draw while it holds still. NaN never equals a number, so the first + // frame always draws. + let drawnSoftness = Number.NaN; + const removePrePass = shaderContext.registerPrePass(() => { + if (softness.value === drawnSoftness) return; + drawnSoftness = softness.value; + pass.render(); + }); + + return { + // The coordinate goes in when the node is built, so three skips the + // texture matrix (the texture-matrix gotcha in docs/agents/tsl.md). + color: texture(averageTexture, vec2(0.5, 0.5)).rgb, + dispose() { + removePrePass(); + pass.dispose(); + }, + }; +} diff --git a/packages/shaders/src/components/liquid-gradient/flow.ts b/packages/shaders/src/components/liquid-gradient/flow.ts new file mode 100644 index 00000000..b8ea6fbe --- /dev/null +++ b/packages/shaders/src/components/liquid-gradient/flow.ts @@ -0,0 +1,458 @@ +// The flow LiquidGradient carries its colors along, and the backward trace +// through it. The flow is the curl of a gradient-noise field: the noise's +// slope turned 90 degrees, so colors slide along the noise's contour lines +// and never pile up. The flow pass draws it into a texture whenever it +// moves, and the shader (./shader.tsx) traces each pixel back through that +// texture, averaging the colors it passes. Lives beside the component, as stroke.ts +// does in cursor-ripple/, until a second component needs it. +import type { ShaderNodeObject } from 'three/tsl'; +import { + cos, + float, + floor, + Fn, + fract, + Loop, + sign, + sin, + texture, + uint, + type uniform, + uv, + vec2, + vec3, + vec4, +} from 'three/tsl'; +import { HalfFloatType, LinearFilter, type Node } from 'three/webgpu'; + +import { createTexturePass, stableHashUint, type TSLNode } from '../../engine.js'; +import type { ShaderContextValue } from '../../react/context/shader-context.js'; + +// --------------------------------------------- +// Constants +// --------------------------------------------- + +// The flow repeats every FLOW_PERIOD lattice cells on both axes, so one +// period drawn into a texture covers every point a trace can reach. A +// lattice cell is about one eddy, so at the scale slider's top, 4 eddies +// per canvas height, a 16:10 canvas spans about 6.4 cells and no repeat +// shows. Doubling it pushes the repeat out past scale 8, but the flow pass +// draws four times the texels whenever the flow moves: a period of 16 read +// 0.45 ms in the Reactive card's window at 2x, against 0.35 ms for 8. A +// power of two, so dividing a cell index by it stays exact in a float. +export const FLOW_PERIOD = 8; + +// Texels (pixels of a texture) per period on each side. 256 over 8 cells +// gives 32 texels per eddy, and 16 per smaller eddy, which the texture's +// linear filter blends into a smooth flow. Half that density drew faster but +// showed the filter's straight runs between texels: hard color edges came +// out faceted instead of curved. +const FLOW_TEXELS = 256; + +// The smaller eddies run at twice the broad flow's frequency, so each one is +// half the size of a broad eddy. 2 divides FLOW_PERIOD, so they repeat with +// the broad flow. +const SMALL_EDDY_FREQUENCY = 2; + +// The smaller eddies' strength. 0 would leave the broad currents alone, and +// at 0.5 the smaller eddies swirl as hard as the broad ones. Higher curls +// more fine billowing into the broad currents. +const SMALL_EDDY_STRENGTH = 0.45; + +// How far the flow slides per unit of phase, in lattice cells, on top of +// the eddies morphing in place. With the phase at speed x seconds, the +// default speed of 0.15 carries the currents across a canvas height in +// about ten seconds. 0 would leave the eddies morphing where they stand. +const SLIDE_RATE = 1; + +// The way the flow slides: right and a little up, as a unit vector. +const SLIDE_DIRECTION = [0.8, 0.6] as const; + +const TWO_PI = Math.PI * 2; + +// --------------------------------------------- +// Gradient noise with its slope +// --------------------------------------------- + +/** + * The random arrow at one corner of the noise lattice, a unit vector at a + * hashed angle that turns as `phase` grows. `rowWord` is the hash of the + * corner's row, so the four corners of a cell share two row hashes instead + * of hashing a row each. + * + * The turning is Perlin and Neyret's "flow noise": every arrow spins at its + * own rate, so the hills and valleys of the noise swell, shrink, and trade + * places, and the eddies around them form, merge, and dissolve where they + * stand instead of sliding across the canvas. Each arrow spins between half + * and one and a half radians per unit of phase, clockwise or + * counterclockwise at random. A rate near 0 would leave patches of the flow + * frozen while the rest moved, and arrows all spinning one way would cycle + * the whole flow back to its start every turn. + */ +function cornerArrow( + column: ShaderNodeObject, + rowWord: ShaderNodeObject, + phase: ShaderNodeObject, +) { + // One 32-bit hash word per corner, split in two: the top 16 bits set the + // starting angle and the bottom 16 the spin. Each half is a random whole + // number 0..65535, which a float holds exactly on both backends, and + // dividing by 65536 turns it into 0..1. One hash serves both because the + // flow pass runs this for four corners, two noise layers, and every texel, + // each frame the flow moves. + const cornerWord = stableHashUint(column.toUint().add(rowWord)); + const startAngle = cornerWord + .shiftRight(uint(16)) + .toFloat() + .mul(TWO_PI / 65536); + + // A random -1..1, then pushed half a unit away from 0 on its own side: + // -1.5..-0.5 spins one way, 0.5..1.5 the other. + const signedSpin = cornerWord + .bitAnd(uint(0xffff)) + .toFloat() + .mul(2 / 65536) + .sub(1); + const spin = signedSpin.add(sign(signedSpin).mul(0.5)); + const angle = startAngle.add(phase.mul(spin)); + + return vec2(cos(angle), sin(angle)); +} + +/** + * A lattice index folded into 0..period - 1, so whatever it hashes repeats + * every `period` cells. Written out with floor() rather than mod(), because + * WGSL's float remainder keeps the sign of a negative index and GLSL's + * mod() does not. It also keeps the hash's input positive, as stableHash + * needs. The period is a whole number, and a power of two keeps the divide + * exact in a float. + */ +export function wrapIndex(index: ShaderNodeObject, period: number) { + return index.sub(floor(index.div(period)).mul(period)); +} + +/** A flow lattice index folded into 0..FLOW_PERIOD - 1, so the flow repeats. */ +function wrapCell(index: ShaderNodeObject) { + return wrapIndex(index, FLOW_PERIOD); +} + +/** + * 2D gradient noise (Perlin's kind) at `point`, together with its slope: how + * fast the noise climbs along x and along y. The slope comes from the same + * reads as the value, by the chain rule, so a caller that needs the slope + * pays for one noise read, not three. After Inigo Quilez's "gradient noise + * derivatives" (iquilezles.org/articles/gradientnoise). + * + * The value runs roughly -0.7..0.7, and the slope's length stays under about + * 2 per lattice cell. Only `slope` feeds the flow, and the GPU compiler drops + * `value` when nothing reads it. + */ +function gradientNoise( + point: TSLNode, + seedWord: ShaderNodeObject, + phase: ShaderNodeObject, +) { + // Split the point into its lattice cell (the integer corner below and to + // its left) and its place inside that cell, 0..1 on each axis. + const cell = floor(point); + const local = fract(point); + + // The quintic fade 6t^5 - 15t^4 + 10t^3: an S-curve from 0 to 1 whose + // first and second slopes are 0 at both ends, so neighboring cells meet + // with no visible crease in the noise or in its slope. `fadeSlope` is its + // derivative, 30t^2(t - 1)^2, which the slope below needs. + const fade = local + .mul(local) + .mul(local) + .mul(local.mul(local.mul(6).sub(15)).add(10)); + const fadeSlope = local + .mul(local) + .mul(30) + .mul(local.mul(local.sub(2)).add(1)); + + // One random arrow per corner. Nested hashing, row then column, as in + // voronoi-cells.ts, keeps neighboring cells unrelated. The seed joins the + // row hash, so each seed reshuffles every arrow. + const lowerRow = stableHashUint(wrapCell(cell.y).toUint().add(seedWord)); + const upperRow = stableHashUint(wrapCell(cell.y.add(1)).toUint().add(seedWord)); + const leftColumn = wrapCell(cell.x); + const rightColumn = wrapCell(cell.x.add(1)); + const arrowA = cornerArrow(leftColumn, lowerRow, phase); // corner (0, 0) + const arrowB = cornerArrow(rightColumn, lowerRow, phase); // corner (1, 0) + const arrowC = cornerArrow(leftColumn, upperRow, phase); // corner (0, 1) + const arrowD = cornerArrow(rightColumn, upperRow, phase); // corner (1, 1) + + // Each corner's vote: how far the point sits along that corner's arrow. + // The vote is 0 at the corner itself, so the noise is 0 on every lattice + // point, and it rises or falls away from the corner as the arrow points. + const voteA = arrowA.dot(local); + const voteB = arrowB.dot(local.sub(vec2(1, 0))); + const voteC = arrowC.dot(local.sub(vec2(0, 1))); + const voteD = arrowD.dot(local.sub(vec2(1, 1))); + + // Blend the four votes across the cell with the fade as the weight, written + // out as a bilinear blend: start at A, add the x and y steps, then the + // cross term that bends the surface between the four corners. + const cross = voteA.sub(voteB).sub(voteC).add(voteD); + const value = voteA + .add(fade.x.mul(voteB.sub(voteA))) + .add(fade.y.mul(voteC.sub(voteA))) + .add(fade.x.mul(fade.y).mul(cross)); + + // The same blend, differentiated. Two parts: the arrows blended with the + // same weights (how each vote changes as the point moves), plus the + // fade's own slope times how much the votes differ across the cell (how + // the weights change as the point moves). + const arrowCross = arrowA.sub(arrowB).sub(arrowC).add(arrowD); + const slope = arrowA + .add(arrowB.sub(arrowA).mul(fade.x)) + .add(arrowC.sub(arrowA).mul(fade.y)) + .add(arrowCross.mul(fade.x.mul(fade.y))) + .add(fadeSlope.mul(fade.yx.mul(cross).add(vec2(voteB, voteC)).sub(voteA))); + + return { value, slope }; +} + +// --------------------------------------------- +// The flow's velocity +// --------------------------------------------- + +/** What shapes the flow, shared by the flow pass and the inline fallback. */ +export interface FlowInputs { + /** A whole number 0 or above that picks the flow's random layout. */ + seed: TSLNode; + /** + * How far the flow has moved: the speed prop summed over time. Each lattice + * arrow turns by about this many radians, more or less by its own spin, + * and the flow slides SLIDE_RATE lattice cells per unit. + */ + phase: TSLNode; +} + +/** + * The flow's velocity at `point`, in lattice cells per unit of trace time, + * worked out from the noise. The velocity is the curl of the noise: its + * slope turned 90 degrees, (slope.y, -slope.x). Turned that way, it runs + * along the noise's contour lines instead of downhill, so it never gathers + * into a sink or spreads from a source. Every patch of color keeps its area + * as it moves: bands stretch into threads and fold rather than bunching + * into knots. + * + * The flow is two layers of noise, broad eddies and smaller ones at twice + * the frequency, and the curl of a sum is the sum of the curls. + */ +function flowVelocity(point: TSLNode, { seed, phase }: FlowInputs) { + const broadSeed = stableHashUint(float(seed)); + // A second, unrelated hash stream, so the smaller eddies don't line up + // with the broad ones. + const smallSeed = stableHashUint(broadSeed); + const phaseNode = float(phase); + + // Reading the flow from a point behind this one, against SLIDE_DIRECTION, + // shifts the whole flow forward along it by phase x SLIDE_RATE lattice cells. + const slideDistance = phaseNode.mul(SLIDE_RATE); + const readPoint = vec2(point).sub( + vec2(slideDistance.mul(SLIDE_DIRECTION[0]), slideDistance.mul(SLIDE_DIRECTION[1])), + ); + + const broadSlope = gradientNoise(readPoint, broadSeed, phaseNode).slope; + const smallSlope = gradientNoise(readPoint.mul(SMALL_EDDY_FREQUENCY), smallSeed, phaseNode).slope; + + // The small layer's slope weight. Read at twice the frequency, its slope + // comes out twice as steep by the chain rule, so at a strength of 0.5 the + // small eddies swirl as hard as the broad ones. The strength scales the + // layer's height, as an fBm octave's gain does. + const slope = broadSlope.add(smallSlope.mul(SMALL_EDDY_STRENGTH * SMALL_EDDY_FREQUENCY)); + + // Curl: the slope turned 90 degrees clockwise. + return vec2(slope.y, slope.x.negate()); +} + +/** + * FlowInputs as uniforms, whose current values the flow pass can read on + * the CPU to tell whether the flow changed since its last draw. + */ +export type FlowUniforms = { [Input in keyof FlowInputs]: ReturnType> }; + +/** Reads the flow's velocity at a point, in lattice cells. */ +export type VelocityAt = (point: ShaderNodeObject) => ShaderNodeObject; + +/** + * The inline fallback: a VelocityAt that works the velocity out from the + * noise at every read, two noise reads per step. For renderers the flow + * pass can't draw on. + */ +export function inlineVelocity(inputs: FlowInputs): VelocityAt { + return (point) => flowVelocity(point, inputs); +} + +// --------------------------------------------- +// The flow pass +// --------------------------------------------- + +/** The flow pass's handle: how to read the flow, and how to release it. */ +export interface FlowPass { + velocityAt: VelocityAt; + dispose: () => void; +} + +/** + * Draws one period of the flow's velocity into a texture, so each trace step + * reads one texel (one pixel of a texture) instead of working out two noise + * layers. The velocity depends on the point the trace reaches and on the + * uniforms, never on the pixel, so every pixel's steps read the same field + * (the texture-pass case in docs/agents/tsl.md). Returns null where the + * renderer can't draw into a half-float target, and the trace works the + * velocity out inline. + * + * The texture holds FLOW_TEXELS + 1 texels a side. Texel i sits at lattice + * point i x FLOW_PERIOD / FLOW_TEXELS, so the last row and column land on + * the period's far edge and, since the flow repeats, repeat the first. A + * read anywhere in the period falls between two drawn texels and never on + * the texture's edge, so the linear filter blends across the period's seam + * as smoothly as anywhere else. + * + * The pass redraws only on a frame where one of its inputs has changed since + * its last draw. While the flow holds still, other layers can keep the scene + * drawing, as a cursor effect does, and the texture already holds the flow. + * + * The caller creates and disposes the pass in one effect, so a Strict Mode + * remount builds a fresh one (the Strict Mode gotcha in docs/agents/tsl.md). + */ +export function createFlowPass( + shaderContext: ShaderContextValue, + inputs: FlowUniforms, +): FlowPass | null { + const side = FLOW_TEXELS + 1; + + // One texel's value: the velocity at the lattice point under it. uv() is + // the texel center's 0..1 position, (i + 0.5) / side, so undoing that + // gives back i, and i x FLOW_PERIOD / FLOW_TEXELS is the lattice point. + const velocityNode = Fn(() => { + const point = uv() + .mul(side) + .sub(0.5) + .mul(FLOW_PERIOD / FLOW_TEXELS); + + return vec4(flowVelocity(point, inputs), 0, 1); + })(); + + // A half float holds the velocity to about three significant digits, far + // finer than a step can show. Half floats are filterable on every backend + // that draws them, so the linear filter blends the four texels around + // each read. + const pass = createTexturePass(shaderContext.renderer.three, velocityNode, { + width: side, + height: side, + type: HalfFloatType, + filter: LinearFilter, + }); + const velocityTexture = pass.texture; + + if (velocityTexture === null) return null; + + // The output stage draws pre-passes ahead of the scene pass in the same + // frame, so the trace always reads this frame's flow (the draw-order + // gotcha in docs/agents/tsl.md). The CPU compares each input with the + // value it had at the last draw and skips the draw while all of them + // match. NaN never equals a number, so the first frame always draws. + const sources = [inputs.seed, inputs.phase]; + const drawnValues = sources.map(() => Number.NaN); + const removePrePass = shaderContext.registerPrePass(() => { + if (sources.every((source, index) => source.value === drawnValues[index])) return; + sources.forEach((source, index) => { + drawnValues[index] = source.value; + }); + pass.render(); + }); + + return { + // Fold the point into the period, then into the texture: f x FLOW_TEXELS + // is the texel index, and + 0.5 over the side is its center in 0..1. + // The coordinate goes in when the texture node is built, not chained on + // with .uv(), so three skips the texture matrix on every step (the + // texture-matrix gotcha in docs/agents/tsl.md). + velocityAt: (point) => { + const periodFraction = fract(point.mul(1 / FLOW_PERIOD)); + const coordinate = periodFraction.mul(FLOW_TEXELS).add(0.5).div(side); + + return texture(velocityTexture, coordinate).xy; + }, + dispose() { + removePrePass(); + pass.dispose(); + }, + }; +} + +// --------------------------------------------- +// The backward trace +// --------------------------------------------- + +/** Reads linear RGB at one point of a trace, given its offset from the start. */ +export type PathSampler = (offset: ShaderNodeObject) => ShaderNodeObject; + +/** The options for one backward trace through the flow. */ +export interface TraceFlowOptions { + /** + * How many steps the trace takes. A plain number, because it is the loop's + * bound and the loop is compiled into the shader. + */ + steps: number; + /** + * How far each step travels where the flow runs at unit speed, in lattice + * cells. 0 holds the trace in place. + */ + stepLength: TSLNode; + /** Where the trace reads the flow's velocity: the flow pass, or inline. */ + velocityAt: VelocityAt; + /** + * What color to read at every point of the path. The trace calls this + * inside its shader function before the loop, so it can pin the values + * that are the same on every step with .toVar() (the first-use gotcha in + * docs/agents/tsl.md), and it returns the reader for one point. + */ + sampleAlongPath: () => PathSampler; +} + +/** + * Trace a point backward along the flow and return the average linear RGB + * color of every point it passed, from the start through each step to the + * end. Each step reads the velocity where the trace has reached, steps + * against it, toward where the color came from, and reads the color there. + * The average streaks every color along the current, the way a long + * exposure of moving liquid does. + * + * Written with Fn and Loop, so the GPU runs a real loop instead of `steps` + * copies of the body. + */ +export function traceFlow( + start: TSLNode, + { steps, stepLength, velocityAt, sampleAlongPath }: TraceFlowOptions, +): ShaderNodeObject { + return Fn(() => { + // Pin what is the same on every step before the loop. Without .toVar(), + // TSL writes each expression where the shader first uses it, which is + // inside the loop, so every step would redo it (the first-use gotcha in + // docs/agents/tsl.md). + const origin = vec2(start).toVar(); + const step = float(stepLength).toVar(); + const sampleAt = sampleAlongPath(); + + // The loop's running state: how far the trace has moved from `origin`, + // and the sum of the colors it read. Starting the offset at 0 means a + // step length of 0 reads the pixel's own point every time, so straight + // bands stay straight. The sum starts with that point. + const offset = vec2(0, 0).toVar(); + const colorSum = vec3(sampleAt(offset)).toVar(); + + Loop(steps, () => { + // One velocity read per step. STEP_COUNT in ./shader.tsx says why + // midpoint integration's second read isn't worth it here. + offset.subAssign(velocityAt(origin.add(offset)).mul(step)); + colorSum.addAssign(sampleAt(offset)); + }); + + return colorSum.div(steps + 1); + })(); +} diff --git a/packages/shaders/src/components/liquid-gradient/liquid-gradient.test.tsx b/packages/shaders/src/components/liquid-gradient/liquid-gradient.test.tsx new file mode 100644 index 00000000..444e9323 --- /dev/null +++ b/packages/shaders/src/components/liquid-gradient/liquid-gradient.test.tsx @@ -0,0 +1,244 @@ +import { type ReactNode, StrictMode } from 'react'; + +import { render } from '@testing-library/react'; +import { type RenderTarget, Scene, type Texture, type WebGPURenderer } from 'three/webgpu'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PrePass, SchedulerTick } from '../../engine.js'; +import { ShaderContext, type ShaderContextValue } from '../../react/context/shader-context.js'; +import { LiquidGradient } from './liquid-gradient.js'; + +// When LiquidGradient's pre-passes draw, never what the liquid looks like. +// The look is the demo page and apps/docs-tests/visual/liquid-gradient.spec.ts, +// which can't tell a texture drawn once from one drawn every frame. A stub +// renderer stands in for three's, as in aurora.test.tsx. +beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', () => 0); + vi.stubGlobal('cancelAnimationFrame', () => {}); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +interface StubOptions { + /** WebGL2: whether EXT_color_buffer_float lets the renderer draw into float targets. */ + floatTargets?: boolean; +} + +/** A WebGL2 renderer on a device that allows textures 4096 texels a side. */ +function makeRenderer({ floatTargets = true }: StubOptions = {}) { + let boundTarget: RenderTarget | null = null; + + return { + render: vi.fn(), + setRenderTarget: vi.fn((target: RenderTarget | null) => { + boundTarget = target; + }), + getRenderTarget: vi.fn(() => boundTarget), + getPixelRatio: () => 2, + getDrawingBufferSize: (target: { set: (x: number, y: number) => unknown }) => + target.set(1600, 800), + backend: { + isWebGLBackend: true, + extensions: { has: (name: string) => floatTargets && name === 'EXT_color_buffer_float' }, + gl: { MAX_TEXTURE_SIZE: 0x0d33, getParameter: () => 4096 }, + }, + } as unknown as WebGPURenderer; +} + +/** + * A scene context whose registerPrePass records what LiquidGradient draws + * before the scene, and whose scheduler ticks only when a test says so. + */ +function makeScene(renderer: WebGPURenderer) { + const scene = new Scene(); + const prePasses: PrePass[] = []; + const removedPrePasses: PrePass[] = []; + const clients = new Set<(tick: SchedulerTick) => void>(); + const scheduler = { + idle: true, + add: vi.fn((client: (tick: SchedulerTick) => void) => { + clients.add(client); + }), + remove: vi.fn((client: (tick: SchedulerTick) => void) => { + clients.delete(client); + }), + requestRender: vi.fn(() => true), + setIdle: vi.fn(() => () => undefined), + onPhaseReset: vi.fn(() => () => undefined), + }; + const shaderContext = { + renderer: { three: renderer }, + scene, + camera: {}, + scheduler, + registerOverlay: () => () => undefined, + registerBaseUvTransform: () => () => undefined, + registerPrePass: (prePass: PrePass) => { + prePasses.push(prePass); + + return () => { + removedPrePasses.push(prePass); + }; + }, + canvasSize: { + get: () => [800, 400, 2], + on: () => () => undefined, + }, + timeGpu: () => () => undefined, + } as unknown as ShaderContextValue; + + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + + /** Advance every scheduler client by one frame of `delta` seconds. */ + const tick = (delta: number) => { + for (const client of clients) client({ delta, elapsed: delta } as SchedulerTick); + }; + + /** The pre-passes still registered: added and not yet removed. */ + const livePrePasses = () => prePasses.filter((prePass) => !removedPrePasses.includes(prePass)); + + return { Wrapper, scene, tick, livePrePasses }; +} + +/** Run a pre-pass and return the textures it drew into, none if it skipped. */ +function drawPrePass(renderer: WebGPURenderer, prePass: PrePass): Texture[] { + vi.mocked(renderer.setRenderTarget).mockClear(); + prePass(); + + // A draw binds its target, then puts back the one bound before, which is + // none here. + return vi + .mocked(renderer.setRenderTarget) + .mock.calls.flatMap(([target]) => (target ? [target.texture] : [])); +} + +// Each pass draws into a texture of its own size, which names it. +const PASS_BY_SIZE: Record = { + '257x257': 'flow', + '1025x1025': 'paint', + '1024x1': 'ramp', + '1x1': 'average', +}; + +/** + * Run every live pre-pass once, as the first frame does, and name each by + * the texture it drew. + */ +function drawFirstFrame(renderer: WebGPURenderer, live: PrePass[]) { + const passes: Record = {}; + + for (const prePass of live) { + const [drawn] = drawPrePass(renderer, prePass); + + if (!drawn) throw new Error('expected every pre-pass to draw on the first frame'); + const name = PASS_BY_SIZE[`${drawn.image.width}x${drawn.image.height}`]; + + if (!name) throw new Error('expected a texture the size of a known pass'); + passes[name] = prePass; + } + + return passes; +} + +/** How many times each named pre-pass draws on one frame. */ +function drawCounts(renderer: WebGPURenderer, passes: Record) { + return Object.fromEntries( + Object.entries(passes).map(([name, prePass]) => [name, drawPrePass(renderer, prePass).length]), + ); +} + +describe('LiquidGradient with half-float targets', () => { + // Strict Mode mounts, unmounts, and mounts again, so a leaked + // registration from the first mount would draw every texture twice. + it('registers one pre-pass each for the flow, the paint, the ramp, and the average', () => { + const renderer = makeRenderer(); + const scene = makeScene(renderer); + const { unmount } = render(, { wrapper: scene.Wrapper }); + const live = scene.livePrePasses(); + + expect(live).toHaveLength(4); + expect(Object.keys(drawFirstFrame(renderer, live)).sort()).toEqual([ + 'average', + 'flow', + 'paint', + 'ramp', + ]); + expect(scene.scene.children).toHaveLength(1); + + unmount(); + expect(scene.livePrePasses()).toHaveLength(0); + expect(scene.scene.children).toHaveLength(0); + }); + + // Other layers can keep the scene drawing while the liquid holds still, + // and each skipped draw saves a render pass. + it('skips every draw while nothing a texture depends on has changed', () => { + const renderer = makeRenderer(); + const scene = makeScene(renderer); + + render(, { wrapper: scene.Wrapper }); + const passes = drawFirstFrame(renderer, scene.livePrePasses()); + + expect(drawCounts(renderer, passes)).toEqual({ flow: 0, paint: 0, ramp: 0, average: 0 }); + }); + + it('redraws the flow and the paint once when the seed changes', () => { + const renderer = makeRenderer(); + const scene = makeScene(renderer); + const { rerender } = render(, { wrapper: scene.Wrapper }); + const passes = drawFirstFrame(renderer, scene.livePrePasses()); + + rerender(); + + expect(drawCounts(renderer, passes)).toEqual({ flow: 1, paint: 1, ramp: 0, average: 0 }); + expect(drawCounts(renderer, passes)).toEqual({ flow: 0, paint: 0, ramp: 0, average: 0 }); + }); + + it('redraws only the average once when softness changes', () => { + const renderer = makeRenderer(); + const scene = makeScene(renderer); + const { rerender } = render(, { wrapper: scene.Wrapper }); + const passes = drawFirstFrame(renderer, scene.livePrePasses()); + + rerender(); + + expect(drawCounts(renderer, passes)).toEqual({ flow: 0, paint: 0, ramp: 0, average: 1 }); + expect(drawCounts(renderer, passes)).toEqual({ flow: 0, paint: 0, ramp: 0, average: 0 }); + }); + + it('redraws only the flow on each frame the flow moves', () => { + const renderer = makeRenderer(); + const scene = makeScene(renderer); + + render(, { wrapper: scene.Wrapper }); + const passes = drawFirstFrame(renderer, scene.livePrePasses()); + + scene.tick(1 / 60); + expect(drawCounts(renderer, passes)).toEqual({ flow: 1, paint: 0, ramp: 0, average: 0 }); + + scene.tick(1 / 60); + expect(drawCounts(renderer, passes)).toEqual({ flow: 1, paint: 0, ramp: 0, average: 0 }); + }); +}); + +// Without half-float targets every read works its value out inline, so +// LiquidGradient still draws, only slower. +describe('LiquidGradient without half-float targets', () => { + it('registers no pre-pass and still draws its mesh', () => { + const renderer = makeRenderer({ floatTargets: false }); + const scene = makeScene(renderer); + + render(, { wrapper: scene.Wrapper }); + + expect(scene.livePrePasses()).toHaveLength(0); + expect(scene.scene.children).toHaveLength(1); + }); +}); diff --git a/packages/shaders/src/components/liquid-gradient/liquid-gradient.tsx b/packages/shaders/src/components/liquid-gradient/liquid-gradient.tsx new file mode 100644 index 00000000..d605d5ca --- /dev/null +++ b/packages/shaders/src/components/liquid-gradient/liquid-gradient.tsx @@ -0,0 +1,109 @@ +'use client'; + +// Public face of the liquid gradient: owns the props, their JSDoc, and their +// defaults, then delegates to LiquidGradientShader (./shader.tsx), which +// streaks LinearGradient's bands and a field of fractal paint along slow, +// curling currents, like color stirred into liquid. Render it inside a +// . +import type { ColorSpace, HueInterpolation } from '../../engine.js'; +import type { AnimatableProp } from '../../react/hooks/animatable-signal/animatable-signal.js'; +import type { ColorStop } from '../shared/color.js'; +import { LiquidGradientShader } from './shader.js'; + +export type { ColorStop } from '../shared/color.js'; + +export interface LiquidGradientProps { + /** + * Colors of the bands. Accepts hex, `oklch()`, or `oklab()`; positions + * auto-space when omitted. + */ + stops?: ColorStop[]; + /** + * Direction the bands run across before the flow carries them, in + * degrees. 0 runs left to right, 90 runs bottom to top. Defaults to 0. + * Accepts a static value or an animation signal. + */ + angle?: AnimatableProp; + /** + * How many times the stops run across the canvas. Every other pass runs + * the stops backward, so each pass ends on the color the next one starts + * with and no seam forms. Values at or below 1 render as a single pass. + * Defaults to 2. Accepts a static value or an animation signal. + */ + repeat?: AnimatableProp; + /** + * How the colors meet. 0 gives hard bands, one flat band per stop; 1 eases + * each stop into the next across the whole space between them. Defaults + * to 1. Accepts a static value or an animation signal. + */ + softness?: AnimatableProp; + /** + * How far the current carries the colors. 0 leaves the bands straight, and + * the paint's blotches fade in by 0.2; higher values streak and curl the + * colors further. Above about 2, the streaks start to break into separate + * copies around tight curls. Defaults to 0.9. Accepts a static value or an + * animation signal. + */ + swirl?: AnimatableProp; + /** + * Roughly how many eddies span the canvas's height. Above 4, the flow's + * pattern starts to repeat across a wide canvas. Defaults to 1.5. Accepts + * a static value or an animation signal. + */ + scale?: AnimatableProp; + /** + * How fast the current drifts: its eddies form, merge, and dissolve as + * they slide across the canvas. 0 holds the flow still. Defaults to 0.15. + * Accepts a static value or an animation signal. + */ + speed?: AnimatableProp; + /** + * Picks a different flow and paint with the same character. Each whole + * number gives an unrelated layout. Defaults to 0. + */ + seed?: number; + /** Color space the bands are interpolated in. Defaults to `'oklab'`. */ + colorSpace?: ColorSpace; + /** + * Hue arc for cylindrical color spaces (oklch/lch/hsl/hsv); inert + * otherwise. Defaults to `'shorter'`. + */ + hueInterpolation?: HueInterpolation; +} + +// Blue -> violet -> purple: 55 degrees of hue, with lightness climbing 0.157 +// and then 0.26 between stops. The mirrored passes run it back down again, +// so the darkest blue and the lightest purple each meet only themselves. +const DEFAULT_STOPS: ColorStop[] = [ + { color: 'oklch(0.303 0.152 265.847)' }, // paletteOklch.blue[3] + { color: 'oklch(0.460 0.248 293.328)' }, // paletteOklch.violet[6] + { color: 'oklch(0.720 0.250 320)' }, // paletteOklch.purple[9] +]; + +export function LiquidGradient({ + stops = DEFAULT_STOPS, + angle = 0, + repeat = 2, + softness = 1, + swirl = 0.9, + scale = 1.5, + speed = 0.15, + seed = 0, + colorSpace = 'oklab', + hueInterpolation = 'shorter', +}: LiquidGradientProps) { + return ( + + ); +} diff --git a/packages/shaders/src/components/liquid-gradient/paint.ts b/packages/shaders/src/components/liquid-gradient/paint.ts new file mode 100644 index 00000000..95fb59b5 --- /dev/null +++ b/packages/shaders/src/components/liquid-gradient/paint.ts @@ -0,0 +1,214 @@ +// The paint the flow carries: a field of fractal noise that the shader +// (./shader.tsx) adds to each point's place in the bands. Its broad layers +// break the bands into blotches, and its fine layers are what the path +// average draws out into streaks along the current, the way a long exposure +// streaks a detailed surface. The paint pass draws one period of it into a +// texture when the seed changes, so each trace step reads one texel (one +// pixel of a texture) instead of summing the layers. +import type { ShaderNodeObject } from 'three/tsl'; +import { float, floor, Fn, fract, mix, texture, type uniform, uv, vec2, vec4 } from 'three/tsl'; +import { HalfFloatType, LinearFilter, type Node, RedFormat } from 'three/webgpu'; + +import { createTexturePass, stableHash, stableHashUint, type TSLNode } from '../../engine.js'; +import type { ShaderContextValue } from '../../react/context/shader-context.js'; +import { FLOW_PERIOD, wrapIndex } from './flow.js'; + +// --------------------------------------------- +// Constants +// --------------------------------------------- + +// The paint repeats with the flow, every FLOW_PERIOD lattice cells, so the +// two tile together and no repeat shows below the scale slider's top. +const PAINT_PERIOD = FLOW_PERIOD; + +// The noise layers, broadest first, in waves per lattice cell. Each doubles +// the one before, so the broadest makes blotches about an eddy wide and the +// finest makes detail a sixteenth of that, which the path average draws +// out into streaks. Every frequency times PAINT_PERIOD is a whole number, +// so every layer repeats with the period. +const LAYER_FREQUENCIES = [1, 2, 4, 8, 16]; + +// Texels per period on each side. 1024 over 8 cells gives 128 texels per +// cell, 8 per wave of the finest layer, which the linear filter (a read +// between texel centers blends the four around it) smooths over. The +// texture holds one half float, a 16-bit float, per texel, 2 MB, and draws +// only when the seed changes. +const PAINT_TEXELS = 1024; + +// How loudly the finer layers speak: each layer weighs this much times the +// one before. Lower leaves soft blotches; higher adds more of the fine +// detail the path average streaks along the current. At 1 every layer +// would weigh the same. +const LAYER_GAIN = 0.6; + +// The inline fallback sums only the broadest layers, because every pixel +// pays for them at every step. +const INLINE_LAYERS = 2; + +// --------------------------------------------- +// Value noise +// --------------------------------------------- + +/** + * Smooth value noise at `point`, -1..1, repeating every `period` cells. Each + * lattice corner gets a random height from stableHash, and the point blends + * the four corners around it with the quintic fade, so the surface is + * smooth across cell edges. Cheaper than gradient noise, and fine here, + * where nothing needs its slope. + */ +function valueNoise( + point: ShaderNodeObject, + seedWord: ShaderNodeObject, + period: number, +) { + const cell = floor(point); + const local = fract(point); + + // The quintic fade 6t^5 - 15t^4 + 10t^3, as in flow.ts. + const fade = local + .mul(local) + .mul(local) + .mul(local.mul(local.mul(6).sub(15)).add(10)); + + // One random height per corner, from nested row-then-column hashes as in + // flow.ts, mapped from 0..1 to -1..1. + const lowerRow = stableHashUint(wrapIndex(cell.y, period).toUint().add(seedWord)); + const upperRow = stableHashUint(wrapIndex(cell.y.add(1), period).toUint().add(seedWord)); + const leftColumn = wrapIndex(cell.x, period).toUint(); + const rightColumn = wrapIndex(cell.x.add(1), period).toUint(); + const heightAt = (column: ShaderNodeObject, rowWord: ShaderNodeObject) => + stableHash(column.add(rowWord)).mul(2).sub(1); + + const lower = mix(heightAt(leftColumn, lowerRow), heightAt(rightColumn, lowerRow), fade.x); + const upper = mix(heightAt(leftColumn, upperRow), heightAt(rightColumn, upperRow), fade.x); + + return mix(lower, upper, fade.y); +} + +/** What shapes the paint. */ +export interface PaintInputs { + /** A whole number 0 or above that picks the paint's random layout. */ + seed: TSLNode; +} + +/** + * The paint at `point`, summed from the first `layerCount` layers and + * divided by the total weight, so it stays roughly -1..1. + */ +function paintNoise(point: TSLNode, { seed }: PaintInputs, layerCount: number) { + // A hash stream of its own, so the paint doesn't line up with the flow, + // which hashes the seed directly. + const seedWord = stableHashUint(stableHashUint(float(seed)).add(1)); + let total = float(0); + let totalWeight = 0; + let weight = 1; + + LAYER_FREQUENCIES.slice(0, layerCount).forEach((frequency, layer) => { + // Each layer gets its own stream, seedWord + layer, so the layers + // don't echo each other. + const layerNoise = valueNoise( + vec2(point).mul(frequency), + seedWord.add(layer), + PAINT_PERIOD * frequency, + ); + + total = total.add(layerNoise.mul(weight)); + totalWeight += weight; + weight *= LAYER_GAIN; + }); + + return total.div(totalWeight); +} + +// --------------------------------------------- +// The paint pass +// --------------------------------------------- + +/** Reads the paint at a point in lattice cells, -1..1. */ +export type PaintAt = (point: ShaderNodeObject) => ShaderNodeObject; + +/** The paint pass's handle: how to read the paint, and how to release it. */ +export interface PaintPass { + paintAt: PaintAt; + dispose: () => void; +} + +/** + * Draws one period of the paint into a one-channel texture, so a trace step + * reads one texel instead of summing five layers. Laid out like the flow + * pass in flow.ts: PAINT_TEXELS + 1 texels a side, the last row and column + * repeating the first, so the linear filter blends across the period's seam + * as smoothly as anywhere else. The paint holds still while the flow moves, + * so the pass draws on the first frame and again only when the seed + * changes. Where the renderer can't draw half floats, every read sums the + * broadest layers inline instead. + * + * The caller creates and disposes the pass in one effect, so a Strict Mode + * remount builds a fresh one (the Strict Mode gotcha in docs/agents/tsl.md). + */ +export function createPaintPass( + shaderContext: ShaderContextValue, + inputs: { seed: ReturnType> }, +): PaintPass { + const side = PAINT_TEXELS + 1; + + // One texel's value: the paint at the lattice point under it, worked out + // from uv() as the flow pass does. + const paintNode = Fn(() => { + const point = uv() + .mul(side) + .sub(0.5) + .mul(PAINT_PERIOD / PAINT_TEXELS); + + return vec4(paintNoise(point, inputs, LAYER_FREQUENCIES.length), 0, 0, 1); + })(); + + // A half float keeps the paint's fine steps, which an 8-bit texel would + // round into visible terraces once the path average smooths them. One + // channel stores a quarter of the bytes an RGBA texel would. + const pass = createTexturePass(shaderContext.renderer.three, paintNode, { + width: side, + height: side, + type: HalfFloatType, + filter: LinearFilter, + format: RedFormat, + }); + const paintTexture = pass.texture; + + if (paintTexture === null) { + return { + paintAt: (point) => paintNoise(point, inputs, INLINE_LAYERS), + dispose: () => { + // Nothing was allocated. + }, + }; + } + + // A pre-pass is a draw the output stage runs before the scene pass in the + // same frame (the draw-order gotcha in docs/agents/tsl.md). This one + // compares the seed with its value at the last draw and skips the draw + // while it matches. NaN never equals a number, so the first frame always + // draws. + let drawnSeed = Number.NaN; + const removePrePass = shaderContext.registerPrePass(() => { + if (inputs.seed.value === drawnSeed) return; + drawnSeed = inputs.seed.value; + pass.render(); + }); + + return { + // Fold the point into the period, then into the texture, as the flow + // pass's velocityAt does, with the coordinate passed when the node is + // built (the texture-matrix gotcha in docs/agents/tsl.md). + paintAt: (point) => { + const periodFraction = fract(point.mul(1 / PAINT_PERIOD)); + const coordinate = periodFraction.mul(PAINT_TEXELS).add(0.5).div(side); + + return texture(paintTexture, coordinate).r; + }, + dispose() { + removePrePass(); + pass.dispose(); + }, + }; +} diff --git a/packages/shaders/src/components/liquid-gradient/shader.tsx b/packages/shaders/src/components/liquid-gradient/shader.tsx new file mode 100644 index 00000000..efacb66c --- /dev/null +++ b/packages/shaders/src/components/liquid-gradient/shader.tsx @@ -0,0 +1,403 @@ +'use client'; + +// LiquidGradient's GPU half. The pixel's journey: place the pixel in the +// flow, trace it backward along the flow (./flow.ts), color every point the +// trace passes from the bands and the paint there (./bands.ts, ./paint.ts), +// and average those colors. The average streaks each color along the +// current, the way a long exposure of moving liquid does, and the paint's +// fine detail is what the streaks are drawn from. The wrapper +// (./liquid-gradient.tsx) supplies the props. +import { useEffect, useMemo } from 'react'; + +import { + cos, + fract, + fwidth, + max, + mix, + type ShaderNodeObject, + sin, + smoothstep, + uniform, + uv, + vec2, +} from 'three/tsl'; +import { Mesh, MeshBasicNodeMaterial, type Node, PlaneGeometry } from 'three/webgpu'; + +import type { ColorSpace, HueInterpolation } from '../../engine.js'; +import type { AnimatableProp } from '../../react/hooks/animatable-signal/animatable-signal.js'; +import { useAnimatableSpeed } from '../../react/hooks/use-animatable-speed/use-animatable-speed.js'; +import { useAnimatableUniform } from '../../react/hooks/use-animatable-uniform/use-animatable-uniform.js'; +import { useAspectUniform } from '../../react/hooks/use-aspect-uniform/use-aspect-uniform.js'; +import { useShaderContext } from '../../react/hooks/use-shader-context/use-shader-context.js'; +import { useStaticSceneHint } from '../../react/hooks/use-static-hint/use-static-hint.js'; +import { type ColorStop, colorStopsKey, toColorRampStops } from '../shared/color.js'; +import { type BandPalette, createAveragePass, createRampPass } from './bands.js'; +import { createFlowPass, inlineVelocity, type PathSampler, traceFlow } from './flow.js'; +import { createPaintPass } from './paint.js'; + +// --------------------------------------------- +// Feel constants (tuned by eye at the defaults gate) +// --------------------------------------------- +// GPU time, measured with on WebGPU in headless Chromium +// on the M1 Max at 2x, beside a LinearGradient baseline reading 0.12 ms: at +// 1440 by 900, 2.6 to 2.7 ms with the flow moving and 2.5 to 2.7 ms still. + +// How many points each pixel's trace colors and averages. Each step reads +// the flow, the paint, and the cached palette. 12 steps with two flow reads +// each, for midpoint integration, read 0.57 to 0.63 ms in the Reactive +// card's 312 by 309 window at 2x, over its 0.4 ms budget. 8 steps with one +// read each, at the same reach, read 0.34 to 0.38 ms moving and 0.27 to +// 0.29 ms still, and looked the same. +const STEP_COUNT = 8; + +// How far one step travels at swirl 1 where the flow runs at unit speed, in +// lattice cells (about one eddy each). STEP_COUNT x STEP_LENGTH, 0.3 cells, +// is the whole trace's reach at swirl 1. Longer steps reach further for the +// same cost, until neighboring samples land far enough apart to draw the +// paint's detail as separate copies around tight curls instead of one +// streak. +const STEP_LENGTH = 0.0375; + +// Where thin streaks start fading to the bands' average color: the share of +// one whole pass of the stops a single pixel spans. The fade is complete at +// 1, where a pixel spans every stop and its true color is the average. +// Lower starts the fade sooner, which calms moving streaks at the cost of +// fine detail; higher keeps detail longer, and with it more shimmer. +const FADE_START = 0.25; + +// How far the paint shifts each point's place in the bands, in passes of +// the stops. The paint runs -1..1, so at 1 it can move a point a whole pass +// either way, which breaks the bands into blotches. 0 would leave plain +// streaked bands. +const PAINT_SHIFT = 1; + +// The swirl at which the paint reaches its full shift. Below it, the paint +// fades out with swirl, so swirl 0 draws straight bands instead of still +// blotches. Above it, swirl only carries the colors. +const FULL_PAINT_SWIRL = 0.2; + +export interface LiquidGradientShaderProps { + /** + * Colors of the bands. Accepts hex, `oklch()`, or `oklab()`; positions + * auto-space when omitted. + */ + stops: ColorStop[]; + /** + * Direction the bands run across before the flow carries them, in + * degrees. 0 runs left to right, 90 runs bottom to top. Accepts a static + * value or an animation signal. + */ + angle: AnimatableProp; + /** + * How many times the stops run across the canvas. Every other pass runs + * the stops backward, so each pass ends on the color the next one starts + * with and no seam forms. Values at or below 1 render as a single pass. + * Accepts a static value or an animation signal. + */ + repeat: AnimatableProp; + /** + * How the colors meet. 0 gives hard bands, one flat band per stop; 1 eases + * each stop into the next across the whole space between them. Accepts a + * static value or an animation signal. + */ + softness: AnimatableProp; + /** + * How far the current carries the colors. 0 leaves the bands straight, and + * the paint's blotches fade in by 0.2; higher values streak and curl the + * colors further. Above about 2, the streaks start to break into separate + * copies around tight curls. Accepts a static value or an animation + * signal. + */ + swirl: AnimatableProp; + /** + * Roughly how many eddies span the canvas's height. Above 4, the flow's + * pattern starts to repeat across a wide canvas. Accepts a static value or + * an animation signal. + */ + scale: AnimatableProp; + /** + * How fast the current drifts: its eddies form, merge, and dissolve as + * they slide across the canvas. 0 holds the flow still. Accepts a static + * value or an animation signal. + */ + speed: AnimatableProp; + /** + * Picks a different flow and paint with the same character. Each whole + * number gives an unrelated layout. + */ + seed: number; + /** Color space the bands are interpolated in. */ + colorSpace: ColorSpace; + /** Hue arc for cylindrical color spaces (oklch/lch/hsl/hsv); inert otherwise. */ + hueInterpolation: HueInterpolation; +} + +export function LiquidGradientShader({ + stops, + angle, + repeat, + softness, + swirl, + scale, + speed, + seed, + colorSpace, + hueInterpolation, +}: LiquidGradientShaderProps) { + const shaderContext = useShaderContext(); + + // A literal speed of 0 holds the flow still, so nothing on screen changes + // (an animation signal might move later, so it doesn't count). Telling + // the scene lets its frame scheduler park on the still frame instead of + // redrawing an unchanging image, as Grain does. + const isStatic = typeof speed === 'number' && speed === 0; + + useStaticSceneHint(isStatic); + + // useAnimatableSpeed sums speed x frame time into a phase uniform each + // frame. The flow's arrows turn and the flow slides by the phase, so + // changing speed changes the tempo without snapping the flow to where a + // time x speed product would put it. + const phaseUniform = useAnimatableSpeed(speed); + + // The animatable dials live in uniforms (values the CPU can update each + // frame without rebuilding the shader), tracking either a static number or + // an animation signal. + const angleUniform = useAnimatableUniform(angle); + const repeatUniform = useAnimatableUniform(repeat); + const softnessUniform = useAnimatableUniform(softness); + const swirlUniform = useAnimatableUniform(swirl); + const scaleUniform = useAnimatableUniform(scale); + + // Canvas width over height, so the eddies stay round on a wide canvas. + const aspectNode = useAspectUniform(); + + // The seed goes into the flow's and the paint's hashes, which read it as a + // whole number 0 or above (stableHash truncates to u32, and u32 of a + // negative float is backend-defined). The uniform is created once and + // rewritten, so a new seed repaints without recompiling. The scene renders + // on demand, so poke it to show the change (the bare-uniform-write gotcha + // in docs/agents/tsl.md). + const seedUniform = useMemo(() => uniform(0), []); + + useEffect(() => { + seedUniform.value = Math.round(Math.abs(seed)); + shaderContext?.scheduler.requestRender(); + }, [shaderContext, seedUniform, seed]); + + // Content fingerprint of the stops array (colors + positions). The build + // effect keys on this string, so a re-render that passes a new array with + // the same contents doesn't rebuild the material. + const stopsKey = colorStopsKey(stops); + + // --------------------------------------------- + // Build the material and mount the mesh + // --------------------------------------------- + // Runs once per mount, and again only when the stops, the color space, or + // the hue arc change, because colorRamp bakes the stop colors in as + // constants. Every dial above flows through uniforms. + useEffect( + () => { + if (!shaderContext) return; + + // --------------------------------------------- + // Place the pixel in the flow + // --------------------------------------------- + // uv() is the pixel's position on the plane, 0..1 both ways. + // Subtracting 0.5 centers it, so changing the scale zooms around the + // middle of the canvas rather than its corner. x times the aspect + // ratio measures both axes in canvas heights, which keeps the eddies + // round on a wide canvas. Times scale, one lattice cell of the noise, + // about one eddy, spans 1/scale of the canvas's height. + const centered = uv().sub(vec2(0.5, 0.5)); + const flowPoint = vec2(centered.x.mul(aspectNode), centered.y).mul(scaleUniform); + + // The flow pass draws the flow's velocity into a texture whenever the + // flow moves, so a read costs one texel. Where the renderer can't draw + // half floats, each read works the velocity out from the noise + // instead, which looks the same and costs far more. + const flowInputs = { seed: seedUniform, phase: phaseUniform }; + const flowPass = createFlowPass(shaderContext, flowInputs); + const velocityAt = flowPass?.velocityAt ?? inlineVelocity(flowInputs); + + // The paint pass draws the paint into a texture on the first frame and + // again only when the seed changes (./paint.ts). + const paintPass = createPaintPass(shaderContext, { seed: seedUniform }); + + // --------------------------------------------- + // Find the pixel's place in the bands + // --------------------------------------------- + // LinearGradient's projection at its default center: the angle becomes + // a unit direction, and the pixel's distance along it from the canvas + // center, plus 0.5, is its place on the ramp. At angle 0 that is plain + // u: 0 at the left edge, 1 at the right. + const angleRadians = angleUniform.mul(Math.PI / 180); + const direction = vec2(cos(angleRadians), sin(angleRadians)); + const pixelCoord = centered.dot(direction).add(0.5); + + // The projection is a straight line, so a point some lattice cells + // away from the pixel sits that offset, dotted with coordPerCell, + // further along the ramp. coordPerCell undoes the placement's + // stretches in reverse to turn cells into uv: divide by scale, then x + // by the aspect. The max() keeps a scale of 0 from dividing by zero. + const safeScale = max(scaleUniform, 0.001); + const coordPerCell = vec2(direction.x.div(aspectNode), direction.y).div(safeScale); + + // How far the paint can shift a point, in passes of the stops: + // PAINT_SHIFT, faded in by smoothstep as swirl rises from 0 to + // FULL_PAINT_SWIRL. + // smoothstep is exactly 1 past its upper edge, so at the default swirl + // the paint keeps its full strength. + const paintStrength = smoothstep(0, FULL_PAINT_SWIRL, swirlUniform).mul(PAINT_SHIFT); + + // A point's place in the bands, before the passes fold it: the band + // coordinate there, shifted by the paint there. The paint runs -1..1, + // so `strength` is how many passes it can shift a point either way. + const paintedCoord = ( + bandCoord: ShaderNodeObject, + point: ShaderNodeObject, + strength: ShaderNodeObject, + ) => bandCoord.add(paintPass.paintAt(point).mul(strength)); + + // A coordinate's place on the ramp, 0..1. Times repeat, it counts + // passes of the stops, and a triangle wave folds them so every other + // pass runs backward: fract(passes / 2) climbs 0..1 over two passes, + // x 2 - 1 makes that -1..1, and 1 - abs() folds it into 0 -> 1 -> 0. + // Each pass ends on the color the next starts with, so no seam forms. + // max() holds repeat at 1 or more, so a repeat at or below 1 draws a + // single pass. The fold still catches a coordinate the paint pushed + // past either end and turns it back, where clamping it would leave a + // flat patch of the first or last color. + const bandPosition = (coordinate: ShaderNodeObject) => + fract(coordinate.mul(max(repeatUniform, 1)).mul(0.5)) + .mul(2) + .sub(1) + .abs() + .oneMinus(); + + // --------------------------------------------- + // Color each point of the path + // --------------------------------------------- + // The ramp pass caches the palette's colors in a texture, so a sample + // reads RGB instead of converting color spaces (./bands.ts). The + // average pass holds the bands' average color, which thin streaks fade + // to. + const palette: BandPalette = { + stops: toColorRampStops(stops), + colorSpace, + hueInterpolation, + }; + const averagePass = createAveragePass(shaderContext, palette, softnessUniform); + const rampPass = createRampPass(shaderContext, palette); + + // One point's linear RGB color. A ramp coordinate is a position, not a + // color, and averaging positions through a curved ramp would invent a + // middle color the path never passed, so every sample becomes a color + // first and the trace averages the colors. + const colorAt = ( + coordinate: ShaderNodeObject, + averageColor: ShaderNodeObject, + ) => { + // The GPU shades pixels in 2x2 blocks, so each pixel can see how + // much a value changes to its neighbors. fwidth() returns that + // change, |change across| + |change down|. Taken on the coordinate + // before the fold and times repeat, it counts how many whole passes + // of the stops one pixel spans at this sample. On straight bands that + // is a few thousandths. Where the flow squeezes a streak thinner than + // a pixel, it climbs toward 1. Measured per sample, because slopes + // on either side of a fold cancel once averaged. + const footprint = fwidth(coordinate).mul(max(repeatUniform, 1)); + + // Stops ease in no narrower than one pixel's footprint, so a hard + // band's edge spans a pixel instead of a staircase. A sample that + // spans most of a pass would pick one color from all of them, which + // shimmers as the flow moves, so it fades toward the bands' average. + const bandColor = rampPass.colorAt(bandPosition(coordinate), softnessUniform, footprint); + + return mix(bandColor, averageColor, smoothstep(FADE_START, 1, footprint)); + }; + + // --------------------------------------------- + // Trace it back, averaging the colors it passes + // --------------------------------------------- + // swirl scales every step, so it sets how far the trace reaches: at 0 + // the trace stands still and the bands stay straight. The sampler pins + // its per-pixel inputs before the loop (the first-use gotcha in + // docs/agents/tsl.md), so each step recomputes only what depends on + // its offset. + const sampleAlongPath = (): PathSampler => { + const startPoint = flowPoint.toVar(); + const startCoord = pixelCoord.toVar(); + const perCell = coordPerCell.toVar(); + const strength = paintStrength.toVar(); + // The one-texel average is the same at every step. Read it once. + const averageColor = averagePass.color.toVar(); + + return (offset) => + colorAt( + paintedCoord(startCoord.add(offset.dot(perCell)), startPoint.add(offset), strength), + averageColor, + ); + }; + + // An unlit material whose per-pixel color comes from colorNode. + const material = new MeshBasicNodeMaterial(); + + material.colorNode = traceFlow(flowPoint, { + steps: STEP_COUNT, + stepLength: swirlUniform.mul(STEP_LENGTH), + velocityAt, + sampleAlongPath, + }); + + // A 2x2 plane exactly fills ShaderScene's camera view (-1..1 on both + // axes), so the gradient covers the whole canvas. + const mesh = new Mesh(new PlaneGeometry(2, 2), material); + + shaderContext.scene.add(mesh); + + // A rebuild swaps the mesh without touching any uniform, and a still + // scene is parked, so poke it to draw the new material. + shaderContext.scheduler.requestRender(); + + return () => { + shaderContext.scene.remove(mesh); + flowPass?.dispose(); + paintPass.dispose(); + averagePass.dispose(); + rampPass.dispose(); + try { + material.dispose(); + } catch { + // three/webgpu can throw during dispose under Strict Mode double-invoke + } + try { + mesh.geometry.dispose(); + } catch { + // same + } + }; + }, + // stopsKey is a stable string proxy for the stops array; the array itself + // is intentionally omitted to avoid rebuilds on identity-only changes. + // Animatable uniforms are mutated in place. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + shaderContext, + angleUniform, + repeatUniform, + softnessUniform, + swirlUniform, + scaleUniform, + aspectNode, + phaseUniform, + seedUniform, + stopsKey, + colorSpace, + hueInterpolation, + ], + ); + + return null; +} diff --git a/scripts/build-posters.sh b/scripts/build-posters.sh index c89e386f..ae0a3a27 100755 --- a/scripts/build-posters.sh +++ b/scripts/build-posters.sh @@ -65,6 +65,7 @@ for pair in \ "radial-gradient:jpg" \ "god-rays:jpg:#0b0f1a" \ "conic-gradient:jpg" \ + "liquid-gradient:jpg" \ "dither:jpg" \ "voronoi:jpg" \ "fractal-noise:jpg" \