ネイティブ WebGL · ローカル実習 · AI モデル不使用
3 つの波から水面を作る
波の高さと速さを変え、メッシュを確認し、ブラウザーが実際に描画要求した内容を記録として保存します。無料のローカルデモで、ネイティブ WebGL を使います。AI モデルは使いません。
シェーダーは GPU で実行する小さなプログラムです。ここでは頂点シェーダーが 3 つの正弦波を足し合わせて格子を上下に動かします。フラグメントシェーダーは解析的に計算した法線を補間し、仮の水深による色と簡単な照明で水面を描きます。見た目のために選んだ近似であり、きれいに見えても物理的に正しい海とは限りません。
入力
シーン内の任意単位で表す波高の倍率です。0 では格子が平らになります。
波の位相が進む速度の倍率です。0 では現在の波形が止まります。
メッシュの縦横の辺を表示し、水面と形状データの関係を確認できます。
未実行です。ローカルの WebGL canvas を初期化しています…
描画バッファーは canvas の寸法に合わせて変わります。窓を狭くした場合と広くした場合を比べてください。ピクセル比の上限は 2、形状は 112 × 112 の格子のままです。
WebGL コマンドの描画要求が成功すると記録を保存できます。GPU の完了時間は未計測(null)です。この実習はトークン使用量、モデル推論、有料 API の費用を計測しません。
出力
- ローカルの canvas に描かれる、水面風の高さ場。
- 設定・変更履歴・描画要求フレーム数・描画バッファー寸法・ブラウザーバージョン・JavaScript の描画要求時間を含む JSON 実行記録。
一つずつ変えてみる
- 高さ 0.18、速さ 0.6 で開始し、波の動きを観察してから停止します。
- 高さを 0 にします。格子が平らになり、残った色の違いは照明や視点によるものだと確認できます。
- 高さを戻して格子を表示し、変形した形状と陰影の付いた水面を比べます。
- 速さを 0 にして高さを変えます。静止画なら形状の設定とアニメーションの設定を分けて確認できます。
- 少なくとも 1 フレームの描画要求が成功したら記録を保存します。見た目の観察メモも別に残してください。記録だけでは外観の品質は判断できません。
結果の確認
- 高さ 0 で平面になるか、速さ 0 で位相が止まるか、停止で形が固定されるかを確認します。
- 陰影表示と格子表示を比べ、複数の画面寸法で三角形の欠落・切れ・点滅がないか確認します。
- タブを非表示にするか canvas を画面外へ移動すると自動停止します。動きを減らす設定では静止画から始まり、再生を押すと動きを有効にできます。
- WebGL の初期化やシェーダーに失敗した場合はエラーを表示します。記録には実際に成功した描画要求が必要で、実行済みの実験として事前入力しません。
この近似の限界
- 高さ場の説明用デモで、流体シミュレーションではありません。反射・屈折・泡・岸辺・衝突・テクスチャは実装していません。
- 水深による色は仮の平らな底を使います。実際の水深を計測したり、水中の光の伝わり方を計算したりはしません。
- 法線は頂点で波の微分から解析的に計算し、各フラグメントで補間・正規化します。格子の細かさによって形状の細部には限界があります。
- タイマーは WebGL コマンドを要求する JavaScript の処理を計測します。GPU の完了時間やモデルの推論時間は含みません。アニメーション周期はブラウザーのスケジュールや表示環境にも左右されます。
- 記録は入力変更を含むこのページのセッションを集計し、設定履歴を最大 100 件残します。実行の記録であり、ハードウェアのベンチマークや独立した画質検証ではありません。
- 処理が遅れた後のシミュレーション刻みは 0.1 秒を上限とし、ピクセル比は 2 を上限とします。ブラウザーの実装によって画像や時間は変わります。
コードを読む
下のレンダラーは元の教材デモを保持しています。ページ枠に Learn への導線、言語切替、テーマ設定、入力値の表示を追加しました。シェーダーと実行記録の仕様は変更していません。
レンダラーのソースを表示
// 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 };
}
参考資料
- WebGL tutorial · MDN contributors / Mozilla
原本での参考資料確認日: 2026-10-03 - WebGL 1.0 specification (editor's draft) · Khronos Group
原本での参考資料確認日: 2026-10-03 - WebGL best practices · MDN contributors / Mozilla
原本での参考資料確認日: 2026-10-03 - HTMLCanvasElement: webglcontextlost event · MDN contributors / Mozilla
原本での参考資料確認日: 2026-10-03