Hero waterDesign system
VisualSite

Hero water

WebGL water background with a static fallback for reduced motion and unsupported devices.

npx context-ds add hero-water
On the site

Usage

Use it for

A Hero's background on a launch or campaign page, with the content in the white sky above the water.

Avoid

  • Water behind dense text
  • Water on more than one section of a page
  • White text on the sky

Example

examples/hero-water.tsx
import { Hero } from "@/components/site/hero";import { Button } from "@/components/ui/button";import { HeroWater } from "@/components/visuals/hero-water";export function Site() {  return (    <Hero      className="min-h-160"      background={<HeroWater />}      title="Fresh web data, {always}"      accent="{always}"      description="Monitor any page and get the change the moment it lands."      actions={        <Button asChild variant="pill">          <a href="https://context.dev/signup">Start for free</a>        </Button>      }    />  );}

Source

components/visuals/hero-water.tsx
"use client";import * as React from "react";import { cn } from "@/lib/utils";import { createHeroWater } from "./hero-water-renderer";// Phones, short touch screens and reduced motion get the still water only: the shader costs too much there, or moves// when it should not.const STATIC_QUERY = "(prefers-reduced-motion: reduce), (max-width: 47.99rem), (pointer: coarse) and (max-height: 47.99rem)";// The still water under the shader, and its stand-in wherever the shader does not run: deep brand blue at the foot with// lighter swells, drawn from the brand blues.const STILL_WATER = [  "radial-gradient(70% 45% at 22% 100%, color-mix(in srgb, var(--ds-color-blue-40) 70%, transparent), transparent 70%)",  "radial-gradient(55% 40% at 78% 92%, color-mix(in srgb, var(--ds-color-blue-60) 80%, transparent), transparent 70%)",  "radial-gradient(90% 30% at 50% 62%, color-mix(in srgb, var(--ds-color-blue-40) 60%, transparent), transparent 75%)",  "linear-gradient(180deg, var(--ds-color-blue-60) 40%, var(--ds-color-blue-90) 75%, var(--ds-color-brand) 100%)",].join(", ");// The hero's sky: white at the top fading through the pale blues into the water, which shows through below.const SKY =  "linear-gradient(180deg, var(--ds-color-white) 0%, var(--ds-color-blue-10) 21.87%, var(--ds-color-blue-20) 55%, color-mix(in srgb, var(--ds-color-blue-40) 85%, transparent) 72%, color-mix(in srgb, var(--ds-color-blue-60) 40%, transparent) 85%, transparent 100%)";// The halftone look prints the still water as a dot screen.const HALFTONE_MASK = "radial-gradient(circle, var(--ds-color-black) 2.2px, transparent 2.8px)";/** * The hero's water backdrop: a WebGL water surface under a white-to-blue sky, rippling on its own and in the pointer's * wake (a click drops a stone). `halftone` prints it as a dot screen. Where WebGL is unavailable, on small or touch * screens, or under reduced motion, a still picture of the water shows instead. It fills its parent, never takes * pointer events, and stops drawing off screen. */export function HeroWater({ halftone = false, className }: { halftone?: boolean; className?: string }) {  const canvasRef = React.useRef<HTMLCanvasElement>(null);  React.useEffect(() => {    const canvas = canvasRef.current;    const layer = canvas?.parentElement;    const surface = layer?.closest<HTMLElement>("[data-slot='hero'], section") ?? layer;    if (!canvas || !surface) return;    const staticQuery = window.matchMedia(STATIC_QUERY);    let dispose: (() => void) | undefined;    const sync = () => {      dispose?.();      dispose = undefined;      if (!staticQuery.matches) dispose = createHeroWater(canvas, surface, undefined, halftone ? "halftone" : "water");    };    sync();    staticQuery.addEventListener("change", sync);    return () => {      staticQuery.removeEventListener("change", sync);      dispose?.();    };  }, [halftone]);  return (    <div aria-hidden="true" data-slot="hero-water" className={cn("pointer-events-none absolute inset-0 overflow-hidden", halftone ? "bg-blue-20" : "bg-blue-80", className)}>      <div        className="absolute inset-0"        style={{          backgroundImage: STILL_WATER,          ...(halftone ? { maskImage: HALFTONE_MASK, WebkitMaskImage: HALFTONE_MASK, maskSize: "7px 7px", WebkitMaskSize: "7px 7px" } : {}),        }}      />      {/* The renderer sets its opacity once the first frame is drawn, so it fades in over the still water. */}      <canvas ref={canvasRef} className="absolute inset-0 size-full opacity-0 transition-opacity duration-700 ease-out motion-reduce:hidden max-md:hidden" />      <div className="absolute inset-0" style={{ backgroundImage: SKY }} />    </div>  );}
components/visuals/hero-water-renderer.ts
import { createWaterSimulation } from "./hero-water-simulation";// The water is drawn by a fragment shader: a sum of wave trains plus a few expanding rings, lit by a sky reflection and// caustics, with the pointer's wake from a small height-field simulation added on top. Its colors are the brand blues as// shader constants. The shader's glass branch (refracting an image) is kept but never switched on here.const VERTEX_SHADER = `attribute vec2 position;void main() {	gl_Position = vec4(position, 0.0, 1.0);}`;const FRAGMENT_SHADER = `precision highp float;uniform sampler2D image;uniform sampler2D water;uniform vec2 resolution;uniform vec2 surfaceSize;uniform vec2 imageSize;uniform float time;uniform float glass;uniform float halftone;vec2 waveShape(vec2 point, out vec3 curvature) {	vec2 warp = vec2(sin(point.y * 0.007 + time * 0.19), cos(point.x * 0.006 - time * 0.16)) * 28.0;	vec2 p = point + warp;	vec2 slope = vec2(0.0);	curvature = vec3(0.0);	for (int i = 0; i < 12; i++) {		if (glass > 0.5 && i >= 9) break;		float index = float(i);		float angle = index * 2.39996 + 0.4;		vec2 direction = vec2(cos(angle), sin(angle));		float frequency = 0.016 * pow(1.28, index);		float amplitude = 9.0 * pow(0.76, index);		float speed = glass > 0.5 ? 0.3 + index * 0.09 : 0.25 + fract(sin(index * 17.13 + 1.7) * 437.58) * 0.65;		float packet = sin(dot(point, vec2(-direction.y, direction.x)) * 0.008 + index * 2.7 - time * 0.21);		amplitude *= glass > 0.5 ? 1.0 : 1.0 + packet * 0.22;		if (i >= 9) {			frequency *= 1.5;			amplitude *= 0.45;		}		float phase = dot(p, direction) * frequency + time * speed + index * 12.7;		slope += direction * cos(phase) * amplitude * frequency;		if (i < 9) {			curvature -= sin(phase) * amplitude * frequency * frequency * vec3(direction.x * direction.x, direction.x * direction.y, direction.y * direction.y);		}	}	if (glass > 0.5) return slope;	for (int i = 0; i < 3; i++) {		float index = float(i);		float lifetime = 7.0 + index * 1.3;		float cycle = time / lifetime + index * 0.37;		float generation = floor(cycle);		float age = fract(cycle) * lifetime;		vec2 seed = vec2(generation + index * 13.7, generation * 1.3 + index * 8.9);		vec2 origin = fract(sin(seed + vec2(5.2, 9.1)) * vec2(43758.54, 22578.14));		origin *= vec2(surfaceSize.x, surfaceSize.y * 0.46);		vec2 delta = point - origin;		float radius = length(delta);		float front = radius - age * (48.0 + index * 9.0);		float envelope = exp(-front * front / 7200.0) * smoothstep(0.0, 0.8, age) * exp(-age * 0.45) * (1.0 - smoothstep(lifetime - 1.0, lifetime, age));		vec2 direction = delta / max(radius, 1.0);		float phase = front * 0.07;		slope += direction * cos(phase) * envelope * 0.16;		curvature -= sin(phase) * envelope * 0.008 * vec3(direction.x * direction.x, direction.x * direction.y, direction.y * direction.y);	}	return slope;}vec2 surfaceSlope(vec2 uv) {	vec4 encoded = texture2D(water, uv) * 255.0;	return (vec2(dot(encoded.rg, vec2(256.0, 1.0)), dot(encoded.ba, vec2(256.0, 1.0))) - 32768.0) / 32767.0;}void main() {	vec2 uv = gl_FragCoord.xy / resolution;	// The opaque hero gradient covers the upper 55% of the surface.	if (glass < 0.5 && uv.y > 0.45) {		gl_FragColor = vec4(0.65, 0.8, 0.95, 1.0);		return;	}	vec2 screenPoint = uv * surfaceSize;	float dotPitch = mix(5.0, 7.0, step(700.0, surfaceSize.x));	vec2 point = halftone > 0.5 ? (floor(screenPoint / dotPitch) + 0.5) * dotPitch : screenPoint;	uv = point / surfaceSize;	vec3 curvature;	vec2 waves = waveShape(point, curvature);	vec2 wake = surfaceSlope(uv);	vec2 slope = waves + wake * 1.1;	if (glass > 0.5) {		float cover = max(surfaceSize.x / imageSize.x, surfaceSize.y / imageSize.y) * 1.04;		vec2 renderedSize = imageSize * cover;		vec2 imageUv = (point + slope * 25.0 + vec2((renderedSize.x - surfaceSize.x) * 0.5, 12.0)) / renderedSize;		gl_FragColor = vec4(texture2D(image, imageUv).rgb, 1.0);		return;	}	vec3 normal = normalize(vec3(-slope, 1.0));	vec3 view = normalize(vec3(0.0, 0.38, 1.0));	vec3 reflected = reflect(-view, normal);	float sky = smoothstep(-0.45, 0.65, reflected.y);	vec3 reflection = mix(vec3(0.18, 0.36, 0.67), vec3(0.85, 0.94, 1.0), sky);	float ribbon = exp(-pow((reflected.y + 0.1 + sin(reflected.x * 4.0) * 0.06) * 7.0, 2.0));	reflection += ribbon * vec3(0.28, 0.31, 0.34);	float fresnel = 0.12 + 0.38 * pow(1.0 - max(dot(normal, view), 0.0), 3.0);	vec3 bottomCurvature;	waveShape(point + slope * 65.0 + vec2(32.0, -18.0), bottomCurvature);	vec2 stepUv = vec2(5.0) / surfaceSize;	vec2 wakeX = (surfaceSlope(uv + vec2(stepUv.x, 0.0)) - surfaceSlope(uv - vec2(stepUv.x, 0.0))) / 10.0;	vec2 wakeY = (surfaceSlope(uv + vec2(0.0, stepUv.y)) - surfaceSlope(uv - vec2(0.0, stepUv.y))) / 10.0;	bottomCurvature += vec3(wakeX.x, (wakeX.y + wakeY.x) * 0.5, wakeY.y);	vec3 lens = bottomCurvature * 75.0;	float focusing = (1.0 - lens.x) * (1.0 - lens.z) - lens.y * lens.y;	float caustic = exp(-abs(focusing) * 4.5);	vec3 waterColor = mix(vec3(0.13, 0.34, 0.72), vec3(0.38, 0.67, 0.89), smoothstep(0.0, 1.0, uv.y));	waterColor += caustic * vec3(0.24, 0.34, 0.38);	waterColor *= 0.94 + dot(normal, normalize(vec3(-0.4, 0.5, 1.0))) * 0.12;	vec3 color = mix(waterColor, reflection, fresnel + ribbon * 0.2);	vec3 halfway = normalize(normalize(vec3(-0.35, 0.6, 1.0)) + view);	float glint = pow(max(dot(normal, halfway), 0.0), 160.0);	color += glint * vec3(0.2, 0.23, 0.24);	if (halftone > 0.5) {		float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722));		float darkness = 1.0 - smoothstep(0.3, 0.72, luminance);		float radius = dotPitch * mix(0.06, 0.46, pow(darkness, 1.4));		float distanceToCenter = length(screenPoint - point);		float feather = max(surfaceSize.x / resolution.x, 0.65) * 0.5;		float dotCoverage = 1.0 - smoothstep(radius - feather, radius + feather, distanceToCenter);		vec3 paper = mix(color, vec3(0.9, 0.95, 1.0), 0.55);		vec3 ink = mix(vec3(0.055, 0.18, 0.53), color, 0.6);		color = mix(paper, ink, dotCoverage);	}	gl_FragColor = vec4(color, 1.0);}`;function compileShader(gl: WebGLRenderingContext, type: number, source: string) {  const shader = gl.createShader(type);  if (!shader) return null;  gl.shaderSource(shader, source);  gl.compileShader(shader);  if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;  gl.deleteShader(shader);  return null;}function createProgram(gl: WebGLRenderingContext) {  const vertex = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);  const fragment = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);  const program = gl.createProgram();  if (vertex && fragment && program) {    gl.attachShader(program, vertex);    gl.attachShader(program, fragment);    gl.linkProgram(program);  }  if (vertex) gl.deleteShader(vertex);  if (fragment) gl.deleteShader(fragment);  if (program && gl.getProgramParameter(program, gl.LINK_STATUS)) return program;  if (program) gl.deleteProgram(program);  return null;}export type HeroWaterAppearance = "water" | "halftone";/** Starts the water on the canvas, following the pointer over `surface`. Returns a dispose function, or nothing when WebGL is unavailable. */export function createHeroWater(canvas: HTMLCanvasElement, surface: HTMLElement, onLiveChange?: (live: boolean) => void, appearance: HeroWaterAppearance = "water") {  const gl = canvas.getContext("webgl", { alpha: false, antialias: false, depth: false, powerPreference: "low-power" });  if (!gl) return;  const program = createProgram(gl);  if (!program) return;  const buffer = gl.createBuffer();  const texture = gl.createTexture();  gl.useProgram(program);  gl.uniform1f(gl.getUniformLocation(program, "glass"), 0);  gl.uniform1f(gl.getUniformLocation(program, "halftone"), appearance === "halftone" ? 1 : 0);  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);  const position = gl.getAttribLocation(program, "position");  gl.enableVertexAttribArray(position);  gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);  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, gl.CLAMP_TO_EDGE);  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);  const uniforms = {    resolution: gl.getUniformLocation(program, "resolution"),    surfaceSize: gl.getUniformLocation(program, "surfaceSize"),    imageSize: gl.getUniformLocation(program, "imageSize"),    time: gl.getUniformLocation(program, "time"),    water: gl.getUniformLocation(program, "water"),  };  const waterTexture = gl.createTexture();  gl.activeTexture(gl.TEXTURE1);  gl.bindTexture(gl.TEXTURE_2D, waterTexture);  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, gl.CLAMP_TO_EDGE);  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);  gl.uniform1i(uniforms.water, 1);  gl.activeTexture(gl.TEXTURE0);  let simulation = createWaterSimulation(1, 1);  let pointer: { x: number; y: number } | undefined;  let previousPointer: { x: number; y: number } | undefined;  let frame = 0;  let lastFrame = 0;  let elapsed = 0;  let visible = false;  let ready = false;  let disposed = false;  let live = false;  let bounds = canvas.getBoundingClientRect();  function resize() {    bounds = canvas.getBoundingClientRect();    const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5, Math.sqrt(900_000 / Math.max(bounds.width * bounds.height, 1)));    canvas.width = Math.max(1, Math.round(bounds.width * pixelRatio));    canvas.height = Math.max(1, Math.round(bounds.height * pixelRatio));    simulation = createWaterSimulation(Math.max(1, bounds.width), Math.max(1, bounds.height));    resetPointer();    gl!.activeTexture(gl!.TEXTURE1);    gl!.pixelStorei(gl!.UNPACK_FLIP_Y_WEBGL, false);    gl!.texImage2D(gl!.TEXTURE_2D, 0, gl!.RGBA, simulation.columns, simulation.rows, 0, gl!.RGBA, gl!.UNSIGNED_BYTE, simulation.advance(0));    gl!.activeTexture(gl!.TEXTURE0);    gl!.viewport(0, 0, canvas.width, canvas.height);    gl!.uniform2f(uniforms.resolution, canvas.width, canvas.height);    gl!.uniform2f(uniforms.surfaceSize, bounds.width, bounds.height);  }  function render(now: number) {    frame = requestAnimationFrame(render);    if (now - lastFrame < 1000 / 60 - 1) return;    const seconds = Math.min((now - lastFrame) / 1000, 0.05);    elapsed += seconds;    if (pointer) {      const from = previousPointer ?? pointer;      const distance = Math.hypot(pointer.x - from.x, pointer.y - from.y);      if (!previousPointer || distance > 0.1) {        const strength = previousPointer ? Math.min(1.0, distance * 0.04) : 0.55;        simulation.disturb(from.x, from.y, pointer.x, pointer.y, strength);      }      previousPointer = { ...pointer };    }    gl!.activeTexture(gl!.TEXTURE1);    gl!.texSubImage2D(gl!.TEXTURE_2D, 0, 0, 0, simulation.columns, simulation.rows, gl!.RGBA, gl!.UNSIGNED_BYTE, simulation.advance(seconds));    gl!.activeTexture(gl!.TEXTURE0);    lastFrame = now;    gl!.uniform1f(uniforms.time, elapsed);    gl!.drawArrays(gl!.TRIANGLES, 0, 6);    if (!live) {      canvas.style.opacity = "1";      live = true;      onLiveChange?.(true);    }  }  function syncPlayback() {    cancelAnimationFrame(frame);    resetPointer();    if (!disposed && ready && visible && !document.hidden) {      lastFrame = performance.now();      frame = requestAnimationFrame(render);    }  }  function resetPointer() {    pointer = undefined;    previousPointer = undefined;  }  function onPointerMove(event: PointerEvent) {    if (event.pointerType === "touch" || !ready || !visible || document.hidden) return;    bounds = canvas.getBoundingClientRect();    const x = event.clientX - bounds.left;    const y = bounds.bottom - event.clientY;    if (x < 0 || x > bounds.width || y < 0 || y > bounds.height) {      resetPointer();      return;    }    pointer = { x, y };  }  function onClick(event: MouseEvent) {    if (event.button !== 0 || event.detail === 0 || !ready || !visible || document.hidden) return;    const rect = canvas.getBoundingClientRect();    const x = event.clientX - rect.left;    const y = rect.bottom - event.clientY;    if (x < 0 || x > rect.width || y < 0 || y > rect.height) return;    simulation.disturb(x, y, x, y, 2.6, 28);  }  function onContextLost(event: Event) {    event.preventDefault();    ready = false;    canvas.style.opacity = "0";    if (live) {      live = false;      onLiveChange?.(false);    }    syncPlayback();  }  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 255]));  resize();  ready = true;  const resizeObserver = new ResizeObserver(resize);  resizeObserver.observe(canvas);  const intersectionObserver = new IntersectionObserver(([entry]) => {    visible = entry.isIntersecting;    syncPlayback();  });  intersectionObserver.observe(canvas);  surface.addEventListener("pointermove", onPointerMove, { passive: true });  surface.addEventListener("click", onClick, { passive: true });  surface.addEventListener("pointerleave", resetPointer);  surface.addEventListener("pointercancel", resetPointer);  document.addEventListener("visibilitychange", syncPlayback);  canvas.addEventListener("webglcontextlost", onContextLost);  return () => {    disposed = true;    cancelAnimationFrame(frame);    resizeObserver.disconnect();    intersectionObserver.disconnect();    surface.removeEventListener("pointermove", onPointerMove);    surface.removeEventListener("click", onClick);    surface.removeEventListener("pointerleave", resetPointer);    surface.removeEventListener("pointercancel", resetPointer);    document.removeEventListener("visibilitychange", syncPlayback);    canvas.removeEventListener("webglcontextlost", onContextLost);    canvas.style.opacity = "0";    if (live) onLiveChange?.(false);    gl.deleteTexture(texture);    gl.deleteTexture(waterTexture);    gl.deleteBuffer(buffer);    gl.deleteProgram(program);  };}
components/visuals/hero-water-simulation.ts
const CELL_SIZE = 5;const STEP_SECONDS = 1 / 60;export function createWaterSimulation(width: number, height: number) {  const columns = Math.max(3, Math.min(320, Math.ceil(width / CELL_SIZE)));  const rows = Math.max(3, Math.min(200, Math.ceil(height / CELL_SIZE)));  const cellWidth = width / columns;  const cellHeight = height / rows;  const propagationX = 0.22 * (Math.min(cellWidth, cellHeight) / cellWidth) ** 2;  const propagationY = 0.22 * (Math.min(cellWidth, cellHeight) / cellHeight) ** 2;  let current = new Float32Array(columns * rows);  let previous = new Float32Array(columns * rows);  const pixels = new Uint8Array(columns * rows * 4);  const damping = new Float32Array(columns * rows);  let accumulatedTime = 0;  for (let y = 0; y < rows; y++) {    for (let x = 0; x < columns; x++) {      const edgeDistance = Math.min(x, y, columns - x - 1, rows - y - 1);      damping[y * columns + x] = 0.985 - Math.max(0, 1 - edgeDistance / 12) * 0.12;    }  }  function disturb(fromX: number, fromY: number, toX: number, toY: number, strength: number, radius = 22) {    const deltaX = toX - fromX;    const deltaY = toY - fromY;    const lengthSquared = deltaX * deltaX + deltaY * deltaY;    const startX = Math.max(1, Math.floor((Math.min(fromX, toX) - radius * 2) / cellWidth));    const endX = Math.min(columns - 2, Math.ceil((Math.max(fromX, toX) + radius * 2) / cellWidth));    const startY = Math.max(1, Math.floor((Math.min(fromY, toY) - radius * 2) / cellHeight));    const endY = Math.min(rows - 2, Math.ceil((Math.max(fromY, toY) + radius * 2) / cellHeight));    for (let y = startY; y <= endY; y++) {      for (let x = startX; x <= endX; x++) {        const pointX = (x + 0.5) * cellWidth - fromX;        const pointY = (y + 0.5) * cellHeight - fromY;        const along = lengthSquared > 0 ? Math.max(0, Math.min(1, (pointX * deltaX + pointY * deltaY) / lengthSquared)) : 0;        const distanceSquared = (pointX - deltaX * along) ** 2 + (pointY - deltaY * along) ** 2;        const falloff = Math.exp(-distanceSquared / (radius * radius * 0.5));        const index = y * columns + x;        previous[index] += strength * falloff;      }    }  }  function advance(seconds: number) {    accumulatedTime += Math.min(seconds, 0.05);    while (accumulatedTime >= STEP_SECONDS) {      for (let y = 1; y < rows - 1; y++) {        for (let x = 1; x < columns - 1; x++) {          const index = y * columns + x;          const height = current[index];          const acceleration =            (current[index - 1] + current[index + 1] - height * 2) * propagationX + (current[index - columns] + current[index + columns] - height * 2) * propagationY;          const edgeAbsorption = 1 - (0.985 - damping[index]) * 0.4;          previous[index] = (height + (height - previous[index]) * damping[index] + acceleration) * edgeAbsorption;        }      }      [current, previous] = [previous, current];      accumulatedTime -= STEP_SECONDS;    }    for (let y = 0; y < rows; y++) {      for (let x = 0; x < columns; x++) {        const index = y * columns + x;        const slopeX = (current[y * columns + Math.min(columns - 1, x + 1)] - current[y * columns + Math.max(0, x - 1)]) / (2 * cellWidth);        const slopeY = (current[Math.min(rows - 1, y + 1) * columns + x] - current[Math.max(0, y - 1) * columns + x]) / (2 * cellHeight);        const encodedX = Math.round(Math.max(0, Math.min(65535, 32768 + slopeX * 32767)));        const encodedY = Math.round(Math.max(0, Math.min(65535, 32768 + slopeY * 32767)));        pixels[index * 4] = encodedX >> 8;        pixels[index * 4 + 1] = encodedX & 255;        pixels[index * 4 + 2] = encodedY >> 8;        pixels[index * 4 + 3] = encodedY & 255;      }    }    return pixels;  }  return { columns, rows, disturb, advance };}

Reference: src/components/sections/shared/hero-water.tsx; src/components/sections/shared/hero-water-renderer.ts; src/components/sections/shared/hero-water-simulation.ts; src/components/sections/shared/hero-background.tsx.