labDither Tide

Scene

Dither Tide

A rowboat, water lilies or a swan sinking into a halftone sea, rendered in a WebGL2 shader. Pick a scene, drag the slider from sun to moon to turn day into golden hour and night, and move the cursor for parallax. Inspired by yarik's “Sinking into dither”; boat photo by Neva Szpigel on Pexels.

Source

dither-tide.tsx
"use client";

import { type PointerEvent, useEffect, useRef, useState } from "react";
import { createRenderer, type Renderer, type SceneImages } from "./renderer";
import { ScenePicker } from "./scene-picker";
import { type Scene, scenes } from "./scenes";
import { TimeSlider } from "./time-slider";

const MAX_PIXEL_RATIO = 2;
const MAX_DROPS = 4;
const DROP_LIFETIME = 1.8;

type Drop = { x: number; y: number; start: number };

async function loadImage(src: string) {
  const image = new Image();
  image.src = src;
  await image.decode();
  return image;
}

async function loadScene(scene: Scene): Promise<SceneImages> {
  const [image, water] = await Promise.all([
    loadImage(scene.image),
    loadImage(scene.water),
  ]);
  return { scene, image, water };
}

export function DitherTide() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const nightRef = useRef(0);
  const pointerRef = useRef({ x: 0, y: 0 });
  const hoverRef = useRef({ x: 0.5, y: 0.5, active: false });
  const dropsRef = useRef<Drop[]>([]);
  const rendererRef = useRef<Renderer | null>(null);
  const [night, setNight] = useState(0);
  const [sceneId, setSceneId] = useState(scenes[0].id);
  const [status, setStatus] = useState<"loading" | "ready" | "failed">(
    "loading",
  );

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
    let renderer: Renderer | null = null;
    let disposed = false;
    let frame = 0;
    let visible = true;
    let last = 0;
    let time = 0;
    let current = nightRef.current;
    const pointer = { x: 0, y: 0 };
    const hover = { x: 0.5, y: 0.5, amount: 0 };
    const drops = new Float32Array(MAX_DROPS * 4);

    const resize = () => {
      const { width, height } = canvas.getBoundingClientRect();
      renderer?.resize(
        width,
        height,
        Math.min(devicePixelRatio, MAX_PIXEL_RATIO),
      );
    };

    function tick(now: number) {
      const delta = Math.min((now - last) / 1000, 0.1);
      last = now;
      if (!reduceMotion) time += delta;
      current += (nightRef.current - current) * (1 - Math.exp(-delta * 6));
      const follow = reduceMotion ? 0 : 1 - Math.exp(-delta * 7);
      pointer.x += (pointerRef.current.x - pointer.x) * follow;
      pointer.y += (pointerRef.current.y - pointer.y) * follow;

      const target = hoverRef.current;
      const track = 1 - Math.exp(-delta * 14);
      hover.x += (target.x - hover.x) * track;
      hover.y += (target.y - hover.y) * track;
      hover.amount +=
        ((target.active ? 1 : 0) - hover.amount) * (1 - Math.exp(-delta * 10));

      drops.fill(0);
      dropsRef.current = dropsRef.current.filter(
        ({ start }) => (now - start) / 1000 < DROP_LIFETIME,
      );
      dropsRef.current.forEach(({ x, y, start }, index) => {
        drops.set([x, y, (now - start) / 1000, 1], index * 4);
      });

      renderer?.draw({
        time,
        night: current,
        pointerX: pointer.x,
        pointerY: pointer.y,
        hover: [hover.x, hover.y, hover.amount],
        drops,
        calm: reduceMotion,
      });
      frame = visible ? requestAnimationFrame(tick) : 0;
    }

    function start() {
      if (frame || !renderer) return;
      last = performance.now();
      frame = requestAnimationFrame(tick);
    }

    const resizeObserver = new ResizeObserver(resize);
    const intersectionObserver = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible) start();
    });

    loadScene(scenes[0])
      .then((images) => {
        if (disposed) return;
        renderer = createRenderer(canvas);
        if (!renderer) throw new Error("WebGL2 is not available");
        renderer.setScene(images);
        rendererRef.current = renderer;
        resize();
        resizeObserver.observe(canvas);
        intersectionObserver.observe(canvas);
        setStatus("ready");
        start();
      })
      .catch(() => {
        if (!disposed) setStatus("failed");
      });

    return () => {
      disposed = true;
      cancelAnimationFrame(frame);
      resizeObserver.disconnect();
      intersectionObserver.disconnect();
      renderer?.dispose();
      rendererRef.current = null;
    };
  }, []);

  useEffect(() => {
    const scene = scenes.find(({ id }) => id === sceneId);
    if (!scene || !rendererRef.current) return;
    let cancelled = false;
    loadScene(scene).then((images) => {
      if (!cancelled) rendererRef.current?.setScene(images);
    });
    return () => {
      cancelled = true;
    };
  }, [sceneId]);

  function localPoint(event: PointerEvent<HTMLDivElement>) {
    const rect = event.currentTarget.getBoundingClientRect();
    return {
      x: (event.clientX - rect.left) / rect.width,
      y: (event.clientY - rect.top) / rect.height,
    };
  }

  function isOnControl(event: PointerEvent<HTMLDivElement>) {
    return (event.target as Element).closest("input, label, fieldset") !== null;
  }

  function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
    const { x, y } = localPoint(event);
    pointerRef.current = { x: x * 2 - 1, y: y * 2 - 1 };
    hoverRef.current = {
      x,
      y,
      active: event.pointerType === "mouse" && !isOnControl(event),
    };
  }

  function handlePointerLeave() {
    pointerRef.current = { x: 0, y: 0 };
    hoverRef.current = { ...hoverRef.current, active: false };
  }

  function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
    if (isOnControl(event)) return;
    const { x, y } = localPoint(event);
    dropsRef.current = [
      ...dropsRef.current.slice(1 - MAX_DROPS),
      { x, y, start: performance.now() },
    ];
  }

  function handleNightChange(value: number) {
    nightRef.current = value;
    setNight(value);
  }

  return (
    <div
      onPointerMove={handlePointerMove}
      onPointerLeave={handlePointerLeave}
      onPointerDown={handlePointerDown}
      className="relative size-full touch-pan-y overflow-hidden bg-[#0c3522]"
    >
      {status === "failed" ? (
        // biome-ignore lint/performance/noImgElement: plain fallback when WebGL2 is unavailable
        <img
          src={scenes.find(({ id }) => id === sceneId)?.image}
          alt=""
          className="absolute inset-0 size-full object-cover"
        />
      ) : (
        <canvas
          ref={canvasRef}
          className={`absolute inset-0 size-full transition-opacity duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] ${status === "ready" ? "opacity-100" : "opacity-0"}`}
        />
      )}
      <TimeSlider value={night} onChange={handleNightChange} />
      <ScenePicker value={sceneId} onChange={setSceneId} />
    </div>
  );
}
pixel-icon.tsx
export function PixelIcon({
  pattern,
  className,
}: {
  pattern: string[];
  className?: string;
}) {
  const path = pattern
    .flatMap((row, y) =>
      [...row].map((pixel, x) => (pixel === "#" ? `M${x} ${y}h1v1h-1z` : "")),
    )
    .join("");

  return (
    <svg
      viewBox={`0 0 ${pattern[0].length} ${pattern.length}`}
      shapeRendering="crispEdges"
      fill="currentColor"
      aria-hidden="true"
      className={className}
    >
      <path d={path} />
    </svg>
  );
}
renderer.ts
import type { Scene } from "./scenes";
import { fragmentShader, vertexShader } from "./shader";

export type Frame = {
  time: number;
  night: number;
  pointerX: number;
  pointerY: number;
  hover: [number, number, number];
  drops: Float32Array;
  calm: boolean;
};

function cellSize(width: number, pixelRatio: number) {
  const cssCell = Math.min(Math.max(width / 600, 1), 2);
  return Math.max(2, Math.round(cssCell * pixelRatio));
}

function compile(gl: WebGL2RenderingContext, type: number, source: string) {
  const shader = gl.createShader(type);
  if (!shader) throw new Error("Could not create shader");
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    throw new Error(gl.getShaderInfoLog(shader) ?? "Shader failed to compile");
  }
  return shader;
}

function link(gl: WebGL2RenderingContext) {
  const program = gl.createProgram();
  gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, vertexShader));
  gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, fragmentShader));
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    throw new Error(gl.getProgramInfoLog(program) ?? "Program failed to link");
  }
  return program;
}

function createTexture(gl: WebGL2RenderingContext, unit: number, wrap: number) {
  const texture = gl.createTexture();
  gl.activeTexture(gl.TEXTURE0 + unit);
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
  return texture;
}

export type SceneImages = {
  scene: Scene;
  image: HTMLImageElement;
  water: HTMLImageElement;
};

export function createRenderer(canvas: HTMLCanvasElement) {
  const gl = canvas.getContext("webgl2", { antialias: false });
  if (!gl) return null;

  const program = link(gl);
  // biome-ignore lint/correctness/useHookAtTopLevel: WebGL API, not a React hook
  gl.useProgram(program);

  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([-1, -1, 3, -1, -1, 3]),
    gl.STATIC_DRAW,
  );
  const position = gl.getAttribLocation(program, "aPosition");
  gl.enableVertexAttribArray(position);
  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);

  const imageTexture = createTexture(gl, 0, gl.CLAMP_TO_EDGE);
  const waterTexture = createTexture(gl, 1, gl.MIRRORED_REPEAT);

  const uniform = (name: string) => gl.getUniformLocation(program, name);
  const uResolution = uniform("uResolution");
  const uTime = uniform("uTime");
  const uNight = uniform("uNight");
  const uCell = uniform("uCell");
  const uPointer = uniform("uPointer");
  const uHover = uniform("uHover");
  const uDrops = uniform("uDrops");
  const uCalm = uniform("uCalm");
  const uImageSize = uniform("uImageSize");
  const uFrame = uniform("uFrame");
  const uPhotoWater = uniform("uPhotoWater");
  const uSinkAngle = uniform("uSinkAngle");
  const uSink = uniform("uSink");
  gl.uniform1i(uniform("uImage"), 0);
  gl.uniform1i(uniform("uWater"), 1);

  return {
    setScene({ scene, image, water }: SceneImages) {
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, imageTexture);
      gl.texImage2D(
        gl.TEXTURE_2D,
        0,
        gl.RGBA,
        gl.RGBA,
        gl.UNSIGNED_BYTE,
        image,
      );
      gl.activeTexture(gl.TEXTURE1);
      gl.bindTexture(gl.TEXTURE_2D, waterTexture);
      gl.texImage2D(
        gl.TEXTURE_2D,
        0,
        gl.RGBA,
        gl.RGBA,
        gl.UNSIGNED_BYTE,
        water,
      );
      gl.uniform2f(uImageSize, image.naturalWidth, image.naturalHeight);
      gl.uniform4f(
        uFrame,
        scene.focus[0],
        scene.focus[1],
        scene.minViewWidth,
        scene.minViewHeight,
      );
      gl.uniform1f(uPhotoWater, scene.photoWater);
      gl.uniform1f(uSinkAngle, scene.sinkAngle);
      gl.uniform1f(uSink, scene.sink);
    },
    resize(width: number, height: number, pixelRatio: number) {
      canvas.width = Math.round(width * pixelRatio);
      canvas.height = Math.round(height * pixelRatio);
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.uniform2f(uResolution, canvas.width, canvas.height);
      gl.uniform1f(uCell, cellSize(width, pixelRatio));
    },
    draw({ time, night, pointerX, pointerY, hover, drops, calm }: Frame) {
      gl.uniform3f(uHover, hover[0], hover[1], hover[2]);
      gl.uniform4fv(uDrops, drops);
      gl.uniform1f(uCalm, calm ? 1 : 0);
      gl.uniform1f(uTime, time);
      gl.uniform1f(uNight, night);
      gl.uniform2f(uPointer, pointerX, pointerY);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    },
    dispose() {
      gl.deleteTexture(imageTexture);
      gl.deleteTexture(waterTexture);
      gl.deleteBuffer(buffer);
      gl.deleteProgram(program);
    },
  };
}

export type Renderer = NonNullable<ReturnType<typeof createRenderer>>;
scene-picker.module.css
.panel {
  --ink: #e9edeb;
  --pixel: 3px;
  position: absolute;
  bottom: 16px;
  left: 50%;
  display: flex;
  gap: 2px;
  margin: 0;
  padding: 4px;
  border: 0;
  translate: -50% 0;
  background: #0a0d0e;
  clip-path: polygon(
    0 var(--pixel),
    var(--pixel) var(--pixel),
    var(--pixel) 0,
    calc(100% - var(--pixel)) 0,
    calc(100% - var(--pixel)) var(--pixel),
    100% var(--pixel),
    100% calc(100% - var(--pixel)),
    calc(100% - var(--pixel)) calc(100% - var(--pixel)),
    calc(100% - var(--pixel)) 100%,
    var(--pixel) 100%,
    var(--pixel) calc(100% - var(--pixel)),
    0 calc(100% - var(--pixel))
  );
}

.legend {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.option {
  padding: 6px 10px;
  color: #8a9294;
  font:
    500 11px / 1 var(--font-mono),
    monospace;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  cursor: pointer;
  transition:
    color 150ms ease-out,
    background-color 150ms ease-out;
}

.option:hover {
  color: var(--ink);
}

.option:has(:checked) {
  color: #0a0d0e;
  background: var(--ink);
}

.option:has(:focus-visible) {
  outline: 2px solid #f3d36b;
  outline-offset: -2px;
}

.input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
scene-picker.tsx
import styles from "./scene-picker.module.css";
import { scenes } from "./scenes";

export function ScenePicker({
  value,
  onChange,
}: {
  value: string;
  onChange: (id: string) => void;
}) {
  return (
    <fieldset className={styles.panel}>
      <legend className={styles.legend}>Scene</legend>
      {scenes.map((scene) => (
        <label key={scene.id} className={styles.option}>
          <input
            type="radio"
            name="dither-tide-scene"
            value={scene.id}
            checked={value === scene.id}
            onChange={() => onChange(scene.id)}
            className={styles.input}
          />
          {scene.label}
        </label>
      ))}
    </fieldset>
  );
}
scenes.ts
export type Scene = {
  id: string;
  label: string;
  image: string;
  water: string;
  focus: [number, number];
  minViewWidth: number;
  minViewHeight: number;
  photoWater: number;
  sinkAngle: number;
  sink: number;
};

const base = "/experiments/dither-tide";

export const scenes: Scene[] = [
  {
    id: "boat",
    label: "Boat",
    image: `${base}/boat.webp`,
    water: `${base}/boat-water.webp`,
    focus: [0.473, 0.6],
    minViewWidth: 1.024,
    minViewHeight: 0.9,
    photoWater: 0,
    sinkAngle: 0.1,
    sink: 0.75,
  },
  {
    id: "lily",
    label: "Lilies",
    image: `${base}/lily.webp`,
    water: `${base}/lily-water.webp`,
    focus: [0.5, 0.48],
    minViewWidth: 0.85,
    minViewHeight: 0.85,
    photoWater: 1,
    sinkAngle: 0,
    sink: 0.35,
  },
  {
    id: "swan",
    label: "Swan",
    image: `${base}/swan.webp`,
    water: `${base}/swan-water.webp`,
    focus: [0.48, 0.45],
    minViewWidth: 0.85,
    minViewHeight: 0.9,
    photoWater: 1,
    sinkAngle: 0,
    sink: 0.35,
  },
];
shader.ts
export const vertexShader = /* glsl */ `#version 300 es
in vec2 aPosition;

void main() {
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
`;

export const fragmentShader = /* glsl */ `#version 300 es
precision highp float;

uniform sampler2D uImage;
uniform sampler2D uWater;
uniform vec2 uResolution;
uniform vec2 uImageSize;
uniform float uTime;
uniform float uNight;
uniform float uCell;
uniform vec2 uPointer;
uniform vec3 uHover;
uniform vec4 uDrops[4];
uniform float uCalm;
uniform vec4 uFrame;
uniform float uPhotoWater;
uniform float uSinkAngle;
uniform float uSink;

out vec4 outColor;

const float TAU = 6.28318530718;
const float BOAT_PARALLAX = 0.035;
const float MAX_PHOTO_VIEW_HEIGHT = 1.6;
const float WATER_PARALLAX = 0.02;
const vec2 SHADOW_OFFSET = vec2(0.02, 0.03);

struct Mode {
  vec3 deep;
  vec3 dots;
  vec3 glare;
  float glareStrength;
  vec3 boatTint;
  vec3 rim;
  float density;
};

const Mode DAY = Mode(
  vec3(0.047, 0.21, 0.13),
  vec3(0.7, 0.84, 0.69),
  vec3(1.0, 0.95, 0.66),
  1.0,
  vec3(0.98, 1.0, 0.95),
  vec3(0.0),
  1.0
);

const Mode GOLDEN = Mode(
  vec3(0.09, 0.1, 0.035),
  vec3(0.72, 0.71, 0.5),
  vec3(1.0, 0.76, 0.28),
  1.15,
  vec3(0.55, 0.36, 0.2),
  vec3(1.0, 0.6, 0.18),
  0.95
);

const Mode NIGHT = Mode(
  vec3(0.006, 0.01, 0.026),
  vec3(0.24, 0.34, 0.58),
  vec3(0.0),
  0.0,
  vec3(0.04, 0.05, 0.08),
  vec3(0.62, 0.74, 1.0),
  0.6
);

Mode blend(Mode a, Mode b, float t) {
  return Mode(
    mix(a.deep, b.deep, t),
    mix(a.dots, b.dots, t),
    mix(a.glare, b.glare, t),
    mix(a.glareStrength, b.glareStrength, t),
    mix(a.boatTint, b.boatTint, t),
    mix(a.rim, b.rim, t),
    mix(a.density, b.density, t)
  );
}

float hash(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 45.32);
  return fract(p.x * p.y);
}

float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(
    mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
    mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
    u.y
  );
}

float fbm(vec2 p) {
  float sum = 0.0;
  float amplitude = 0.5;
  for (int i = 0; i < 4; i++) {
    sum += amplitude * noise(p);
    p = p * 2.03 + 17.1;
    amplitude *= 0.5;
  }
  return sum;
}

float bayer2(vec2 a) {
  a = floor(a);
  return fract(dot(a, vec2(0.5, a.y * 0.75)));
}

float bayer8(vec2 a) {
  return bayer2(0.25 * a) * 0.0625 + bayer2(0.5 * a) * 0.25 + bayer2(a);
}

float luma(vec3 c) {
  return dot(c, vec3(0.299, 0.587, 0.114));
}

vec3 film(vec3 c, vec2 screen, float night, float time) {
  float l = luma(c);
  c = mix(vec3(l), c, 0.9);
  c += pow(1.0 - l, 2.0) * vec3(-0.02, 0.025, 0.04);
  c += l * l * vec3(0.05, 0.03, -0.03);
  float lift = mix(0.07, 0.025, night);
  c = c * (1.0 - lift) + lift * vec3(0.95, 0.9, 1.0);
  vec2 v = (screen - 0.5) * vec2(1.1, 1.0);
  c *= 1.0 - 0.32 * pow(length(v) * 1.35, 2.4);
  float leak = exp(-length(screen - vec2(-0.05, 0.08)) * 3.2) * (1.0 - night);
  c += vec3(1.0, 0.52, 0.22) * leak * 0.12;
  float grain = hash(floor(gl_FragCoord.xy) + fract(time * 12.0) * 97.0) - 0.5;
  c += grain * 0.05;
  return clamp(c, 0.0, 1.0);
}

float alphaAt(vec2 uv) {
  float inside = step(0.0, uv.x) * step(0.0, uv.y) * step(uv.y, 1.0);
  return texture(uImage, uv).a * inside;
}

float blurredAlpha(vec2 uv, float radius) {
  return (
    alphaAt(uv) +
    alphaAt(uv + vec2(radius, 0.0)) +
    alphaAt(uv - vec2(radius, 0.0)) +
    alphaAt(uv + vec2(0.0, radius)) +
    alphaAt(uv - vec2(0.0, radius))
  ) * 0.2;
}

void main() {
  vec2 cell = floor(gl_FragCoord.xy / uCell);
  vec2 cellScreen = (cell + 0.5) * uCell / uResolution;
  vec2 fragScreen = gl_FragCoord.xy / uResolution;
  cellScreen.y = 1.0 - cellScreen.y;
  fragScreen.y = 1.0 - fragScreen.y;

  float canvasAspect = uResolution.x / uResolution.y;
  float imageAspect = uImageSize.x / uImageSize.y;
  float viewWidth = max(uFrame.z, uFrame.w * canvasAspect / imageAspect);
  vec2 view = vec2(viewWidth, viewWidth * imageAspect / canvasAspect);
  view /= mix(1.0, max(1.0, view.y / MAX_PHOTO_VIEW_HEIGHT), uPhotoWater);
  vec2 focus = uFrame.xy;

  float golden = smoothstep(0.0, 0.5, uNight);
  float night = smoothstep(0.5, 1.0, uNight);
  Mode mode = blend(blend(DAY, GOLDEN, golden), NIGHT, night);
  float threshold = bayer8(cell);

  vec2 cellUv = focus + (cellScreen - 0.5) * view;
  vec2 waterUv = cellUv + uPointer * WATER_PARALLAX;
  vec2 ringUv = cellUv + uPointer * BOAT_PARALLAX;
  float disturbance = 0.0;
  vec2 hoverDelta = (cellScreen - uHover.xy) * vec2(canvasAspect, 1.0);
  float hoverDistance = length(hoverDelta);
  float lens = uHover.z * exp(-hoverDistance * hoverDistance / 0.012);
  waterUv -= (cellScreen - uHover.xy) * view * lens * 0.3 * (1.0 - uCalm);
  disturbance += uHover.z * exp(-pow((hoverDistance - 0.075) / 0.014, 2.0)) * 0.55;
  disturbance += lens * 0.18;

  for (int i = 0; i < 4; i++) {
    vec4 drop = uDrops[i];
    if (drop.w <= 0.0) continue;
    vec2 delta = (cellScreen - drop.xy) * vec2(canvasAspect, 1.0);
    float dropDistance = length(delta);
    float age = drop.z;
    float radius = age * 0.32;
    float width = 0.01 + age * 0.022;
    float fade = drop.w * (1.0 - smoothstep(0.6, 1.7, age)) * smoothstep(0.0, 0.08, age);
    float rings = exp(-pow((dropDistance - radius) / width, 2.0));
    rings += 0.4 * exp(-pow((dropDistance - radius * 0.6) / (width * 0.8), 2.0));
    rings *= fade * 0.55;
    float glow = drop.w * exp(-dropDistance * dropDistance / 0.006) * (1.0 - smoothstep(0.0, 0.45, age));
    disturbance += mix(rings, glow, uCalm);
    vec2 direction = delta / max(dropDistance, 1e-4);
    waterUv += direction * view * rings * 0.008 * (1.0 - uCalm);
  }

  vec2 waterP = waterUv * vec2(imageAspect, 1.0);

  float streak = fbm(vec2(waterP.x * 2.5 + uTime * 0.015, waterP.y * 75.0));
  float swell = fbm(waterP * 2.2 + vec2(uTime * 0.025, -uTime * 0.015));
  float procedural = 0.16 + (streak - 0.5) * 0.45 + (swell - 0.5) * 0.2;
  vec4 plate = texture(uWater, waterUv);
  float photoWater = uPhotoWater;
  float photoIntensity = pow(luma(plate.rgb), 0.85) * 0.95 + (streak - 0.5) * 0.12;
  float intensity = mix(procedural, photoIntensity, photoWater);

  vec2 ringP = ringUv * vec2(imageAspect, 1.0);
  vec2 overshoot = max(-ringUv, 0.0) + max(ringUv - 1.0, 0.0);
  float field = texture(uWater, clamp(ringUv, 0.001, 0.999)).a;
  field = min(1.0, field + length(overshoot * vec2(imageAspect, 1.0)) / 0.6);
  float ringDistance = field * 0.6;
  ringDistance += 0.012 * sin(ringP.x * 9.0 + ringP.y * 7.0 + uTime * 0.45);
  ringDistance += 0.03 * (fbm(ringP * 5.0 + uTime * 0.08) - 0.5);
  float band = pow(0.5 + 0.5 * cos((ringDistance * 9.0 - uTime * 0.22) * TAU), 4.0);
  float envelope = smoothstep(0.0, 0.03, ringDistance) * exp(-ringDistance * 2.2) * step(field, 0.995);
  float foam = band * envelope * (0.45 + 0.55 * fbm(ringP * vec2(9.0, 28.0) + uTime * 0.15));
  intensity += foam * mix(0.95, 0.6, photoWater);

  intensity -= blurredAlpha(ringUv - SHADOW_OFFSET, 0.025) * 0.45;

  float glare = exp(-length((cellScreen + vec2(0.08, 0.04)) * vec2(1.0, 0.75)) * 2.4);
  intensity += glare * 0.45 * mode.glareStrength;
  intensity += disturbance * 0.85;

  float lit = step(threshold, clamp(intensity * mode.density, 0.0, 1.0));
  vec3 water = mix(mode.deep, mode.dots, lit);
  water = mix(water, mix(mode.deep, mode.dots, clamp(intensity, 0.0, 1.0)), 0.2);

  vec3 photoTint = mix(mix(vec3(1.0), vec3(1.25, 0.95, 0.62), golden), vec3(0.2, 0.3, 0.62), night);
  vec3 plateColor = plate.rgb * photoTint;
  float photoLit = step(threshold, clamp(0.5 + (intensity - luma(plate.rgb)) * 1.6, 0.0, 1.0));
  vec3 photoWaterColor = mix(plateColor * 0.72, min(plateColor * 1.28 + 0.03, vec3(1.0)), photoLit);
  photoWaterColor += mode.glare * glare * mode.glareStrength * 0.25;
  water = mix(water, photoWaterColor, photoWater);

  vec2 boatUv = focus + (fragScreen - 0.5) * view + uPointer * BOAT_PARALLAX;
  float alpha = alphaAt(boatUv);
  float edge = smoothstep(0.35, 0.9, blurredAlpha(boatUv, 0.006));
  vec2 sinkDirection = vec2(sin(uSinkAngle), cos(uSinkAngle));
  float depth = dot(boatUv - (focus + uPointer * BOAT_PARALLAX), sinkDirection) / view.y;
  float sinking = smoothstep(mix(0.05, 0.2, photoWater), 0.4, depth) * uSink;
  float boatVisible = step(0.5, alpha) * step(threshold, edge * (1.0 - sinking));

  vec2 lightDirection = normalize(mix(vec2(-1.0, -1.2), vec2(0.0, -1.0), night));
  float facingLight = 1.0 - alphaAt(boatUv + lightDirection * 0.008);
  vec3 photo = texture(uImage, boatUv).rgb;
  float tintAmount = mix(mix(1.0, 0.45, photoWater), 1.0, night);
  vec3 subjectTint = mix(vec3(1.0), mode.boatTint, tintAmount);
  vec3 boat = mix(photo, vec3(luma(photo)), mix(0.5, 0.08, photoWater)) * subjectTint;
  boat += mode.rim * alpha * facingLight * 0.9;
  boat += mode.glare * glare * mode.glareStrength * 0.2;

  vec3 color = mix(water, boat, boatVisible);

  float smoothGlare = exp(-length((fragScreen + vec2(0.08, 0.04)) * vec2(1.0, 0.75)) * 2.4);
  color += mode.glare * smoothGlare * mode.glareStrength * (0.35 + 0.25 * streak) * (1.0 - boatVisible * 0.5);

  float moonX = 0.6 - uPointer.x * BOAT_PARALLAX / view.x;
  float column = exp(-pow((cellScreen.x - moonX) / (0.08 + 0.1 * abs(cellScreen.y - 0.5)), 2.0));
  float ripple = sin((cellScreen.y + 0.01 * sin(cellScreen.x * 50.0 + uTime * 0.8)) * 160.0 - uTime * 1.4);
  float broken = smoothstep(0.45, 0.75, noise(vec2(cellScreen.x * 24.0, cellScreen.y * 65.0) + vec2(uTime * 0.35, 0.0)));
  float clearOfBoat = 1.0 - blurredAlpha(ringUv, 0.03);
  float glints = step(threshold, smoothstep(0.7, 1.0, ripple) * broken * column * clearOfBoat * 1.4);
  float star = hash(cell);
  float twinkle = step(0.994, star) * (0.5 + 0.5 * sin(uTime * 2.5 + star * 300.0));
  color += night * (1.0 - boatVisible) * (vec3(0.85, 0.9, 1.0) * glints + vec3(0.7, 0.78, 1.0) * twinkle * 0.7);

  outColor = vec4(film(color, fragScreen, night, uTime), 1.0);
}
`;
time-slider.module.css
.panel {
  --ink: #e9edeb;
  --pixel: 3px;
  position: absolute;
  top: 50%;
  left: 16px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  width: 30px;
  height: min(60%, 300px);
  padding: 10px 0;
  translate: 0 -50%;
  color: var(--ink);
  background: #0a0d0e;
  clip-path: polygon(
    0 var(--pixel),
    var(--pixel) var(--pixel),
    var(--pixel) 0,
    calc(100% - var(--pixel)) 0,
    calc(100% - var(--pixel)) var(--pixel),
    100% var(--pixel),
    100% calc(100% - var(--pixel)),
    calc(100% - var(--pixel)) calc(100% - var(--pixel)),
    calc(100% - var(--pixel)) 100%,
    var(--pixel) 100%,
    var(--pixel) calc(100% - var(--pixel)),
    0 calc(100% - var(--pixel))
  );
}

.icon {
  flex-shrink: 0;
  width: 12px;
  height: 12px;
}

.moon {
  color: #f3d36b;
  opacity: calc(0.3 + 0.7 * var(--night));
}

.sun {
  opacity: calc(1 - 0.7 * var(--night));
}

.track {
  position: relative;
  flex: 1;
  width: 100%;
}

.line {
  position: absolute;
  inset: 0 auto;
  left: calc(50% - 1px);
  width: 2px;
  background: linear-gradient(
    to top,
    var(--ink) calc(var(--night) * 100%),
    #3a4143 calc(var(--night) * 100%)
  );
}

.tick {
  position: absolute;
  left: 7px;
  width: 3px;
  height: 1px;
  background: #3a4143;
}

.range {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  writing-mode: vertical-lr;
  direction: rtl;
  appearance: none;
  background: none;
  cursor: grab;
}

.range:active {
  cursor: grabbing;
}

.range:focus-visible {
  outline: none;
}

.range::-webkit-slider-runnable-track {
  background: none;
}

.range::-moz-range-track {
  background: none;
}

.range::-webkit-slider-thumb {
  appearance: none;
  width: 14px;
  height: 8px;
  border-radius: 2px;
  background: var(--ink);
}

.range::-moz-range-thumb {
  width: 14px;
  height: 8px;
  border: none;
  border-radius: 2px;
  background: var(--ink);
}

.range:focus-visible::-webkit-slider-thumb {
  outline: 2px solid #f3d36b;
  outline-offset: 2px;
}

.range:focus-visible::-moz-range-thumb {
  outline: 2px solid #f3d36b;
  outline-offset: 2px;
}
time-slider.tsx
import type { CSSProperties } from "react";
import { PixelIcon } from "./pixel-icon";
import styles from "./time-slider.module.css";

const moon = [
  "..####..#",
  ".###....#",
  "###....#.",
  "###......",
  "###......",
  "###......",
  "###......",
  ".###.....",
  "..####...",
];

const sun = [
  "....#....",
  ".#.....#.",
  "...###...",
  "..#####..",
  "#.#####.#",
  "..#####..",
  "...###...",
  ".#.....#.",
  "....#....",
];

function describe(value: number) {
  if (value < 0.3) return "Day";
  if (value < 0.7) return "Golden hour";
  return "Night";
}

export function TimeSlider({
  value,
  onChange,
}: {
  value: number;
  onChange: (value: number) => void;
}) {
  return (
    <div className={styles.panel} style={{ "--night": value } as CSSProperties}>
      <PixelIcon pattern={moon} className={`${styles.icon} ${styles.moon}`} />
      <div className={styles.track}>
        <span className={styles.line} />
        <span className={styles.tick} style={{ top: "33%" }} />
        <span className={styles.tick} style={{ top: "66%" }} />
        <input
          type="range"
          min={0}
          max={1}
          step={0.001}
          value={value}
          onChange={(event) => onChange(event.target.valueAsNumber)}
          aria-label="Time of day"
          aria-valuetext={describe(value)}
          className={styles.range}
        />
      </div>
      <PixelIcon pattern={sun} className={`${styles.icon} ${styles.sun}`} />
    </div>
  );
}