From b180032c101cf77d7c965482cedccb587d4b7df6 Mon Sep 17 00:00:00 2001 From: clondin Date: Sun, 23 Aug 2026 10:49:27 -0700 Subject: [PATCH] feat(app): add liquid model-thinking animation --- app/src/components/channels/activity-bar.tsx | 5 +- .../components/channels/chat-transcript.tsx | 10 +- .../channels/liquid-thinking-orb.tsx | 304 +++++ .../channels/liquid-thinking-orb.wgsl | 1167 +++++++++++++++++ app/src/routes/_authed/_app/bot.tsx | 7 +- app/src/styles.css | 66 +- 6 files changed, 1511 insertions(+), 48 deletions(-) create mode 100644 app/src/components/channels/liquid-thinking-orb.tsx create mode 100644 app/src/components/channels/liquid-thinking-orb.wgsl diff --git a/app/src/components/channels/activity-bar.tsx b/app/src/components/channels/activity-bar.tsx index 39ecef9..1930472 100644 --- a/app/src/components/channels/activity-bar.tsx +++ b/app/src/components/channels/activity-bar.tsx @@ -1,5 +1,6 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useEffect, useState } from "react"; +import { LiquidThinkingOrb } from "@/components/channels/liquid-thinking-orb"; import { EASE_OUT } from "@/lib/motion"; /** @@ -62,9 +63,7 @@ export function AgentActivityBar({ ease: EASE_OUT, }} > - - - + {actionLabel ?? "Thinking"} diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index f80d784..98e1cf6 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -24,6 +24,7 @@ import { useState, } from "react"; import { Streamdown } from "streamdown"; +import { LiquidThinkingOrb } from "@/components/channels/liquid-thinking-orb"; import { Bubble, BubbleContent } from "@/components/ui/bubble"; import { MessageContent, @@ -218,13 +219,10 @@ function Thinking() { > {/* * The shimmer lives on the TEXT SPAN, not the paragraph: `background-clip: text` makes the - * paragraph's own colour transparent, and the orb sitting inside a transparent-text element - * is fine (it paints backgrounds, not glyphs) — but keeping them separate means neither - * effect has to know the other exists. + * paragraph's own colour transparent. Keeping the liquid canvas beside that span means the + * shader and the text treatment never have to know how the other paints. */} - - - + Thinking{seconds >= 5 ? ` · ${seconds}s` : ""} diff --git a/app/src/components/channels/liquid-thinking-orb.tsx b/app/src/components/channels/liquid-thinking-orb.tsx new file mode 100644 index 0000000..8daf386 --- /dev/null +++ b/app/src/components/channels/liquid-thinking-orb.tsx @@ -0,0 +1,304 @@ +import { useReducedMotion } from "motion/react"; +import { type ComponentProps, useEffect, useRef, useState } from "react"; + +// WebGPU is not yet part of TypeScript's DOM library. Keep this surface deliberately narrow: these +// are only the methods the supplied liquid-orb renderer uses. +interface WebGpuApi { + getPreferredCanvasFormat(): string; + requestAdapter(): Promise; +} + +interface WebGpuAdapter { + requestDevice(): Promise; +} + +interface WebGpuCompilationMessage { + lineNum: number; + linePos: number; + message: string; + type: string; +} + +interface WebGpuShaderModule { + getCompilationInfo(): Promise<{ messages: WebGpuCompilationMessage[] }>; +} + +interface WebGpuRenderPipeline { + getBindGroupLayout(index: number): unknown; +} + +interface WebGpuBuffer { + destroy(): void; +} + +interface WebGpuRenderPass { + draw(vertexCount: number): void; + end(): void; + setBindGroup(index: number, bindGroup: unknown): void; + setPipeline(pipeline: WebGpuRenderPipeline): void; +} + +interface WebGpuCommandEncoder { + beginRenderPass(descriptor: object): WebGpuRenderPass; + finish(): unknown; +} + +interface WebGpuDevice { + createBindGroup(descriptor: object): unknown; + createBuffer(descriptor: object): WebGpuBuffer; + createCommandEncoder(): WebGpuCommandEncoder; + createRenderPipeline(descriptor: object): WebGpuRenderPipeline; + createShaderModule(descriptor: { code: string }): WebGpuShaderModule; + lost: Promise; + queue: { + submit(commands: unknown[]): void; + writeBuffer( + buffer: WebGpuBuffer, + bufferOffset: number, + data: Float32Array, + ): void; + }; +} + +interface WebGpuCanvasContext { + configure(descriptor: { + alphaMode: "premultiplied"; + device: WebGpuDevice; + format: string; + }): void; + getCurrentTexture(): { createView(): unknown }; + unconfigure(): void; +} + +interface SharedRenderer { + device: WebGpuDevice; + format: string; + pipeline: WebGpuRenderPipeline; +} + +// WebGPUBufferUsage.UNIFORM and WebGPUBufferUsage.COPY_DST. Numeric flags avoid adding a package +// just for WebGPU globals while TypeScript's DOM library catches up with browsers. +const UNIFORM_BUFFER_USAGE = 0x40 | 0x08; + +// This is the exact preset supplied with the Liquid Orb reference. The first three slots are +// replaced per frame with canvas width, canvas height, and elapsed time. +const LIQUID_ORB_UNIFORM_SEED = [ + 1, 1, 0, 1.3200000524520874, 0.7200000286102295, 0.550000011920929, + 5.400000095367432, 0.7799999713897705, 4.199999809265137, 0.1599999964237213, + 0.36000001430511475, 0.2800000011920929, 0.20000000298023224, + 0.2199999988079071, 1.25, 11, 0.004999999888241291, 0, 0, 1, + 0.41999998688697815, 0.05000000074505806, 2, 0.41999998688697815, + 0.7699999809265137, 0.23000000417232513, 65, 0, 0, 1, 0.2199999988079071, + 0.25, 0.0235294122248888, 0.007843137718737125, 0.054901961237192154, 1, 0, + 0.6000000238418579, 1, 1, 0.14509804546833038, 0.545098066329956, 1, 1, + 0.07450980693101883, 0.4588235318660736, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0.09803921729326248, 0.3176470696926117, 0.7607843279838562, 1, 0, + 0.9137254953384399, 1, 1, 0.9176470637321472, 0.95686274766922, 1, 1, + 0.8627451062202454, 0.9176470637321472, 1, 1, 0.007843137718737125, + 0.003921568859368563, 0.019607843831181526, 1, 0, 0.6000000238418579, 1, 1, + 0.9686274528503418, 0.9843137264251709, 1, 1, 0.9372549057006836, + 0.9647058844566345, 0.9921568632125854, 1, 0.8784313797950745, + 0.9333333373069763, 0.9764705896377563, 1, 0.8313725590705872, + 0.9019607901573181, 0.9686274528503418, 1, 0.7333333492279053, + 0.8352941274642944, 0.9529411792755127, 1, 0.6509804129600525, + 0.7803921699523926, 0.9411764740943909, 1, 0.529411792755127, + 0.6901960968971252, 0.9215686321258545, 1, 0.43529412150382996, + 0.6196078658103943, 0.9098039269447327, 1, 0.43529412150382996, + 0.6196078658103943, 0.9098039269447327, 1, 0.43529412150382996, + 0.6196078658103943, 0.9098039269447327, 1, 0.43529412150382996, + 0.6196078658103943, 0.9098039269447327, 1, 0.43529412150382996, + 0.6196078658103943, 0.9098039269447327, 1, +] as const; + +let sharedRendererPromise: Promise | undefined; + +async function createSharedRenderer(): Promise { + const gpu = (navigator as Navigator & { gpu?: WebGpuApi }).gpu; + if (!gpu) return null; + + // Loading the 55KB shader is conditional on a browser both needing the indicator and supporting + // WebGPU. Adapter discovery and the code-split shader request are independent, so start together. + const [adapter, shaderSource] = await Promise.all([ + gpu.requestAdapter(), + import("./liquid-thinking-orb.wgsl?raw"), + ]); + if (!adapter) return null; + + const device = await adapter.requestDevice(); + const format = gpu.getPreferredCanvasFormat(); + const shader = device.createShaderModule({ code: shaderSource.default }); + const compilation = await shader.getCompilationInfo(); + const errors = compilation.messages.filter( + (message) => message.type === "error", + ); + if (errors.length > 0) { + throw new Error( + errors + .map( + (message) => + `${message.lineNum}:${message.linePos} ${message.message}`, + ) + .join("\n"), + ); + } + + const pipeline = device.createRenderPipeline({ + layout: "auto", + vertex: { module: shader, entryPoint: "vs_main" }, + fragment: { + module: shader, + entryPoint: "fs_main", + targets: [{ format }], + }, + primitive: { topology: "triangle-list" }, + }); + + // A lost device cannot serve a later thinking cursor. Let the next mount ask for a fresh one. + void device.lost.then(() => { + sharedRendererPromise = undefined; + }); + + return { device, format, pipeline }; +} + +function getSharedRenderer(): Promise { + sharedRendererPromise ??= createSharedRenderer().catch(() => null); + return sharedRendererPromise; +} + +/** + * The supplied liquid-glass animation, shared by every surface that means "the model is working". + * + * Each visible orb owns only a tiny canvas, uniform buffer, and render loop. The device, compiled + * shader, and pipeline are shared across instances. Browsers without WebGPU keep the CSS-painted + * glass fallback; reduced-motion users receive one still WebGPU frame instead of a loop. + */ +export function LiquidThinkingOrb({ + className, + ...props +}: ComponentProps<"canvas">) { + const canvasRef = useRef(null); + const shouldReduceMotion = useReducedMotion(); + const [rendererReady, setRendererReady] = useState(false); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const canvasElement: HTMLCanvasElement = canvas; + + let animationFrame = 0; + let buffer: WebGpuBuffer | null = null; + let context: WebGpuCanvasContext | null = null; + let stopped = false; + + async function start() { + const renderer = await getSharedRenderer(); + if (stopped || !renderer) return; + + const webGpuCanvas = canvasElement as HTMLCanvasElement & { + getContext(contextId: "webgpu"): WebGpuCanvasContext | null; + }; + context = webGpuCanvas.getContext("webgpu"); + if (!context) return; + + context.configure({ + device: renderer.device, + format: renderer.format, + alphaMode: "premultiplied", + }); + + const values = new Float32Array(LIQUID_ORB_UNIFORM_SEED); + buffer = renderer.device.createBuffer({ + size: values.byteLength, + usage: UNIFORM_BUFFER_USAGE, + }); + const bindGroup = renderer.device.createBindGroup({ + layout: renderer.pipeline.getBindGroupLayout(0), + entries: [{ binding: 0, resource: { buffer } }], + }); + const startedAt = performance.now(); + + const renderFrame = (now: number) => { + if (stopped || !context || !buffer) return false; + + try { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const width = Math.max( + 1, + Math.floor(canvasElement.clientWidth * dpr), + ); + const height = Math.max( + 1, + Math.floor(canvasElement.clientHeight * dpr), + ); + if ( + canvasElement.width !== width || + canvasElement.height !== height + ) { + canvasElement.width = width; + canvasElement.height = height; + } + + values[0] = width; + values[1] = height; + values[2] = (now - startedAt) / 1000; + renderer.device.queue.writeBuffer(buffer, 0, values); + + const encoder = renderer.device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: context.getCurrentTexture().createView(), + clearValue: { r: 0, g: 0, b: 0, a: 0 }, + loadOp: "clear", + storeOp: "store", + }, + ], + }); + pass.setPipeline(renderer.pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); + renderer.device.queue.submit([encoder.finish()]); + return true; + } catch { + return false; + } + }; + + const tick = (now: number) => { + if (!renderFrame(now)) { + if (!stopped) setRendererReady(false); + return; + } + animationFrame = requestAnimationFrame(tick); + }; + + if (!renderFrame(startedAt)) return; + if (!stopped) setRendererReady(true); + if (shouldReduceMotion !== true) { + animationFrame = requestAnimationFrame(tick); + } + } + + void start().catch(() => { + if (!stopped) setRendererReady(false); + }); + return () => { + stopped = true; + cancelAnimationFrame(animationFrame); + buffer?.destroy(); + context?.unconfigure(); + }; + }, [shouldReduceMotion]); + + return ( + + ); +} diff --git a/app/src/components/channels/liquid-thinking-orb.wgsl b/app/src/components/channels/liquid-thinking-orb.wgsl new file mode 100644 index 0000000..3fb53c1 --- /dev/null +++ b/app/src/components/channels/liquid-thinking-orb.wgsl @@ -0,0 +1,1167 @@ +// Glass Liquid — curated flow programs with an optional glass shell. +// +// The local presets use independent spatial models for Siri-like sheets, +// symmetric colour waves, aurora curtains, frost flow, neural interference, +// liquid chrome, opal interference, a voice membrane, a blue liquid drop, and +// a violet molten core, plus a chromatic brushed-metal field. The legacy liquid +// bank remains below for compatibility with older shared shader +// code, but is not exposed as an editor preset. +// +// When enabled, the shell uses a signed-distance refraction profile around the +// boundary, asymmetric spectral separation, and two directional edge lights. +// The fluid is resampled through that profile, so glass changes the image rather +// than covering it with a translucent white face. +// +// --------------------------------------------------------------------------- +// Analytic optical diffusion without a convolution. +// --------------------------------------------------------------------------- +// +// The source used a thirteen-tap 5px frost blur. This port keeps one fluid +// evaluation and applies the equivalent gaussian in the frequency domain: +// +// 1. **Per-octave attenuation, inside `lqFbm`.** Convolving with a gaussian of +// sigma σ scales a component at wavenumber k by exp(-k²σ²/2). An fbm's +// octaves have known wavenumbers — octave i sits at 2.03^i times the base — +// so each octave's amplitude is scaled by its own factor and the field is +// sampled once. The mean is untouched (a blur preserves it), so only the +// deviation from 0.5 is scaled and the `s / m` normaliser is unchanged. +// Every caller passes the diffusion sigma in its own input units, so detail +// attenuation continues to track `zoom`. +// +// 2. **Value-space quadrature at every pointwise nonlinearity.** This is the +// part that is easy to get wrong. `blur(ridge(f))` is not `ridge(blur(f))`: +// attenuating first and ridging after leaves filaments thin and hard where +// the blur should have spread them, which is exactly how the earlier +// analytic-edge version failed. So `lqFbm` also returns the standard +// deviation of the detail the attenuation removed — within a gaussian +// window an octave scaled by β contributes variance ∝ (1 - β²), NOT +// (1 - β)² — and every nonlinearity applied to that field integrates it +// back out with a three-point Gauss-Hermite rule (exact through the fourth +// moment). Three evaluations of a function of one float, not three +// evaluations of the noise. `lqRidgeS`/`lqStepS`/`lqPowS` below; Nectar's +// branch has the fbm inside a `sin`, where the same integral is closed-form +// (E[sin(A + cε)] = sin A · exp(-c²σ²/2)), so it damps the sine instead. +// +// 3. **One continuous disc edge.** The fluid always reaches the sphere +// boundary. Glass changes its sample coordinates near that boundary, so +// toggling the shell cannot reveal a second hard-clipped silhouette. +// +// Deliberately NOT ported, and why: +// - The liquid grain. It sits below display-pixel scale and adds noise rather +// than useful optical detail, so Glass Liquid has no Grain parameter. +// - The two contact-shadow ellipses under the ball and its outer +// `0 26px 50px -24px` drop shadow. The Orbs family cut the source app's +// floor at the user's request, and the export paints over `Color.black`. +// +// Scalar controls are packed after `time`; the colour bank starts on the next +// 16-byte boundary. The TypeScript writer mirrors this order exactly. +struct Uniforms { + size: vec2, + time: f32, + speed: f32, + radius: f32, + zoom: f32, + warp: f32, + ridgeAmt: f32, + sharp: f32, + shade: f32, + sheen: f32, + gloss: f32, + shellMidAlpha: f32, + shellEdgeAlpha: f32, + exposure: f32, + style: f32, + edgeSoftness: f32, + edgeGlow: f32, + paletteCount: f32, + glassEnabled: f32, + glassOpacity: f32, + contourDeform: f32, + bandDensity: f32, + chromaticShift: f32, + metalScale: f32, + metalStretch: f32, + metalAngle: f32, + metalOffset: f32, + metalPhase: f32, + metalEvolution: f32, + metalRoughness: f32, + metalDepth: f32, + colorA: vec4, + colorB: vec4, + colorC: vec4, + colorD: vec4, + highlightColor: vec4, + shellInner: vec4, + shellMid: vec4, + shellEdge: vec4, + sheenColor: vec4, + specColor: vec4, + canvasColor: vec4, + glowColor: vec4, + paletteStop0: vec4, + paletteStop1: vec4, + paletteStop2: vec4, + paletteStop3: vec4, + paletteStop4: vec4, + paletteStop5: vec4, + paletteStop6: vec4, + paletteStop7: vec4, + paletteStop8: vec4, + paletteStop9: vec4, + paletteStop10: vec4, + paletteStop11: vec4, +}; +@group(0) @binding(0) var u: Uniforms; + +// ── The Orbs edge bank (WGSL) ─────────────────────────────────────────────── +// Two knobs every orb on the shelf carries: how soft its limb is, and how far +// it glows past it. See effects/_shared/edge.ts for the contract. +// +// THREE files must agree — edge.wgsl, edge.metal, edge.sksl. Change one, change +// all three, or the Code tab starts lying about what it ships. + +// How much wider than the shipped feather the Edge softness slider is asking +// for. 0.005 is the width every orb was authored with, so this returns exactly +// 0 at the default and every edge expression collapses to the constant it +// replaced — the defaults are bit-identical to the render before the bank. +fn mfEdgeD(soft: f32) -> f32 { + return soft - 0.005; +} + +// The halo an orb throws past its own limb. +// +// ADDED, never subtracted: whatever the orb already paints out there — a +// studio wall, its own exp() bleed, the sheet's cones — survives untouched. +// That is what lets this be adopted by seventeen shaders whose backdrops have +// nothing in common. +// +// `glow == 0` returns `col` by an early exit rather than by adding zero. Both +// are exact, but the exit also skips the length() on the ~60% of the frame +// outside the ball, and 0 is the default. +fn mfEdgeGlow(col: vec3, uv: vec2, ctr: vec2, rad: f32, + soft: f32, glow: f32, glowRGB: vec3) -> vec3 { + if (glow <= 0.0) { return col; } + let r = length(uv - ctr); + // Fenced to the outside of the limb by the same softness the limb uses, so + // the halo starts where the ball stops however soft that boundary is. Without + // it the exp() is 1 across the whole disc and washes the face flat. + let outside = smoothstep(rad - max(soft, 0.0005), rad + max(soft, 0.0005), r); + return col + glowRGB * (glow * exp(-max(r - rad, 0.0) * 11.0) * outside); +} + + +// ── The Orbs palette-ramp bank (WGSL) ─────────────────────────────────────── +// The add/remove colour list, evaluated INSIDE the shader so every stop paints +// its own region of the ball instead of being averaged into a role colour. +// See effects/_shared/ramp.ts for the contract. +// +// THREE files must agree — ramp.wgsl, ramp.metal, ramp.sksl. Change one, change +// all three, or the Code tab starts lying about what it ships. + +// One stop, picked without a dynamic array index. +// +// A `var` array indexed by a runtime value is the shape that spills to scratch +// memory on the GPUs this project cares about (PERFORMANCE.md); twelve selects +// stay in registers and are branchless on every backend. Written once here so +// no adopting shader has to. +fn mfRampPick(idx: f32, + s0: vec3, s1: vec3, s2: vec3, s3: vec3, + s4: vec3, s5: vec3, s6: vec3, s7: vec3, + s8: vec3, s9: vec3, s10: vec3, s11: vec3) -> vec3 { + var r = s0; + r = select(r, s1, idx == 1.0); + r = select(r, s2, idx == 2.0); + r = select(r, s3, idx == 3.0); + r = select(r, s4, idx == 4.0); + r = select(r, s5, idx == 5.0); + r = select(r, s6, idx == 6.0); + r = select(r, s7, idx == 7.0); + r = select(r, s8, idx == 8.0); + r = select(r, s9, idx == 9.0); + r = select(r, s10, idx == 10.0); + r = select(r, s11, idx == 11.0); + return r; +} + +// The CYCLIC ramp: `t` wraps, and the last stop runs back into the first. +// +// This is the one a generated-colour orb wants. Prism's hue comes from a cosine +// of an unbounded scalar field, so its colour has always been periodic — a +// clamped ramp would flatten every band past t == 1 into one colour and throw +// the banding away. Wrapping keeps the field's structure exactly and only swaps +// what the structure is *coloured* with. +// +// NOT ONE BRANCH IN HERE, and that is load-bearing rather than tidy. An orb +// evaluates this next to a `fract(sin(x) * 43758.5453)` grain hash, which +// amplifies a last-bit change in its argument by ~44000x. Any `if` in this file +// or at a call site splits the fragment's basic block, the compiler stops +// folding `uv / rad` into its uses, and the hash turns that into speckle up to +// 33/255 — measured, on exactly the first cut of this bank. Straight-line code +// keeps the untouched render bit-identical. Same reasoning as the early-out +// guards every orb carries; see the note in orb-prism.wgsl. +fn mfRampCyc(tIn: f32, n: f32, + s0: vec3, s1: vec3, s2: vec3, s3: vec3, + s4: vec3, s5: vec3, s6: vec3, s7: vec3, + s8: vec3, s9: vec3, s10: vec3, s11: vec3) -> vec3 { + let k = clamp(floor(n + 0.5), 1.0, 12.0); + let x = fract(tIn) * k; + let i0 = min(floor(x), k - 1.0); + let i1 = select(i0 + 1.0, 0.0, i0 + 1.0 >= k); // the wrap + return mix(mfRampPick(i0, s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11), + mfRampPick(i1, s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11), + x - i0); +} + +// The CLAMPED ramp: stop 0 at t == 0, the last stop at t == 1, held outside. +// +// This is the one an orb with an authored dark→light body ramp wants — the +// four-stop Deep/Mid/Surge/Crest shape, where the ends really are ends. +// +// Branchless for the same reason as `mfRampCyc`. The single-stop case falls out +// of the arithmetic rather than needing an early return: k == 1 makes the span +// zero, so x is 0, i0 is 0 and the mix weight is 0 — s0, exactly. +fn mfRampLin(tIn: f32, n: f32, + s0: vec3, s1: vec3, s2: vec3, s3: vec3, + s4: vec3, s5: vec3, s6: vec3, s7: vec3, + s8: vec3, s9: vec3, s10: vec3, s11: vec3) -> vec3 { + let k = clamp(floor(n + 0.5), 1.0, 12.0); + let x = clamp(tIn, 0.0, 1.0) * (k - 1.0); + let i0 = clamp(floor(x), 0.0, max(k - 2.0, 0.0)); + return mix(mfRampPick(i0, s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11), + mfRampPick(i0 + 1.0, s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11), + x - i0); +} + +// ── The ramp as ONE value ─────────────────────────────────────────────────── +// +// Thirteen uniforms is a reasonable thing for a shader to hold and a terrible +// thing for a helper to take. Several orbs make their body colour deep inside +// one — Glass·Liquid's fluid, the studio orbs' environment mirrors — and in the +// MSL these files are transcribed against, a helper cannot read the stitchable +// entry point's arguments, so the palette has to be handed down. Bundled like +// this that is one parameter instead of thirteen, and the three languages stay +// line-for-line. +// +// The stops come back out by CONSTANT index only, so this is still not a +// dynamically indexed array and still cannot spill to scratch memory. +struct MfRamp { + n: f32, + s0: vec3, s1: vec3, s2: vec3, s3: vec3, + s4: vec3, s5: vec3, s6: vec3, s7: vec3, + s8: vec3, s9: vec3, s10: vec3, s11: vec3, +}; + +fn mfRampOf(n: f32, + s0: vec3, s1: vec3, s2: vec3, s3: vec3, + s4: vec3, s5: vec3, s6: vec3, s7: vec3, + s8: vec3, s9: vec3, s10: vec3, s11: vec3) -> MfRamp { + return MfRamp(n, s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11); +} + +fn mfRampCycR(t: f32, r: MfRamp) -> vec3 { + return mfRampCyc(t, r.n, r.s0, r.s1, r.s2, r.s3, r.s4, r.s5, + r.s6, r.s7, r.s8, r.s9, r.s10, r.s11); +} + +fn mfRampLinR(t: f32, r: MfRamp) -> vec3 { + return mfRampLin(t, r.n, r.s0, r.s1, r.s2, r.s3, r.s4, r.s5, + r.s6, r.s7, r.s8, r.s9, r.s10, r.s11); +} + + +// Fluid geometry, in ball radii (|p| == 1 on the ball's edge, y up). +const GL_FU: f32 = 0.88172043; // canvas half-side = 0.82/0.93 R + +// Pure fluid keeps tighter diffusion; enabling glass restores the source's 5px +// frosted diffusion inside the inset shell. +const GL_BSIG_CLEAR: f32 = 0.01800000; +const GL_BSIG_GLASS: f32 = 0.03990000; + +// --- the three constants the frequency-domain blur is fitted on ------------- +// A gaussian's response is exp(-k²σ²/2), so GL_KA is k²/2 for the wavenumber +// where smoothstep-interpolated value noise actually keeps its energy. The +// textbook choice — one cycle per noise cell, k = 2π, GL_KA = 19.74 — blurs too +// hard, because the smoothstep interpolation is itself a low-pass and pulls the +// effective k down to about 3.5. Fitted against the 13-tap render. +const GL_KA: f32 = 6.0; +// (2.03)² — how σ grows, in its own octave's cells, from one octave to the next. +const GL_KG: f32 = 4.1209; +// The warp field displaces the fluid rather than colouring it, so blurring the +// image does not attenuate it as strongly as the model says. Also fitted. +const GL_KWA: f32 = 0.5; +// One value-noise octave's standard deviation about its own mean, as a fraction +// of its range — the scale that turns "amplitude the attenuation removed" into +// "how far the removed detail typically pushed the value". +const GL_KR: f32 = 0.32; +const GL_GH: f32 = 1.73205081; // sqrt(3), the 3-point Gauss-Hermite abscissa + +// Pure fluid reaches the ball edge. +const GL_CLEAR_EA: f32 = 0.995; +const GL_CLEAR_EB: f32 = 1.04; + +// --------------------------------------------------------------------------- +// The sheet's liquid noise bank. Five octaves, gain .5, normalised by the +// weight sum, and rotated every octave. This is NOT the bank the sheet's Prism +// screen uses (a different hash, gain .55, unnormalised, no rotation). +// --------------------------------------------------------------------------- +fn lqHash(pIn: vec2) -> f32 { + var p = fract(pIn * vec2(123.34, 456.21)); + p = p + vec2(dot(p, p + vec2(45.32))); + return fract(p.x * p.y); +} + +fn lqNoise(p: vec2) -> f32 { + let i = floor(p); + var f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(lqHash(i), lqHash(i + vec2(1.0, 0.0)), f.x), + mix(lqHash(i + vec2(0.0, 1.0)), lqHash(i + vec2(1.0, 1.0)), f.x), f.y); +} + +// The fbm, pre-blurred. `bs` is the blur's sigma expressed in THIS call's input +// units — the caller scales it by whatever it scaled the domain by. Returns +// `.x` the attenuated value and `.y` the standard deviation of the detail the +// attenuation took out, which is what a following nonlinearity has to integrate +// over. Both are exact for a gaussian window: the surviving amplitude is β and +// the variance that leaves is (1 - β²), per octave, weighted by that octave's +// own share of the normalised sum. +fn lqFbm(pIn: vec2, bs: f32) -> vec2 { + var p = pIn; + var s: f32 = 0.0; + var a: f32 = 0.5; + var m: f32 = 0.0; + var vr: f32 = 0.0; + let e = -GL_KA * bs * bs; + var g: f32 = 1.0; + for (var i: i32 = 0; i < 5; i = i + 1) { + let b = exp(e * g); + s = s + a * (0.5 + b * (lqNoise(p) - 0.5)); + vr = vr + a * a * (1.0 - b * b); + m = m + a; + a = a * 0.5; + g = g * GL_KG; + // GLSL's mat2(.8,.6,-.6,.8) is COLUMN-major — columns (.8,.6) and + // (-.6,.8) — so the product is written out rather than constructed. + p = vec2(0.8 * p.x - 0.6 * p.y, 0.6 * p.x + 0.8 * p.y) * 2.03; + } + return vec2(s / m, GL_KR * sqrt(vr) / m); +} + +fn lqRidge(v: f32, k: f32) -> f32 { + return pow(clamp(1.0 - abs(v * 2.0 - 1.0), 0.0, 1.0), k); +} + +// The sheet's four-stop ramp, shared by every branch of every program. +fn lqRamp(v: f32, cA: vec3, cB: vec3, cC: vec3, cD: vec3) -> vec3 { + var c = mix(cA, cB, smoothstep(0.0, 0.45, v)); + c = mix(c, cC, smoothstep(0.38, 0.72, v)); + c = mix(c, cD, smoothstep(0.68, 1.0, v)); + // The editor's four colours are the default ramp. An optional custom palette + // can replace them without changing the scalar field that produces `v`. + return select(c, mfRampLin(v, u.paletteCount, + u.paletteStop0.rgb, u.paletteStop1.rgb, u.paletteStop2.rgb, + u.paletteStop3.rgb, u.paletteStop4.rgb, u.paletteStop5.rgb, + u.paletteStop6.rgb, u.paletteStop7.rgb, u.paletteStop8.rgb, + u.paletteStop9.rgb, u.paletteStop10.rgb, u.paletteStop11.rgb), u.paletteCount > 0.5); +} + +// --------------------------------------------------------------------------- +// The three nonlinearities the fluid applies to a pre-blurred field, each +// integrated over the detail `lqFbm` attenuated away. Three-point +// Gauss-Hermite — nodes 0 and ±sqrt(3)·sd, weights 4/6 and 1/6 — reproduces a +// gaussian's second AND fourth moments, which is what keeps a ridged filament +// spreading as it dims instead of just dimming. `vs` is an `lqFbm` result: +// `.x` the value, `.y` that standard deviation. +// --------------------------------------------------------------------------- +fn lqRidgeS(vs: vec2, k: f32) -> f32 { + let d = GL_GH * vs.y; + return (lqRidge(vs.x - d, k) + 4.0 * lqRidge(vs.x, k) + lqRidge(vs.x + d, k)) / 6.0; +} + +fn lqStepS(vs: vec2, a: f32, b: f32) -> f32 { + let d = GL_GH * vs.y; + return (smoothstep(a, b, vs.x - d) + 4.0 * smoothstep(a, b, vs.x) + + smoothstep(a, b, vs.x + d)) / 6.0; +} + +fn lqPowS(vs: vec2, k: f32) -> f32 { + let d = GL_GH * vs.y; + return (pow(clamp(vs.x - d, 0.0, 1.0), k) + 4.0 * pow(clamp(vs.x, 0.0, 1.0), k) + + pow(clamp(vs.x + d, 0.0, 1.0), k)) / 6.0; +} + +// --------------------------------------------------------------------------- +// Curated local flow programs. Each preset owns a different spatial model; +// colour changes are secondary to silhouette, frequency, and motion structure. +// --------------------------------------------------------------------------- + +fn glsFinishPresetFluid(colorIn: vec3, p: vec2) -> vec3 { + var color = colorIn; + color = mix(color, u.highlightColor.rgb, + u.shade * 0.22 * smoothstep(0.15, 1.15, dot(p, vec2(-0.32, 0.78)))); + color = color * (1.0 - u.shade * 0.34 + * smoothstep(-0.1, 1.2, dot(p, vec2(0.45, -0.62)))); + color = color * (1.0 - u.shade * 0.22 * smoothstep(0.72, 1.08, length(p))); + return clamp(color, vec3(0.0), vec3(1.0)); +} + +fn glsSiriBand(q: vec2, drift: f32, phaseOffset: f32, amplitude: f32, + mainY: f32, envelope: f32, softness: f32) -> vec2 { + let y = amplitude * envelope * sin(q.x * 1.0 + drift + phaseOffset); + let distanceToLine = abs(q.y - y); + let line = 0.018 / (sqrt(distanceToLine * distanceToLine + softness * softness) + 0.026); + let bandDistance = max(0.0, max(q.y - max(mainY, y), min(mainY, y) - q.y)); + let band = 0.018 / (bandDistance + 0.075); + return vec2(line, band); +} + +fn glsSiriFluid(p: vec2, t: f32) -> vec3 { + // The reference wave is a main sinusoid plus four chromatically separated + // waves. Their enclosed bands carry colour while the shared crest stays hot. + let scale = 0.74 + u.zoom * 0.34; + let q = p / scale; + let xNorm = q.x; + let envelopeBase = cos(1.57079633 * min(abs(0.9 * xNorm), 1.0)); + let envelope = envelopeBase * envelopeBase; + let low = 0.5 + 0.5 * cos(t * 0.37); + let mid = 0.5 + 0.5 * sin(t * 0.51 + 1.2); + let high = 0.5 + 0.5 * cos(t * 0.73 + 2.1); + let drift = t * 2.4; + let mainAmplitude = 0.25 + u.ridgeAmt * 0.075 + low * 0.018; + let bandAmplitude = mainAmplitude + mid * 0.025 + high * 0.018; + let mainY = mainAmplitude * envelope * sin(q.x * 1.1 + drift); + let separation = 1.85 + u.warp * 0.2 + mid * 0.28; + let softness = 0.035 + (1.0 - u.ridgeAmt) * 0.018 + mid * 0.006; + + let band0 = glsSiriBand(q, drift, -separation, bandAmplitude, mainY, envelope, softness); + let band1 = glsSiriBand(q, drift, -separation * 0.34, bandAmplitude, mainY, envelope, softness); + let band2 = glsSiriBand(q, drift, separation * 0.34, bandAmplitude, mainY, envelope, softness); + let band3 = glsSiriBand(q, drift, separation, bandAmplitude, mainY, envelope, softness); + let w0 = band0.x + band0.y; + let w1 = band1.x + band1.y; + let w2 = band2.x + band2.y; + let w3 = band3.x + band3.y; + let total = w0 + w1 + w2 + w3; + let dominant0 = w0 * w0; + let dominant1 = w1 * w1; + let dominant2 = w2 * w2; + let dominant3 = w3 * w3; + let dominantTotal = dominant0 + dominant1 + dominant2 + dominant3; + let spectral = (u.colorA.rgb * dominant0 + u.colorC.rgb * dominant1 + + u.colorB.rgb * dominant2 + u.colorD.rgb * dominant3) + / max(dominantTotal, 0.0001); + let energy = (1.0 - exp(-total * 0.58)) * envelope; + let mainDistance = abs(q.y - mainY); + let whiteCore = exp(-mainDistance * mainDistance / 0.0028) * envelope; + let atmosphere = mix(u.colorD.rgb, u.colorB.rgb, + smoothstep(-0.7, 0.7, q.y)) * 0.018; + var color = atmosphere + spectral * energy * 1.14; + color = color + u.highlightColor.rgb * whiteCore * (0.18 + 0.1 * low); + color = color / (vec3(1.0) + color * 0.18); + return glsFinishPresetFluid(color, p); +} + +fn glsSpectrumHeight(q: vec2, t: f32, frequency: f32, + phaseOffset: f32, amplitude: f32) -> f32 { + let x = q.x * 2.15; + let envelope = pow(4.0 / (4.0 + x * x), 4.0); + let breathing = 0.82 + 0.18 * sin(t * 0.48 + phaseOffset * 0.7); + let wave = abs(sin(frequency * x - t * 1.36 + phaseOffset)); + return envelope * amplitude * breathing * (0.28 + 0.72 * wave); +} + +fn glsSpectrumLayer(q: vec2, height: f32, softness: f32) -> f32 { + return (1.0 - smoothstep(max(height - softness, 0.0), height + softness, abs(q.y))) + * smoothstep(0.0, 0.045, height); +} + +fn glsSpectrumFluid(p: vec2, t: f32) -> vec3 { + // Three symmetric filled wave surfaces orbit a persistent support line. This + // keeps the iOS 9 voice-field silhouette without depending on canvas strokes. + let scale = 0.74 + u.zoom * 0.34; + let q = p / scale; + let amplitude = 0.26 + u.ridgeAmt * 0.27; + let frequency = 0.72 + u.warp * 0.095; + let softness = 0.026 + (1.0 - u.ridgeAmt) * 0.032; + let h0 = glsSpectrumHeight(q, t, frequency * 0.82, -1.2, amplitude * 0.72); + let h1 = glsSpectrumHeight(q, t, frequency, 0.45, amplitude); + let h2 = glsSpectrumHeight(q, t, frequency * 1.17, 2.05, amplitude * 0.82); + let l0 = glsSpectrumLayer(q, h0, softness); + let l1 = glsSpectrumLayer(q, h1, softness); + let l2 = glsSpectrumLayer(q, h2, softness); + let spectrumX = q.x * 2.15; + let envelope = pow(4.0 / (4.0 + spectrumX * spectrumX), 4.0); + let support = exp(-q.y * q.y / 0.00072) * envelope; + let total = l0 + l1 + l2; + let spectral = (u.colorB.rgb * l0 + u.colorC.rgb * l1 + u.colorD.rgb * l2) + / max(total, 0.001); + var color = u.colorD.rgb * 0.025 + spectral * (1.0 - exp(-total * 0.86)); + color = color + u.colorA.rgb * support * 0.58; + color = color / (vec3(1.0) + color * 0.2); + return glsFinishPresetFluid(color, p); +} + +fn glsAuroraLayer(p: vec2, t: f32, offset: f32) -> f32 { + let drift = t * 0.18 + offset * 2.5; + let wave1 = sin(p.x * (2.0 + u.warp * 0.13) + drift + offset * 6.0) * 0.25; + let wave2 = sin(p.x * 3.7 + drift * 1.3 + offset * 4.0) * 0.12; + let wave3 = sin(p.x * 7.2 + drift * 0.7 + offset * 8.0) * 0.055; + let noiseValue = lqFbm(vec2(p.x * 1.6 + drift * 0.35, + p.y * 0.8 + offset * 3.0), 0.018).x; + let center = offset * 0.46 + wave1 + wave2 + wave3 + + (noiseValue - 0.5) * 0.28; + let dist = abs(p.y - center); + let glow = exp(-dist * dist * (13.0 - 5.0 * u.ridgeAmt)); + let shimmer = lqFbm(vec2(p.x * 4.0 + t * 0.22, + p.y * 7.0 + offset * 5.0), 0.012).x; + return glow * (0.64 + 0.36 * shimmer); +} + +fn glsAuroraFluid(p: vec2, t: f32) -> vec3 { + let q = p * (0.82 + u.zoom * 0.58); + let l0 = glsAuroraLayer(q, t, -0.72); + let l1 = glsAuroraLayer(q, t, 0.0); + let l2 = glsAuroraLayer(q, t, 0.72); + var color = u.colorA.rgb * (0.46 + 0.18 * (q.y + 1.0)); + color = color + u.colorB.rgb * l0 * 1.3; + color = color + u.colorC.rgb * l1 * 1.15; + color = color + u.colorD.rgb * l2 * 1.2; + color = color + mix(u.colorB.rgb, u.colorD.rgb, 0.5) * min(l0 * l2, l1) * 0.65; + + let starUv = (q + vec2(1.0)) * 18.0; + let starCell = floor(starUv); + let starHash = lqHash(starCell); + let starPoint = exp(-dot(fract(starUv) - vec2(0.5), + fract(starUv) - vec2(0.5)) * 90.0); + let stars = step(0.965, starHash) * starPoint + * (0.55 + 0.45 * sin(t * (1.0 + starHash * 2.0) + starHash * 6.28)); + color = color + u.highlightColor.rgb * stars * (1.0 - clamp(l0 + l1 + l2, 0.0, 1.0)); + color = color / (vec3(1.0) + color * 0.28); + return glsFinishPresetFluid(color, p); +} + +fn glsRotate(p: vec2, angle: f32) -> vec2 { + let c = cos(angle); + let s = sin(angle); + return vec2(c * p.x - s * p.y, s * p.x + c * p.y); +} + +fn glsNeuroShape(pIn: vec2, t: f32) -> f32 { + var p = pIn * (0.34 + 0.08 * u.zoom); + var sineAccum = vec2(0.0); + var result = vec2(0.0); + var scale = 8.0; + for (var j: i32 = 0; j < 11; j = j + 1) { + p = glsRotate(p, 1.0); + sineAccum = glsRotate(sineAccum, 1.0); + let layer = p * scale + vec2(f32(j)) + sineAccum - vec2(t * 0.34); + sineAccum = sineAccum + sin(layer); + result = result + (vec2(0.5) + 0.5 * cos(layer)) / scale; + scale = scale * 1.16; + } + return result.x + result.y; +} + +fn glsPlasmaFluid(p: vec2, t: f32) -> vec3 { + let shape = glsNeuroShape(p, t); + let phase = shape * (10.0 + u.warp) + p.x * 1.7 - p.y * 1.3 - t * 0.52; + let ridgeWidth = 0.62 - 0.24 * u.ridgeAmt; + let primary = pow(abs(cos(phase)), max(1.3, u.sharp * ridgeWidth)); + let secondary = pow(abs(cos(phase * 0.53 + atan2(p.y, p.x) * 2.0 + t * 0.21)), + max(1.6, u.sharp * (ridgeWidth + 0.1))); + let filaments = max(primary, secondary * 0.64); + let core = pow(primary, 4.0); + let polarity = 0.5 + 0.5 * sin(phase * 0.37 + shape * 3.0); + var color = mix(u.colorA.rgb * 0.42, u.colorD.rgb * 0.48, polarity * 0.46); + color = mix(color, u.colorB.rgb, filaments * 0.72); + color = mix(color, u.colorC.rgb, core * 0.68); + color = color + u.highlightColor.rgb * pow(core, 3.0) * 0.16; + color = color / (vec3(1.0) + color * 0.34); + return glsFinishPresetFluid(color, p); +} + +fn glsChromeFluid(p: vec2, t: f32) -> vec3 { + var q = p * (1.0 + u.zoom * 0.35); + let amplitude = 0.028 * u.warp; + for (var i: i32 = 1; i <= 9; i = i + 1) { + let fi = f32(i); + q.x = q.x + amplitude / fi * cos(fi * 2.7 * q.y + t * 0.46); + q.y = q.y + amplitude / fi * cos(fi * 3.1 * q.x - t * 0.4); + } + let denominator = max(abs(sin(t * 0.24 - q.y - q.x)), 0.045); + let flare = clamp(1.0 / denominator, 0.0, 18.0); + let metal = smoothstep(1.15, 7.5, flare); + let fold = 0.5 + 0.5 * cos((q.x - q.y) * (3.2 + u.sharp * 0.28) + t * 0.32); + let value = clamp(metal * 0.74 + fold * 0.36, 0.0, 1.0); + var color = lqRamp(value, u.colorD.rgb, u.colorC.rgb, u.colorB.rgb, u.colorA.rgb); + color = mix(color, u.colorA.rgb, pow(metal, 5.0) * 0.62); + return glsFinishPresetFluid(color, p); +} + +fn glsChromaticMetalPhase(p: vec2, t: f32) -> f32 { + let angle = u.metalAngle * 0.01745329252; + let scale = max(u.metalScale, 0.05); + let stretch = mix(0.48, 1.58, clamp(u.metalStretch, 0.0, 1.0)); + var q = glsRotate(p / scale, angle); + q = vec2(q.x / stretch, q.y * stretch); + + // The reference advances continuously while local reflections evolve out of + // phase. Travelling domain waves provide that deformation without rotating + // the entire pattern as one rigid layer. Integer harmonics keep a clean loop. + let cycle = t * 0.46 + u.metalPhase * 6.28318530718; + let evolution = clamp(u.metalEvolution, 0.0, 2.0); + q.x = q.x + sin(q.y * 1.86 - cycle) * 0.095 * evolution; + q.x = q.x + sin((q.x + q.y) * 1.28 + cycle * 2.0 + 1.4) * 0.045 * evolution; + q.y = q.y + sin(q.x * 1.52 + cycle + 0.8) * 0.07 * evolution; + + let repeats = max(u.bandDensity, 1.0); + return q.x * repeats * 2.18 + + sin(q.y * (1.3 + repeats * 0.26) - cycle) * 0.56 * evolution + + sin((q.x - q.y) * 1.34 + cycle * 2.0 + 1.7) * 0.27 * evolution + + sin((q.x * 0.72 + q.y) * 2.1 - cycle * 3.0 + 0.35) * 0.11 * evolution + + sin(cycle) * 0.1 + + sin(cycle * 3.0 + 0.7) * 0.035 + + cycle + + u.metalOffset * 6.28318530718; +} + +fn glsChromaticMetalTone(phase: f32) -> f32 { + let wave = 0.5 + 0.5 * cos(phase); + let roughness = clamp(u.metalRoughness, 0.0, 1.0); + let depth = clamp(u.metalDepth, 0.0, 1.0); + let edge = 0.025 + roughness * 0.18; + let broadReflection = smoothstep(0.5 - edge, 0.5 + edge, wave); + let hardReflection = pow(wave, mix(13.0, 4.0, roughness)); + let blackFold = pow(1.0 - wave, mix(9.0, 3.0, roughness)); + let body = mix(wave, broadReflection, 0.2 + depth * 0.3); + return clamp(0.018 + body * (0.46 + depth * 0.12) + + hardReflection * (0.3 + depth * 0.42) + - blackFold * (0.07 + depth * 0.11), 0.0, 1.0); +} + +fn glsChromaticMetalSample(p: vec2, t: f32) -> vec3 { + let phase = glsChromaticMetalPhase(p, t); + let angle = u.metalAngle * 0.01745329252; + let brushP = glsRotate(p / max(u.metalScale, 0.05), angle); + let brushed = sin(brushP.y * 146.0 + sin(brushP.x * 11.0) * 0.58) + + 0.48 * sin(brushP.y * 317.0 - brushP.x * 5.0); + let brushAmount = 0.004 + clamp(u.metalRoughness, 0.0, 1.0) * 0.014; + let tone = clamp(glsChromaticMetalTone(phase) + brushed * brushAmount, 0.0, 1.0); + return lqRamp(tone, u.colorD.rgb, u.colorB.rgb, u.colorC.rgb, u.colorA.rgb); +} + +fn glsChromaticMetalFluid(p: vec2, t: f32) -> vec3 { + let angle = u.metalAngle * 0.01745329252; + let splitDirection = glsRotate(vec2(0.0, 1.0), angle); + let split = splitDirection * u.chromaticShift * 0.045; + let redSample = glsChromaticMetalSample(p + split, t); + let neutral = glsChromaticMetalSample(p, t); + let blueSample = glsChromaticMetalSample(p - split, t); + let optical = vec3(redSample.r, neutral.g, blueSample.b); + let fringe = clamp(length(optical - neutral) * 4.0, 0.0, 1.0); + var color = mix(neutral, optical, + clamp(u.chromaticShift * (0.72 + fringe * 0.28), 0.0, 1.0)); + let centerTone = glsChromaticMetalTone(glsChromaticMetalPhase(p, t)); + let glint = pow(centerTone, mix(12.0, 5.0, clamp(u.metalRoughness, 0.0, 1.0))); + color = mix(color, u.highlightColor.rgb, + glint * clamp(u.metalDepth, 0.0, 1.0) * 0.06); + + // A second, sphere-scale reflection layer keeps the material metallic even + // when the optional glass shell is disabled. It modulates the animated ramp + // instead of raising exposure, preserving dark chrome between reflections. + let radial2 = clamp(dot(p, p), 0.0, 1.0); + let normal = normalize(vec3(p, sqrt(max(1.0 - radial2, 0.0)))); + let roughness = clamp(u.metalRoughness, 0.0, 1.0); + let depth = clamp(u.metalDepth, 0.0, 1.0); + let key = pow(max(dot(normal, normalize(vec3(-0.48, 0.62, 0.62))), 0.0), + mix(7.0, 3.0, roughness)); + let fill = pow(max(dot(normal, normalize(vec3(0.7, -0.34, 0.63))), 0.0), + mix(10.0, 4.0, roughness)); + let limb = 1.0 - normal.z; + let fresnel = pow(limb, 3.0); + let rim = pow(limb, 10.0); + color = color * (0.86 + normal.z * 0.14); + color = mix(color, u.highlightColor.rgb, key * (0.05 + depth * 0.13)); + color = mix(color, u.colorC.rgb, fill * (0.025 + depth * 0.07)); + color = mix(color, u.colorD.rgb, fresnel * (0.12 + depth * 0.15)); + color = mix(color, u.highlightColor.rgb, rim * (0.035 + depth * 0.055)); + return glsFinishPresetFluid(color, p); +} + +fn glsOpalFluid(p: vec2, t: f32) -> vec3 { + let q = p * (0.8 + u.zoom * 0.64); + let complexity = 0.76 + u.warp * 0.085; + var d = -t * 0.42; + var a = 0.0; + for (var i: i32 = 0; i < 8; i = i + 1) { + let fi = f32(i); + a = a + cos(fi - d - a * q.x * complexity); + d = d + sin(q.y * fi * complexity + a); + } + d = d + t * 0.42; + let c1 = cos(q * vec2(d, a)) * 0.6 + vec2(0.4); + let c2 = cos(a + d) * 0.5 + 0.5; + let interference = 0.5 + 0.5 * cos(vec3(c1.x, c1.y, c2) + * cos(vec3(d, a, 2.5)) * 0.5 + vec3(0.5)); + let tone = fract(interference.r * 0.37 + interference.g * 0.51 + + interference.b * 0.73 + c1.x * 0.22 - c1.y * 0.15); + var color = lqRamp(tone, u.colorB.rgb, u.colorC.rgb, u.colorD.rgb, u.colorA.rgb); + color = mix(color, u.colorA.rgb, 0.16 + 0.1 * interference.b); + color = color / (vec3(1.0) + color * 0.16); + return glsFinishPresetFluid(color, p); +} + +fn glsFrostFluid(p: vec2, t: f32) -> vec3 { + // The initial frost-style orb: a slow domain warp drives broad cloudy colour + // bodies, while a second higher-frequency field contributes adjustable veins. + var q = p * (0.66 + u.zoom * 0.92); + q.y = q.y + t * 0.055; + let blur = 0.011 + 0.006 * u.zoom; + let warpField = vec2( + lqFbm(q * 1.14 + vec2(t * 0.055, 0.0), blur).x, + lqFbm(q * 1.14 + vec2(6.8, -t * 0.048), blur).x + ); + let warped = q + (warpField - vec2(0.5)) * (0.28 + u.warp * 0.17); + let body = lqFbm(warped * 1.48 + vec2(t * 0.032, -t * 0.02), blur * 1.48); + let veins = lqRidgeS( + lqFbm(warped * 2.36 + vec2(3.1, -t * 0.024), blur * 2.36), + u.sharp + ); + let value = mix(lqStepS(body, 0.1, 0.9), + clamp(veins * 0.8 + body.x * 0.46, 0.0, 1.0), + u.ridgeAmt); + var color = lqRamp(value, u.colorA.rgb, u.colorB.rgb, u.colorC.rgb, u.colorD.rgb); + color = mix(color, u.colorA.rgb, 0.08 * smoothstep(0.62, 0.92, body.x)); + return glsFinishPresetFluid(color, p); +} + +fn glsVoiceWaveFluid(p: vec2, t: f32) -> vec3 { + // A single broad membrane stays phase-coherent across the sphere. Nearby + // translucent layers add volume without splitting into separate Siri bands. + let scale = 0.76 + u.zoom * 0.34; + let q = p / scale; + let rimEnvelope = pow(max(1.0 - q.x * q.x, 0.0), 0.72); + let drift = t * 0.82; + let amplitude = 0.2 + u.warp * 0.018; + let mainY = rimEnvelope * (amplitude * sin(q.x * 1.48 + drift) + + 0.055 * sin(q.x * 3.2 - drift * 0.43 + 1.1)); + let distance = q.y - mainY; + let width = 0.11 + (1.0 - u.ridgeAmt) * 0.075; + let membrane = exp(-distance * distance / max(width * width, 0.001)) * rimEnvelope; + let upperVeil = exp(-(distance - 0.105) * (distance - 0.105) + / max(width * width * 2.4, 0.001)) * rimEnvelope; + let lowerVeil = exp(-(distance + 0.115) * (distance + 0.115) + / max(width * width * 2.8, 0.001)) * rimEnvelope; + let crest = exp(-distance * distance / 0.0026) * rimEnvelope; + let depth = sqrt(max(1.0 - clamp(dot(p, p), 0.0, 1.0), 0.0)); + var color = mix(u.colorA.rgb * 0.7, u.colorD.rgb * 0.34, + smoothstep(-0.82, 0.82, q.y)); + color = mix(color, u.colorB.rgb, upperVeil * 0.7); + color = mix(color, u.colorC.rgb, lowerVeil * 0.62); + color = color + mix(u.colorB.rgb, u.colorC.rgb, 0.46) * membrane * 0.34; + color = color + u.highlightColor.rgb * crest * 0.14; + color = color * (0.58 + 0.42 * depth); + return glsFinishPresetFluid(color, p); +} + +fn glsBlueDropFluid(p: vec2, t: f32) -> vec3 { + // Slow diagonal advection keeps the broad liquid bodies coherent. The two + // shear waves replace the reference orb's circular, looped point motion. + let depth = sqrt(max(1.0 - clamp(dot(p, p), 0.0, 1.0), 0.0)); + var q = p * mix(0.72, 1.0, depth * 0.62 + 0.38); + q = glsRotate(q, -0.24 + 0.06 * sin(t * 0.17)); + let scale = 1.0 + u.zoom * 1.12; + let blur = 0.012 + 0.006 * u.zoom; + let driftA = lqFbm(q * 1.28 + vec2(t * 0.095, -t * 0.034), blur * 1.28); + let driftB = lqFbm(glsRotate(q, 1.08) * 1.62 + + vec2(-t * 0.042, t * 0.078), blur * 1.62); + var flowed = q + vec2(driftA.x - 0.5, driftB.x - 0.5) + * (0.24 + u.warp * 0.1); + flowed.x = flowed.x + sin(flowed.y * 2.15 + t * 0.24) * (0.035 + u.warp * 0.012); + flowed.y = flowed.y + sin(flowed.x * 1.38 - t * 0.18) * (0.045 + u.warp * 0.01); + let body = lqFbm(flowed * scale + vec2(t * 0.025, -t * 0.018), blur * scale); + let marble = lqRidgeS(lqFbm(flowed * (1.72 + u.zoom * 0.9) + + vec2(2.7, -t * 0.035), + blur * (1.72 + u.zoom * 0.9)), + 0.8 + u.sharp * 0.46); + let value = clamp(mix(body.x, body.x * 0.62 + marble * 0.58, u.ridgeAmt), 0.0, 1.0); + var color = lqRamp(value, u.colorA.rgb, u.colorB.rgb, u.colorC.rgb, u.colorD.rgb); + let light = pow(max(dot(normalize(vec3(p, depth)), + normalize(vec3(-0.48, 0.62, 0.92))), 0.0), 3.2); + color = mix(color, u.highlightColor.rgb, light * (0.035 + 0.05 * u.shade)); + color = color * (0.74 + 0.26 * depth); + return glsFinishPresetFluid(color, p); +} + +fn glsVioletEmberFluid(p: vec2, t: f32) -> vec3 { + // A radial twist and two crossing drift fields make heavy molten folds. This + // moves as a breathing spiral instead of the reference orb's closed circles. + let scale = 1.08 + u.zoom * 1.18; + let blur = 0.011 + 0.005 * u.zoom; + let radius = length(p); + let twist = t * 0.055 + radius * (0.72 + u.warp * 0.11) + + 0.08 * sin(t * 0.31 + radius * 4.0); + let q = glsRotate(p * scale, twist); + let low = lqFbm(q * 1.18 + vec2(t * 0.068, -t * 0.105), blur * 1.18); + let cross = lqFbm(glsRotate(q, -1.12) * 1.52 + + vec2(-t * 0.094, t * 0.042) + + vec2(low.x * 1.35, -low.x * 0.72), blur * 1.52); + let warped = q + vec2(low.x - 0.5, cross.x - 0.5) + * (0.3 + u.warp * 0.12); + let melt = lqFbm(warped * 1.34 + + vec2(cross.x * 1.48, low.x * 1.12), blur * 1.34); + let veins = lqRidgeS(lqFbm(warped * (2.05 + u.zoom * 0.72) + + vec2(-2.1, t * 0.052), + blur * (2.05 + u.zoom * 0.72)), + 0.82 + u.sharp * 0.58); + let heat = smoothstep(0.18, 0.92, + melt.x * (0.72 - u.ridgeAmt * 0.16) + + veins * (0.32 + u.ridgeAmt * 0.5)); + var color = lqRamp(heat, u.colorA.rgb, u.colorB.rgb, u.colorC.rgb, u.colorD.rgb); + let pulse = 0.94 + 0.06 * sin(t * 0.44 + melt.x * 5.0); + color = color * pulse; + color = mix(color, u.highlightColor.rgb, pow(veins, 4.0) * 0.045); + return glsFinishPresetFluid(color, p); +} + +fn glsPresetFluid(p: vec2, style: i32, t: f32) -> vec3 { + if (style == 9) { return glsSiriFluid(p, t); } + if (style == 10) { return glsAuroraFluid(p, t); } + if (style == 11) { return glsPlasmaFluid(p, t); } + if (style == 12) { return glsChromeFluid(p, t); } + if (style == 13) { return glsOpalFluid(p, t); } + if (style == 14) { return glsSpectrumFluid(p, t); } + if (style == 15) { return glsFrostFluid(p, t); } + if (style == 19) { return glsVoiceWaveFluid(p, t); } + if (style == 20) { return glsBlueDropFluid(p, t); } + if (style == 21) { return glsVioletEmberFluid(p, t); } + if (style == 22) { return glsChromaticMetalFluid(p, t); } + return glsFrostFluid(p, t); +} + +// --------------------------------------------------------------------------- +// The fluid, at one point, already blurred and straight (not premultiplied): +// the sheet's shader on `fu` — both programs, all four inner branches, and the +// shared shade tail — with the blur folded into the noise bank as above. The +// disc's own alpha is the caller's, because it is analytic now. +// --------------------------------------------------------------------------- +fn glsFluid(fu: vec2, md: i32, t: f32) -> vec3 { + let df = length(fu); + + let cA = u.colorA.rgb; + let cB = u.colorB.rgb; + let cC = u.colorC.rgb; + let cD = u.colorD.rgb; + + // The blur's sigma, carried from fluid units into the fluid's own domains. + // `sp` is it in pp/q units — the warp shifts q about but does not stretch it + // on average, so pp and q share one. `sw` is the warp field's own, softened + // by GL_KWA. + let blurSigma = select(GL_BSIG_CLEAR, GL_BSIG_GLASS, u.glassEnabled > 0.5); + let sp = blurSigma * u.zoom; + let sw = sp * 1.1 * GL_KWA; + + var fcol: vec3; + if (md < 0) { + // progA — the warped body, the only branch with the slow vertical drift + // and the only one that reads Ridge. + var pp = fu * u.zoom; + pp.y = pp.y + t * 0.05; + let w = vec2(lqFbm(pp * 1.1 + vec2(0.0, t * 0.09), sw).x, + lqFbm(pp * 1.1 + vec2(7.7, -t * 0.07), sw).x); + let q = pp + u.warp * (w - vec2(0.5)); + let body = lqFbm(q * 1.5 + vec2(t * 0.04, 0.0), sp * 1.5); + let veins = lqRidgeS(lqFbm(q * 2.2 + vec2(3.1), sp * 2.2), u.sharp); + let v = mix(lqStepS(body, 0.12, 0.88), + clamp(veins * 0.85 + 0.45 * body.x, 0.0, 1.0), u.ridgeAmt); + fcol = lqRamp(v, cA, cB, cC, cD); + } else { + // progB — same warp, no vertical drift, four inner branches. + let pp = fu * u.zoom; + let w = vec2(lqFbm(pp * 1.1 + vec2(0.0, t * 0.09), sw).x, + lqFbm(pp * 1.1 + vec2(7.7, -t * 0.07), sw).x); + let q = pp + u.warp * (w - vec2(0.5)); + if (md == 0) { + // Nectar — a sine band the noise leans on. The fbm is INSIDE the sine, so + // the removed detail integrates out in closed form rather than by + // quadrature: E[sin(A + 6e)] = sin(A)·exp(-18·sd²). The second term of + // the exponent is the same integral for the sine's own `q.x * 7.0`, which + // the blur attenuates by exp(-49·sp²/2). + let n0 = lqFbm(q * 2.2, sp * 2.2); + let damp = exp(-18.0 * n0.y * n0.y - 24.5 * sp * sp); + var v = 0.5 + 0.5 * damp * sin(q.x * 7.0 + n0.x * 6.0 + t * 0.35); + v = mix(v, lqFbm(q * 1.4 + vec2(t * 0.03), sp * 1.4).x, 0.25); + fcol = lqRamp(v, cA, cB, cC, cD); + } else if (md == 1) { + // Lumen — two ridged fields multiplied into filaments. The two fields are + // independent, so each integrates its own detail out before the product. + let v = lqRidgeS(lqFbm(q * 1.4 + vec2(t * 0.06, 0.0), sp * 1.4), u.sharp) + * lqRidgeS(lqFbm(q * 1.7 - vec2(0.0, t * 0.05), sp * 1.7), u.sharp); + fcol = lqRamp(pow(v, 0.7), cA, cB, cC, cD); + } else if (md == 6) { + // Sprig — noise warped by noise, with a ridged edge darkening it. + let v = lqFbm(q * 1.3 + vec2(1.5 * lqFbm(q * 2.6 + vec2(t * 0.025), sp * 2.6).x), sp * 1.3); + let edge = lqRidgeS(lqFbm(q * 2.1 + vec2(7.0), sp * 2.1), 1.3); + fcol = lqRamp(lqStepS(v, 0.1, 0.9), cA, cB, cC, cD); + fcol = fcol * (1.0 - 0.18 * edge); + } else { + // Haze and Smoke — the same rising plume at two palettes. + let q2 = q + vec2(0.0, -t * 0.14); + let v = lqFbm(q2 * 1.6 + vec2(2.2 * lqFbm(q2 * 2.4 + vec2(0.0, -t * 0.05), sp * 2.4).x), sp * 1.6); + fcol = lqRamp(lqPowS(v, 1.5), cA, cB, cC, cD); + } + } + + // The sheet's shared tail: a highlight up-left, a shadow down-right, and a + // darkened limb. All three are far below the blur's cutoff, so they are the + // sheet's own expressions untouched. The grain term the sheet ends on is not + // ported — see the header. The two `1 - shade*k*smoothstep(...)` terms are + // multiplicative darkening, not colours, so they stay literal. + fcol = mix(fcol, u.highlightColor.rgb, + u.shade * 0.3 * smoothstep(0.25, 1.25, dot(fu, vec2(-0.32, 0.78)))); + fcol = fcol * (1.0 - u.shade * 0.42 * smoothstep(-0.05, 1.25, dot(fu, vec2(0.45, -0.62)))); + fcol = fcol * (1.0 - u.shade * 0.3 * smoothstep(0.72, 1.0, df)); + return clamp(fcol, vec3(0.0), vec3(1.0)); +} + +// --------------------------------------------------------------------------- +// The shell. +// --------------------------------------------------------------------------- + +// Source-over onto an opaque destination, straight (un-premultiplied) sRGB. +fn glsOver(dst: vec3, src: vec3, a: f32) -> vec3 { + let k = clamp(a, 0.0, 1.0); + return src * k + dst * (1.0 - k); +} + +fn glsRefractionProfile(t: f32) -> f32 { + let depth = clamp(t, 0.0, 1.0); + let circular = sqrt(max(1.0 - (1.0 - depth) * (1.0 - depth), 0.0)); + return 1.0 - circular; +} + +fn glsHighlightLobe(normal: vec2, direction: vec2, cut: f32, + power: f32) -> f32 { + let angular = clamp((dot(normal, direction) - cut) / max(1.0 - cut, 0.001), + 0.0, 1.0); + return pow(angular, power); +} + +fn glsContourWave(angle: f32, t: f32) -> vec2 { + let style = i32(u.style + 0.5); + if (style == 19) { + let wave = sin(angle * 2.0 + t * 0.27) * 0.72 + + sin(angle * 4.0 - t * 0.16 + 2.1) * 0.28; + let slope = cos(angle * 2.0 + t * 0.27) * 1.44 + + cos(angle * 4.0 - t * 0.16 + 2.1) * 1.12; + return vec2(wave, slope); + } + let wave = sin(angle * 3.0 + t * 0.62) * 0.52 + + sin(angle * 5.0 - t * 0.41 + 1.7) * 0.31 + + sin(angle * 2.0 + t * 0.23 + 3.1) * 0.17; + let slope = cos(angle * 3.0 + t * 0.62) * 1.56 + + cos(angle * 5.0 - t * 0.41 + 1.7) * 1.55 + + cos(angle * 2.0 + t * 0.23 + 3.1) * 0.34; + return vec2(wave, slope); +} + +fn glsContourStrength() -> f32 { + if (u.style >= 18.5) { return 0.11; } + return select(0.09, 0.16, u.style >= 15.5); +} + +fn glsContourScale(uv: vec2, t: f32, amount: f32) -> f32 { + if (amount <= 0.0) { return 1.0; } + let contour = glsContourWave(atan2(uv.y, uv.x), t); + return 1.0 + clamp(amount, 0.0, 1.0) * glsContourStrength() * contour.x; +} + +fn glsContourNormal(uv: vec2, rad: f32, t: f32, amount: f32) -> vec2 { + let distance = length(uv); + if (distance <= 0.0001) { return vec2(0.0); } + let radial = uv / distance; + let contour = glsContourWave(atan2(uv.y, uv.x), t); + let slope = clamp(amount, 0.0, 1.0) * glsContourStrength() * contour.y; + let tangent = vec2(-radial.y, radial.x); + return normalize(radial - tangent * (rad * slope / distance)); +} + +fn orbGlassLiquidAnim(uv01: vec2) -> vec4 { + // The runner hands uv01 with y down from the top, like stitchable MSL's + // `position`; the orb was authored bottom-left, so flip back. + let fc = vec2(uv01.x, 1.0 - uv01.y) * u.size; + let uv = (2.0 * fc - u.size) / max(min(u.size.x, u.size.y), 1.0); + + let rad = max(u.radius, 0.05); + let t = u.time * u.speed; + let contourRad = rad * glsContourScale(uv, t, u.contourDeform); + + // Nothing on this pixel — and here that is the whole fluid and the whole + // shell skipped, over roughly 60% of the quad. 1.01 is the far edge of the + // ball's own coverage, `1 - smoothstep(0.99, 1.01, pd)` on the last line of + // this function, which is EXACTLY zero past it, so the full path already + // returns opaque black here. An early-out, not a clip: the number is that + // coverage term's own far edge, so do not "tidy" it to 1.0 — that would + // shave the outer half of the limb's antialiasing. + // + // Tested on `uv` rather than on `pd` because `|uv| > rad * 1.01` IS + // `pd > 1.01`, and it keeps `p` and `pd` in the same basic block as + // everything that reads them — the shape the four sibling orbs of this port + // need, where branching on `d` after computing it makes the compiler stop + // folding `uv / rad` into its uses and the moved last bit comes back through + // their grain hash as speckle up to 34/255. Glass Liquid has no grain and is + // nearly immune either way: at 1024x1024 this costs under a dozen bytes of a + // four-million-byte frame, off by 1/255. Those are the branch existing, not a + // pixel wrongly skipped — a copy of this guard with a threshold it can never + // reach diffs identically, and against it the guard is exactly 0/255. + if (length(uv) > contourRad * (1.01 + mfEdgeD(u.edgeSoftness))) { + // Off the ball entirely — but the halo lives out here, so hand back + // what the edge bank paints on nothing. Exactly black at Glow 0. + return vec4(clamp(mfEdgeGlow(vec3(0.0), uv, vec2(0.0), contourRad, + u.edgeSoftness, u.edgeGlow, u.glowColor.rgb), + vec3(0.0), vec3(1.0)), 1.0); + } + + let p = uv / contourRad; // deformed ball space: |p| == 1 on the edge + let pd = length(p); + + // ---- the fluid ------------------------------------------------------ + let fu = p / GL_FU; + + // Branch dispatch. Source indices 0/2/4/6 are progA (md < 0); the others are + // progB at the sheet's own mode number. An if-chain avoids a runtime-indexed + // lookup here. + let s = i32(u.style + 0.5); + var md: i32 = -1; + if (s == 1) { md = 1; } + else if (s == 3 || s == 8) { md = 7; } + else if (s == 5) { md = 6; } + else if (s == 7) { md = 0; } + + let clearFa = 1.0 - smoothstep(GL_CLEAR_EA, GL_CLEAR_EB, pd); + let normal = glsContourNormal(uv, rad, t, u.contourDeform); + let edgeDepth = max(1.0 - pd, 0.0); + let refractionWidth = 0.015 + 0.95 * clamp(u.shellMidAlpha, 0.0, 1.0); + let refractionT = edgeDepth / max(refractionWidth, 0.001); + let refractionProfile = pow(glsRefractionProfile(refractionT), 0.68); + let refractionAmount = 1.6 * clamp(u.glassOpacity, 0.0, 1.0) + * refractionProfile; + let refractedP = p - normal * refractionAmount; + var fcol = vec3(0.0); + if (clearFa > 0.0) { + if (s >= 9) { + if (u.glassEnabled > 0.5) { + // Three actual fluid evaluations produce optical dispersion. At the + // outer boundary the reference lens pulls samples from deep inside the + // orb; the channels converge continuously at the inner edge of the + // refraction band. + let channelSplit = 0.14 * clamp(u.gloss, 0.0, 2.0) + * clamp(u.glassOpacity, 0.0, 1.0) + * refractionProfile; + let redSample = glsPresetFluid(refractedP - normal * channelSplit, s, t); + let greenSample = glsPresetFluid(refractedP, s, t); + let blueSample = glsPresetFluid(refractedP + normal * channelSplit, s, t); + fcol = vec3(redSample.r, greenSample.g, blueSample.b); + } + else { fcol = glsPresetFluid(p, s, t); } + } + else { fcol = glsFluid(fu, md, t); } + } + + // The no-glass branch is the fluid alone, expanded to the sphere boundary. + let lum = dot(fcol, vec3(0.213, 0.715, 0.072)); + let clearSat = clamp(vec3(lum) + (fcol - vec3(lum)) * 1.22, + vec3(0.0), vec3(1.0)); + var col = glsOver(u.canvasColor.rgb, clearSat, 0.99 * clearFa); + + if (u.glassEnabled > 0.5) { + // Surface lighting stays on a thin arc. The broad visual change comes from + // the refracted fluid above, not from a translucent white overlay. + // Its weights still need enough contrast to keep the exposed colour and + // highlight controls perceptible in the compact scene preview. + let surfaceWidth = 0.026 + 0.055 * clamp(u.shellEdgeAlpha, 0.0, 1.0); + let surfaceBand = (1.0 - smoothstep(0.0, surfaceWidth, edgeDepth)) * clearFa; + let opticalRim = pow(surfaceBand, 1.8); + col = glsOver(col, u.shellInner.rgb, + opticalRim * u.glassOpacity * 0.45); + + let coolDirection = normalize(vec2(0.84, 0.54)); + let warmDirection = normalize(vec2(-0.62, -0.78)); + let coolSplit = glsHighlightLobe(normal, coolDirection, -0.32, 1.8); + let warmSplit = glsHighlightLobe(normal, warmDirection, -0.28, 2.0); + let dispersion = opticalRim * clamp(u.gloss, 0.0, 2.0) + * (0.8 + 0.8 * u.shellEdgeAlpha); + col = glsOver(col, u.shellMid.rgb, dispersion * coolSplit); + col = glsOver(col, u.shellEdge.rgb, dispersion * warmSplit); + + let edgeShadow = opticalRim * (0.015 + 0.15 * u.shellEdgeAlpha) + * (0.15 + 0.85 * max(dot(normal, vec2(0.45, -0.89)), 0.0)); + col = col * (1.0 - edgeShadow); + + let keyDirection = normalize(vec2(-0.68, 0.73)); + let fillDirection = normalize(vec2(0.74, -0.67)); + let key = opticalRim * glsHighlightLobe(normal, keyDirection, 0.2, 2.8) + * clamp(u.sheen, 0.0, 2.0) * 1.4; + let fill = opticalRim * glsHighlightLobe(normal, fillDirection, 0.4, 3.6) + * clamp(u.sheen, 0.0, 2.0) * 1.0; + col = glsOver(col, u.sheenColor.rgb, key); + col = glsOver(col, u.specColor.rgb, fill); + } + + // The ball's own edge, and nothing outside it — everything the effect does + // not paint must be exactly 0 so the page shows through. + let ballA = 1.0 - smoothstep(0.99 - mfEdgeD(u.edgeSoftness), 1.01 + mfEdgeD(u.edgeSoftness), pd); + col = clamp(col * max(u.exposure, 0.0), vec3(0.0), vec3(1.0)) * ballA; + // The Orbs edge bank — the Edge group's Glow. Adding zero is exactly + // the render this file was diffed against, and zero is the default. + let edged = mfEdgeGlow(col, uv, vec2(0.0), contourRad, + u.edgeSoftness, u.edgeGlow, u.glowColor.rgb); + return vec4(clamp(edged, vec3(0.0), vec3(1.0)), 1.0); +} + + +struct VOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) i: u32) -> VOut { + var p = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0), + ); + var out: VOut; + out.pos = vec4(p[i], 0.0, 1.0); + let uv01 = (p[i] + vec2(1.0)) * 0.5; + out.uv = vec2(uv01.x, 1.0 - uv01.y); + return out; +} + +@fragment +fn fs_main(in: VOut) -> @location(0) vec4 { + let c = orbGlassLiquidAnim(in.uv); + + let fc = vec2(in.uv.x, 1.0 - in.uv.y) * u.size; + let uv = (2.0 * fc - u.size) / max(min(u.size.x, u.size.y), 1.0); + let rad = max(u.radius, 0.05); + let t = u.time * u.speed; + let contourRad = rad * glsContourScale(uv, t, u.contourDeform); + let pd = length(uv) / contourRad; + let ballA = 1.0 - smoothstep( + 0.99 - mfEdgeD(u.edgeSoftness), + 1.01 + mfEdgeD(u.edgeSoftness), + pd, + ); + let lum = max(c.r, max(c.g, c.b)); + let q = (2.0 * fc - u.size) / u.size; + let fitEnd = 1.0; + let fitFeather = 2.0 / max(min(u.size.x, u.size.y), 1.0); + let fitStart = min(mix(contourRad, fitEnd, 0.5), fitEnd - fitFeather); + let fit = 1.0 - smoothstep(fitStart, fitEnd, max(abs(q.x), abs(q.y))); + let alpha = select(ballA, max(ballA, lum), u.edgeGlow > 0.0); + + return vec4(c.rgb * fit, clamp(alpha, 0.0, 1.0) * fit); +} diff --git a/app/src/routes/_authed/_app/bot.tsx b/app/src/routes/_authed/_app/bot.tsx index cb5cd96..0af406e 100644 --- a/app/src/routes/_authed/_app/bot.tsx +++ b/app/src/routes/_authed/_app/bot.tsx @@ -12,6 +12,7 @@ import { useContext, useState, } from "react"; +import { LiquidThinkingOrb } from "@/components/channels/liquid-thinking-orb"; import { useActiveBot } from "@/lib/copilot/active-bot"; import { useBotThread } from "@/lib/copilot/bot-thread"; import { useStoppedTurn } from "@/lib/copilot/stopped-turn"; @@ -34,10 +35,8 @@ export function BotThinkingCursor({ data-testid="bot-thinking" role="status" > - {/* The same orb the channel transcript breathes, so waiting looks like one product. */} - - - + {/* The same liquid orb as channel thinking, so waiting looks like one product. */} + Thinking… ); diff --git a/app/src/styles.css b/app/src/styles.css index 18ae873..b7e710d 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -254,45 +254,42 @@ body { } /* - * The thinking orb: a small gradient sphere that breathes while the Bot works. - * - * Two elements on purpose. The halo scales and fades — the breath — while the orb inside spins its - * conic gradient. One element cannot do both: `transform` carries either the rotation or the - * scale, and merging them into one keyframe ties the breath's rhythm to the spin's. - * - * The colours are fixed rather than themed. This app's `--primary` is monochrome by design, and a - * grey orb next to grey text reads as a smudge; a violet-to-cyan turn is the one deliberate accent, - * and the same gradient works on both light and dark ground. Only `transform` and `opacity` - * animate, so the compositor owns the whole effect. + * The model-working cursor. WebGPU paints the supplied liquid-glass shader into this canvas; the + * gradient is an immediate fallback while that renderer starts and for browsers without WebGPU. + * The shader itself supplies the movement, so the canvas stays still once WebGPU has its first + * frame. At 20 CSS pixels and up to 2x DPR, the detailed sphere remains crisp without rendering a + * large off-screen texture for every thinking surface. */ -.thinking-orb-halo { - display: inline-flex; - animation: thinking-orb-breathe 2s ease-in-out infinite; +.liquid-thinking-orb { + display: block; + flex: none; + width: 1.25rem; + height: 1.25rem; + border-radius: 9999px; + background: + radial-gradient( + circle at 37% 30%, + oklch(0.87 0.1 235 / 95%) 0 10%, + transparent 25% + ), + radial-gradient( + circle, + oklch(0.43 0.18 255) 0 42%, + oklch(0.69 0.17 240) 54%, + transparent 61% + ); + filter: drop-shadow(0 0 0.22rem oklch(0.65 0.17 250 / 45%)); } -.thinking-orb { - display: inline-flex; - width: 0.875rem; - height: 0.875rem; - border-radius: 9999px; - background: conic-gradient( - from 0deg, - oklch(0.62 0.21 285), - oklch(0.68 0.16 240), - oklch(0.8 0.13 200), - oklch(0.62 0.21 285) - ); - box-shadow: 0 0 10px 1px oklch(0.65 0.18 260 / 35%); - animation: thinking-orb-spin 2.8s linear infinite; +.liquid-thinking-orb[data-renderer="fallback"] { + animation: liquid-thinking-orb-fallback 2s ease-in-out infinite; } -@keyframes thinking-orb-spin { - to { - transform: rotate(1turn); - } +.liquid-thinking-orb[data-renderer="webgpu"] { + background: transparent; } -@keyframes thinking-orb-breathe { +@keyframes liquid-thinking-orb-fallback { 0%, 100% { transform: scale(1); @@ -304,10 +301,9 @@ body { } } -/* A still orb for reduced-motion users: present, glowing, not moving. */ +/* A still rendered frame or fallback for reduced-motion users: present, glowing, not moving. */ @media (prefers-reduced-motion: reduce) { - .thinking-orb-halo, - .thinking-orb { + .liquid-thinking-orb { animation: none; } }