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