labDither Tide
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
"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>
);
}
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>
);
}
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>>;
.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;
}
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>
);
}
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,
},
];
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);
}
`;
.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;
}
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>
);
}