Native WebGL · local exercise · no AI model

Build a water surface from three waves

Change wave height and speed, inspect the mesh, then download a record of what your browser actually submitted. This free local demo uses native WebGL and no AI model.

A shader is a small GPU program. Here the vertex shader moves a grid up and down by summing three sine waves. The fragment shader shades the surface using interpolated analytic normals, a fictional depth tint and simple lighting. These are chosen visual approximations: a convincing image does not establish a physically correct ocean.

Controls

Maximum combined height scale in arbitrary scene units. Zero flattens the grid.

Multiplies the simulated phase progression. Zero keeps the current wave shape still.

Shows horizontal and vertical mesh edges so you can connect the surface to its geometry.

Not executed yet. Initializing the local WebGL canvas…

The drawing buffer follows the canvas size. Try a narrow window and a wide window. The pixel ratio is capped at 2; the geometry remains a 112 × 112 grid.

A record becomes available after a successful WebGL command submission. GPU completion time is unmeasured (null). No token use, model inference or paid API cost is measured by this exercise.

What this produces

Try one change at a time

  1. Start with height 0.18 and speed 0.6. Observe one full cycle, then pause.
  2. Set height to zero. The grid becomes flat; any remaining color variation comes from the lighting and view terms.
  3. Restore the height and turn on the grid. Compare the displaced geometry with the shaded surface.
  4. Set speed to zero, then change height. A still frame separates the geometry setting from the animation setting.
  5. Download the record after at least one submitted frame. Save an observation of visual quality alongside it; the record itself cannot judge appearance.

Check the result

Limits of this approximation

Read the code

The renderer below is preserved from the original teaching demo. The page shell adds Learn navigation, locale links, theme controls and visible input values; the shaders and execution-record contract are unchanged.

Show renderer source
// A local, procedural teaching demo. No model, texture, library or remote service.
const GRID_SEGMENTS = 112;
const SURFACE_SIZE = 9;
const WAVES = [
  { direction: [0.86, 0.52], frequency: 1.7, weight: 0.58, speed: 1 },
  { direction: [-0.6, 0.8], frequency: 2.9, weight: 0.28, speed: 1.35 },
  { direction: [0.24, -0.97], frequency: 4.1, weight: 0.14, speed: 0.8 },
];

const VERTEX_SOURCE = `
attribute vec2 aXZ;
uniform mat4 uProjection;
uniform mat4 uView;
uniform float uTime;
uniform float uAmplitude;
varying vec3 vPosition;
varying vec3 vNormal;
void wave(vec2 direction, float frequency, float weight, float speed,
          inout float height, inout vec2 gradient) {
  vec2 d = normalize(direction);
  float phase = dot(aXZ, d) * frequency - uTime * speed;
  height += uAmplitude * weight * sin(phase);
  gradient += uAmplitude * weight * frequency * cos(phase) * d;
}
void main() {
  float height = 0.0;
  vec2 gradient = vec2(0.0);
  wave(vec2(0.86, 0.52), 1.7, 0.58, 1.0, height, gradient);
  wave(vec2(-0.6, 0.8), 2.9, 0.28, 1.35, height, gradient);
  wave(vec2(0.24, -0.97), 4.1, 0.14, 0.8, height, gradient);
  vPosition = vec3(aXZ.x, height, aXZ.y);
  vNormal = normalize(vec3(-gradient.x, 1.0, -gradient.y));
  gl_Position = uProjection * uView * vec4(vPosition, 1.0);
}`;

const fragmentSource = (precision) => `
precision ${precision} float;
uniform vec3 uCamera;
uniform float uWireframe;
varying vec3 vPosition;
varying vec3 vNormal;
void main() {
  vec3 normal = normalize(vNormal);
  vec3 view = normalize(uCamera - vPosition);
  vec3 light = normalize(vec3(-0.5, 0.9, 0.4));
  // An invented flat bottom, not measured ocean depth or optical refraction.
  float illustrativeDepth = max(0.1, 1.4 + vPosition.y);
  vec3 tint = mix(vec3(0.015, 0.15, 0.24), vec3(0.05, 0.56, 0.57),
                 exp(-illustrativeDepth * 0.75));
  float diffuse = max(dot(normal, light), 0.0);
  float rim = pow(1.0 - max(dot(normal, view), 0.0), 3.0);
  float glint = pow(max(dot(normal, normalize(light + view)), 0.0), 72.0);
  vec3 color = tint * (0.55 + diffuse * 0.65);
  color += vec3(0.25, 0.43, 0.48) * rim + vec3(0.8, 0.88, 0.79) * glint * 0.7;
  float haze = smoothstep(5.0, 14.0, length(uCamera - vPosition)) * 0.32;
  color = mix(color, vec3(0.055, 0.12, 0.18), haze);
  color = mix(color, vec3(0.33, 0.88, 0.83), uWireframe);
  gl_FragColor = vec4(color, 1.0);
}`;

const COPY = {
  en: {
    running: 'Running locally · procedural WebGL, no AI model',
    paused: 'Paused · change a control to inspect a still frame',
    reduced: 'Paused for reduced motion · press Play to start',
    hidden: 'Paused while this page is hidden',
    offscreen: 'Paused while the water surface is off screen',
    lost: 'WebGL context lost · waiting for the browser to restore it',
    unavailable: 'WebGL is unavailable. Check browser graphics settings or try another browser.',
    failed: 'WebGL rendering failed',
    play: 'Play', pause: 'Pause',
    exportFailed: 'The execution record could not be downloaded.',
    exported: 'Execution record download requested · JavaScript timing is not GPU timing',
    frames: 'submitted frames', js: 'mean JS submission',
    cadence: 'animation cadence', timing: 'CPU submission time; GPU time is not measured',
  },
  ja: {
    running: 'ローカルで実行中 · 手続き型 WebGL、AI モデルは未使用',
    paused: '停止中 · 入力を変えると静止画で確認できます',
    reduced: '動きを減らす設定により停止中 · 再生で開始できます',
    hidden: 'ページが非表示のため停止中',
    offscreen: '水面が画面外のため停止中',
    lost: 'WebGL コンテキストが失われました · ブラウザーによる復元を待っています',
    unavailable: 'WebGL を利用できません。ブラウザーの描画設定や別のブラウザーを確認してください。',
    failed: 'WebGL の描画に失敗しました',
    play: '再生', pause: '停止',
    exportFailed: '実行記録をダウンロードできませんでした。',
    exported: '実行記録のダウンロードを開始しました · JavaScript の計測値は GPU の処理時間ではありません',
    frames: '描画要求フレーム', js: '平均 JS 描画要求時間',
    cadence: 'アニメーション周期', timing: 'CPU 側の描画要求時間です。GPU 時間は未計測です',
  },
};

function perspective(aspect) {
  // Keep the full teaching mesh in view on both landscape and portrait canvases.
  const f = Math.min(1, aspect) / Math.tan(Math.PI / 8);
  const near = 0.1;
  const far = 40;
  return new Float32Array([
    f / aspect, 0, 0, 0, 0, f, 0, 0,
    0, 0, (far + near) / (near - far), -1,
    0, 0, (2 * far * near) / (near - far), 0,
  ]);
}

function lookAt(eye) {
  const normalize = (v) => { const length = Math.hypot(...v); return v.map((x) => x / length); };
  const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
  const z = normalize([eye[0], eye[1] + 0.6, eye[2]]);
  const x = normalize(cross([0, 1, 0], z));
  const y = cross(z, x);
  const dot = (a, b) => a.reduce((sum, v, i) => sum + v * b[i], 0);
  return new Float32Array([
    x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0,
    -dot(x, eye), -dot(y, eye), -dot(z, eye), 1,
  ]);
}

function makeMesh() {
  const positions = [];
  const triangles = [];
  const lines = [];
  const stride = GRID_SEGMENTS + 1;
  for (let z = 0; z <= GRID_SEGMENTS; z += 1) {
    for (let x = 0; x <= GRID_SEGMENTS; x += 1) {
      positions.push((x / GRID_SEGMENTS - 0.5) * SURFACE_SIZE,
        (z / GRID_SEGMENTS - 0.5) * SURFACE_SIZE);
      const a = z * stride + x;
      if (x < GRID_SEGMENTS) lines.push(a, a + 1);
      if (z < GRID_SEGMENTS) lines.push(a, a + stride);
      if (x < GRID_SEGMENTS && z < GRID_SEGMENTS) {
        triangles.push(a, a + stride, a + 1, a + 1, a + stride, a + stride + 1);
      }
    }
  }
  return { positions: new Float32Array(positions), triangles: new Uint16Array(triangles), lines: new Uint16Array(lines) };
}

function browserVersion() {
  // Keep only browser family/version, never the complete user-agent or GPU identity.
  const ua = navigator.userAgent;
  for (const [family, pattern] of [
    ['Edge', /Edg\/([\d.]+)/], ['Firefox', /Firefox\/([\d.]+)/],
    ['Chrome', /(?:Chrome|CriOS)\/([\d.]+)/], ['Safari', /Version\/([\d.]+).*Safari/],
  ]) {
    const match = ua.match(pattern);
    if (match) return { family, version: match[1] };
  }
  return { family: 'unknown', version: null };
}

function round(value) { return Math.round(value * 1000) / 1000; }

/** Initialize after the water page has been rendered; call dispose before replacing it. */
export function initWaterDemo(root = document) {
  const canvas = root.querySelector('#water-canvas');
  if (!canvas) return { dispose() {}, getRecord: () => null };
  const amplitudeControl = root.querySelector('#wave-amplitude');
  const speedControl = root.querySelector('#wave-speed');
  const toggle = root.querySelector('#water-toggle');
  const wireframeControl = root.querySelector('#water-wireframe');
  const status = root.querySelector('#water-status');
  const metrics = root.querySelector('#water-metrics');
  const exportButton = root.querySelector('#water-export');
  const copy = COPY[document.body.dataset.locale === 'ja' ? 'ja' : 'en'];
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
  const listeners = [];
  const mesh = makeMesh();
  const camera = [8.5, 6.4, 9.6];
  const view = lookAt(camera);
  let gl;
  let resources;
  let animationFrame = 0;
  let disposed = false;
  let contextLost = false;
  let fatalError = null;
  const initialBounds = canvas.getBoundingClientRect();
  let visible = initialBounds.bottom > 0 && initialBounds.top < window.innerHeight
    && initialBounds.right > 0 && initialBounds.left < window.innerWidth;
  let pageHidden = false;
  let userPaused = false;
  let reducedMotionOverride = false;
  let previousFrameTime = null;
  let activeStart = null;
  let activeWallMs = 0;
  let simulatedSeconds = 0;
  let lastMetricsTime = 0;
  let firstDrawCheck = true;
  let contextRestorations = 0;
  let frameCount = 0;
  let totalSubmissionMs = 0;
  let lastSubmissionMs = 0;
  let maxSubmissionMs = 0;
  let frameIntervalMs = 0;
  let frameIntervalCount = 0;
  let recentIntervals = [];
  let parameterHistory = [];
  let historyTruncated = false;
  let lastSubmittedParameters = null;
  let apiVersion = null;
  let shaderVersion = null;
  let dimensions = { cssWidth: 0, cssHeight: 0, width: 0, height: 0, pixelRatio: 1 };

  function listen(element, event, handler) {
    if (!element) return;
    element.addEventListener(event, handler);
    listeners.push(() => element.removeEventListener(event, handler));
  }

  function numericValue(control, fallback, maximum) {
    const number = control ? Number(control.value) : fallback;
    return Number.isFinite(number) ? Math.max(0, Math.min(maximum, number)) : fallback;
  }

  function parameters() {
    return {
      amplitude: numericValue(amplitudeControl, 0.18, 0.5),
      speed: numericValue(speedControl, 0.6, 2),
      wireframe: Boolean(wireframeControl?.checked),
    };
  }

  function rememberParameters() {
    const next = parameters();
    if (parameterHistory.length && JSON.stringify(parameterHistory.at(-1).values) === JSON.stringify(next)) return;
    parameterHistory.push({ afterFrame: frameCount, simulationSeconds: round(simulatedSeconds), values: next });
    if (parameterHistory.length > 100) { parameterHistory.splice(1, 1); historyTruncated = true; }
  }

  function pauseReason() {
    if (fatalError) return 'failed';
    if (contextLost) return 'lost';
    if (document.hidden || pageHidden) return 'hidden';
    if (!visible) return 'offscreen';
    if (userPaused) return 'paused';
    if (reducedMotion.matches && !reducedMotionOverride) return 'reduced';
    return null;
  }

  function updateStatus(message) {
    const reason = pauseReason();
    if (status) status.textContent = message || (fatalError ? `${copy.failed}: ${fatalError}` : copy[reason || 'running']);
    if (toggle) {
      toggle.textContent = reason ? copy.play : copy.pause;
      toggle.setAttribute('aria-pressed', String(!reason));
      toggle.disabled = Boolean(fatalError || contextLost);
    }
    if (exportButton) exportButton.disabled = frameCount === 0;
  }

  function updateMetrics() {
    if (!metrics) return;
    const mean = frameCount ? totalSubmissionMs / frameCount : 0;
    const cadence = recentIntervals.length ? 1000 / (recentIntervals.reduce((sum, x) => sum + x, 0) / recentIntervals.length) : null;
    metrics.textContent = `${frameCount.toLocaleString()} ${copy.frames} · ${dimensions.width} × ${dimensions.height} px · `
      + `${copy.js} ${mean.toFixed(2)} ms`
      + (cadence && !pauseReason() ? ` · ${copy.cadence} ${cadence.toFixed(1)}/s` : '')
      + `. ${copy.timing}.`;
  }

  function stopLoop() {
    if (animationFrame) cancelAnimationFrame(animationFrame);
    animationFrame = 0;
    previousFrameTime = null;
    recentIntervals = [];
    if (activeStart !== null) { activeWallMs += performance.now() - activeStart; activeStart = null; }
  }

  function fail(error) {
    fatalError = String(error?.message || error);
    stopLoop();
    updateStatus();
    updateMetrics();
  }

  function createResources() {
    const shaders = [];
    const buffers = [];
    let program;
    try {
      const precision = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT);
      for (const [type, source] of [
        [gl.VERTEX_SHADER, VERTEX_SOURCE],
        [gl.FRAGMENT_SHADER, fragmentSource(precision?.precision > 0 ? 'highp' : 'mediump')],
      ]) {
        const shader = gl.createShader(type);
        if (!shader) throw new Error('Shader allocation failed');
        shaders.push(shader);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader) || 'Shader compilation failed');
      }
      program = gl.createProgram();
      if (!program) throw new Error('Program allocation failed');
      shaders.forEach((shader) => gl.attachShader(program, shader));
      gl.linkProgram(program);
      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program) || 'Shader linking failed');
      const upload = (target, data) => {
        const buffer = gl.createBuffer();
        if (!buffer) throw new Error('Buffer allocation failed');
        buffers.push(buffer);
        gl.bindBuffer(target, buffer);
        gl.bufferData(target, data, gl.STATIC_DRAW);
        return buffer;
      };
      const position = upload(gl.ARRAY_BUFFER, mesh.positions);
      const triangle = upload(gl.ELEMENT_ARRAY_BUFFER, mesh.triangles);
      const line = upload(gl.ELEMENT_ARRAY_BUFFER, mesh.lines);
      const uniforms = {};
      for (const name of ['uProjection', 'uView', 'uTime', 'uAmplitude', 'uCamera', 'uWireframe']) {
        uniforms[name] = gl.getUniformLocation(program, name);
        if (uniforms[name] === null) throw new Error(`Missing shader uniform ${name}`);
      }
      const attribute = gl.getAttribLocation(program, 'aXZ');
      if (attribute < 0) throw new Error('Missing shader attribute aXZ');
      resources = { program, position, triangle, line, attribute, uniforms, buffers };
      gl.enable(gl.DEPTH_TEST);
      gl.clearColor(0.055, 0.12, 0.18, 1);
      apiVersion = gl.getParameter(gl.VERSION);
      shaderVersion = gl.getParameter(gl.SHADING_LANGUAGE_VERSION);
      firstDrawCheck = true;
    } catch (error) {
      buffers.forEach((buffer) => gl.deleteBuffer(buffer));
      if (program) gl.deleteProgram(program);
      throw error;
    } finally {
      shaders.forEach((shader) => gl.deleteShader(shader));
    }
  }

  function resize() {
    const rect = canvas.getBoundingClientRect();
    const ratio = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
    const width = Math.max(1, Math.round(rect.width * ratio));
    const height = Math.max(1, Math.round(rect.height * ratio));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
    }
    dimensions = {
      cssWidth: round(rect.width), cssHeight: round(rect.height),
      width: gl.drawingBufferWidth, height: gl.drawingBufferHeight, pixelRatio: ratio,
    };
    gl.viewport(0, 0, dimensions.width, dimensions.height);
  }

  function draw(timestamp) {
    if (disposed || contextLost || fatalError || !resources) return;
    const started = performance.now();
    try {
      resize();
      const values = parameters();
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
      gl.useProgram(resources.program);
      gl.bindBuffer(gl.ARRAY_BUFFER, resources.position);
      gl.enableVertexAttribArray(resources.attribute);
      gl.vertexAttribPointer(resources.attribute, 2, gl.FLOAT, false, 0, 0);
      gl.uniformMatrix4fv(resources.uniforms.uProjection, false, perspective(dimensions.width / dimensions.height));
      gl.uniformMatrix4fv(resources.uniforms.uView, false, view);
      gl.uniform1f(resources.uniforms.uTime, simulatedSeconds);
      gl.uniform1f(resources.uniforms.uAmplitude, values.amplitude);
      gl.uniform3fv(resources.uniforms.uCamera, camera);
      gl.uniform1f(resources.uniforms.uWireframe, values.wireframe ? 1 : 0);
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, values.wireframe ? resources.line : resources.triangle);
      gl.drawElements(values.wireframe ? gl.LINES : gl.TRIANGLES,
        values.wireframe ? mesh.lines.length : mesh.triangles.length, gl.UNSIGNED_SHORT, 0);
      // Validate the first submission after resource creation, without a blocking query every frame.
      if (firstDrawCheck) {
        const error = gl.getError();
        if (error !== gl.NO_ERROR) throw new Error(`WebGL error 0x${error.toString(16)}`);
        firstDrawCheck = false;
      }
      lastSubmissionMs = performance.now() - started;
      totalSubmissionMs += lastSubmissionMs;
      maxSubmissionMs = Math.max(maxSubmissionMs, lastSubmissionMs);
      frameCount += 1;
      lastSubmittedParameters = { ...values };
      if (timestamp - lastMetricsTime > 500 || frameCount === 1 || pauseReason()) {
        updateMetrics();
        lastMetricsTime = timestamp;
      }
      if (exportButton) exportButton.disabled = false;
    } catch (error) { fail(error); }
  }

  function frame(timestamp) {
    animationFrame = 0;
    if (disposed || pauseReason()) { stopLoop(); updateStatus(); return; }
    if (previousFrameTime !== null) {
      const elapsed = Math.max(0, timestamp - previousFrameTime);
      frameIntervalMs += elapsed;
      frameIntervalCount += 1;
      recentIntervals.push(elapsed);
      if (recentIntervals.length > 90) recentIntervals.shift();
      // Avoid a large simulation jump if the browser stalls between callbacks.
      simulatedSeconds += Math.min(elapsed / 1000, 0.1) * parameters().speed;
    }
    previousFrameTime = timestamp;
    draw(timestamp);
    if (!disposed && !pauseReason()) animationFrame = requestAnimationFrame(frame);
  }

  function reconcile() {
    if (disposed) return;
    if (pauseReason()) stopLoop();
    else if (!animationFrame) {
      activeStart = performance.now();
      animationFrame = requestAnimationFrame(frame);
    }
    updateStatus();
    updateMetrics();
  }

  function getRecord() {
    if (!frameCount) return null;
    return {
      demoWaterVersion1: 1,
      recordedAt: new Date().toISOString(),
      status: 'executed',
      executionState: pauseReason() || 'running',
      procedure: {
        id: 'procedural-water-v1', model: 'none', execution: 'local-procedural',
        sourceFiles: ['apps/academy/src/water.js', 'apps/academy/content/creative/demos/water.json'],
        method: '112×112 grid; three summed sine waves; analytic height gradients; interpolated normals; illustrative depth tint and lighting',
      },
      parameters: { ...lastSubmittedParameters, gridSegments: GRID_SEGMENTS, surfaceSize: SURFACE_SIZE, waves: WAVES },
      controlsAtExport: parameters(),
      parameterHistory,
      parameterHistoryTruncated: historyTruncated,
      metrics: {
        scope: 'current page session; aggregates may include different parameter settings',
        submittedFrames: frameCount,
        activeAnimationWallSeconds: round((activeWallMs + (activeStart === null ? 0 : performance.now() - activeStart)) / 1000),
        simulationSeconds: round(simulatedSeconds),
        meanJsSubmissionMs: round(totalSubmissionMs / frameCount),
        lastJsSubmissionMs: round(lastSubmissionMs),
        maxJsSubmissionMs: round(maxSubmissionMs),
        meanAnimationCallbackIntervalMs: frameIntervalCount ? round(frameIntervalMs / frameIntervalCount) : null,
        animationCallbackIntervalSamples: frameIntervalCount,
        gpuExecutionMs: null,
        surface: { ...dimensions },
        vertices: mesh.positions.length / 2,
        triangles: mesh.triangles.length / 3,
        contextRestorations,
      },
      environment: {
        browser: browserVersion(), webglVersion: apiVersion, shadingLanguageVersion: shaderVersion,
        reducedMotionPreference: reducedMotion.matches,
      },
      limitations: [
        'This is an illustrative height-field approximation, not fluid physics.',
        'No optical reflection, refraction, foam, shoreline, collision or texture is simulated.',
        'Depth tint assumes an invented flat bottom, not measured bathymetry.',
        'CPU JavaScript submission timing does not measure GPU completion or model inference.',
        'Animation callback intervals include browser scheduling and display effects; this is not a hardware benchmark.',
        'Simulation steps are capped at 0.1 seconds after stalls; browser results may differ.',
        'Submission counts confirm WebGL commands were issued, not an independent visual-quality test.',
      ],
      failure: fatalError,
    };
  }

  function exportRecord() {
    const record = getRecord();
    if (!record) return;
    let url;
    let anchor;
    try {
      url = URL.createObjectURL(new Blob([JSON.stringify(record, null, 2)], { type: 'application/json' }));
      anchor = document.createElement('a');
      anchor.href = url;
      anchor.download = `kumyu-water-${record.recordedAt.replace(/[:.]/g, '-')}.json`;
      document.body.append(anchor);
      anchor.click();
      updateStatus(copy.exported);
    } catch { updateStatus(copy.exportFailed); }
    finally {
      anchor?.remove();
      if (url) window.setTimeout(() => URL.revokeObjectURL(url), 1000);
    }
  }

  listen(toggle, 'click', () => {
    if (userPaused || (reducedMotion.matches && !reducedMotionOverride)) {
      userPaused = false;
      reducedMotionOverride = true;
    } else userPaused = true;
    reconcile();
  });
  for (const control of [amplitudeControl, speedControl, wireframeControl]) {
    listen(control, 'input', () => {
      rememberParameters();
      if (!document.hidden && visible) draw(performance.now());
      updateMetrics();
    });
  }
  listen(exportButton, 'click', exportRecord);
  listen(document, 'visibilitychange', reconcile);
  listen(reducedMotion, 'change', () => { reducedMotionOverride = false; reconcile(); });
  listen(canvas, 'webglcontextlost', (event) => {
    event.preventDefault();
    contextLost = true;
    resources = null;
    reconcile();
  });
  listen(canvas, 'webglcontextrestored', () => {
    contextLost = false;
    fatalError = null;
    contextRestorations += 1;
    try {
      createResources();
      if (!document.hidden && visible) draw(performance.now());
      reconcile();
    } catch (error) { fail(error); }
  });
  listen(window, 'pagehide', (event) => {
    // Keep the initialized page usable when restored from the back/forward cache.
    if (event.persisted) { pageHidden = true; reconcile(); }
    else dispose();
  });
  listen(window, 'pageshow', () => { pageHidden = false; reconcile(); });

  let intersectionObserver;
  if ('IntersectionObserver' in window) {
    intersectionObserver = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && !document.hidden && pauseReason()) draw(performance.now());
      reconcile();
    }, { threshold: 0.01 });
    intersectionObserver.observe(canvas);
  }
  let resizeObserver;
  const onResize = () => {
    if (!disposed && !document.hidden && visible) { draw(performance.now()); updateMetrics(); }
  };
  if ('ResizeObserver' in window) {
    resizeObserver = new ResizeObserver(onResize);
    resizeObserver.observe(canvas);
  } else listen(window, 'resize', onResize);

  function dispose() {
    if (disposed) return;
    disposed = true;
    stopLoop();
    listeners.forEach((remove) => remove());
    intersectionObserver?.disconnect();
    resizeObserver?.disconnect();
    if (resources && !contextLost) {
      resources.buffers.forEach((buffer) => gl.deleteBuffer(buffer));
      gl.deleteProgram(resources.program);
    }
    resources = null;
  }

  rememberParameters();
  if (exportButton) exportButton.disabled = true;
  try {
    gl = canvas.getContext('webgl', { alpha: false, antialias: true, depth: true, preserveDrawingBuffer: false });
    if (!gl) {
      fatalError = copy.unavailable;
      updateStatus(copy.unavailable);
      if (toggle) toggle.disabled = true;
    } else {
      createResources();
      if (!document.hidden && visible) draw(performance.now());
      reconcile();
    }
  } catch (error) { fail(error); }
  return { dispose, getRecord };
}

Reference material