history.wgsl
@group(0) @binding(0) var sceneTexture: texture_2d<f32>;
@group(0) @binding(1) var previousTexture: texture_2d<f32>;
@group(0) @binding(2) var nextTexture: texture_storage_2d<rgba8unorm, write>;
// time, delta seconds, half-life seconds, aspect ratio
@group(0) @binding(3) var<uniform> options: vec4f;
@compute @workgroup_size(8, 8, 1)
fn computeMain(@builtin(global_invocation_id) id: vec3u) {
let size = textureDimensions(nextTexture);
if (any(id.xy >= size)) { return; }
let p = vec2i(id.xy);
let weights = array<f32, 3>(1.0, 2.0, 1.0);
var history = vec3f(0.0);
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let q = clamp(p + vec2i(x, y), vec2i(0), vec2i(size) - 1);
history += textureLoad(previousTexture, q, 0).rgb * weights[u32(x + 1)] * weights[u32(y + 1)];
}
}
let retention = exp2(-options.y / options.z);
let scene = textureLoad(sceneTexture, p, 0).rgb;
let color = min(history / 16.0 * retention + scene * options.y * 12.0, vec3f(1.0));
textureStore(nextTexture, p, vec4f(color, 1.0));
}
present.wgsl
@group(0) @binding(0) var sourceTexture: texture_2d<f32>;
@group(0) @binding(1) var sourceSampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) uv: vec2f,
};
@vertex fn vertexMain(@builtin(vertex_index) index: u32) -> VertexOutput {
let p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
var output: VertexOutput;
output.position = vec4f(p[index], 0.0, 1.0);
output.uv = vec2f(p[index].x * 0.5 + 0.5, 0.5 - p[index].y * 0.5);
return output;
}
@fragment fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let color = textureSample(sourceTexture, sourceSampler, input.uv).rgb;
return vec4f(color + vec3f(0.015, 0.025, 0.035), 1.0);
}
scene.wgsl
@group(0) @binding(0) var<uniform> options: vec4f;
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) uv: vec2f,
};
@vertex fn vertexMain(@builtin(vertex_index) index: u32) -> VertexOutput {
let p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
var output: VertexOutput;
output.position = vec4f(p[index], 0.0, 1.0);
output.uv = vec2f(p[index].x * 0.5 + 0.5, 0.5 - p[index].y * 0.5);
return output;
}
@fragment fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let t = options.x;
let p = (input.uv - 0.5) * vec2f(options.w, 1.0);
let centers = array<vec2f, 3>(
vec2f(0.55 * cos(t), 0.30 * sin(t * 1.3)),
vec2f(0.45 * sin(t * 0.7 + 2.0), 0.32 * cos(t * 1.1)),
vec2f(0.60 * cos(t * 0.9 + 4.0), 0.25 * sin(t * 1.7 + 1.0))
);
let colors = array<vec3f, 3>(vec3f(0.25, 0.85, 1.0), vec3f(0.80, 0.60, 1.0), vec3f(1.0, 0.75, 0.30));
var color = vec3f(0.0);
for (var i = 0u; i < 3u; i++) {
let d = distance(p, centers[i]);
let core = 1.0 - smoothstep(0.025, 0.035, d);
let halo = 0.12 * exp(-d * d / 0.002);
color += colors[i] * (core + halo);
}
return vec4f(color, 1.0);
}
controls.txt
<div class="test01-controls">
<button id="gpu-stop" type="button" disabled>停止</button>
<button id="gpu-clear" type="button" disabled>履歴クリア</button>
</div>
<div class="test01-controls">
<label for="gpu-speed">速度 <output id="gpu-speed-value" for="gpu-speed">1.0倍</output></label>
<input id="gpu-speed" type="range" min="0" max="3" step="0.1" value="1" disabled>
</div>
<div class="test01-controls">
<label for="gpu-half-life">残像の半減時間 <output id="gpu-half-life-value" for="gpu-half-life">0.8秒</output></label>
<input id="gpu-half-life" type="range" min="0.1" max="3" step="0.1" value="0.8" disabled>
</div>
main.js
// This Test owns its initialization, shaders and animation loop.
const button = document.querySelector('#gpu-start');
const status = document.querySelector('#gpu-status');
const canvas = document.querySelector('#gpu-canvas');
const info = document.querySelector('#gpu-info');
const stop = document.querySelector('#gpu-stop');
const clear = document.querySelector('#gpu-clear');
const speed = document.querySelector('#gpu-speed');
const halfLife = document.querySelector('#gpu-half-life');
const width = 320, height = 180;
let device, context, optionsBuffer, sceneTexture, scenePipeline, sceneGroup;
let computePipeline, presentPipeline, computeGroups, presentGroups;
let histories = [];
let generation = 0, busy = false, running = false, frame = 0;
let readIndex = 0, time = 0, lastTimestamp = null;
function detail(label, value) {
const dt = document.createElement('dt'), dd = document.createElement('dd');
dt.textContent = label;
dd.textContent = value;
info.append(dt, dd);
}
function pause() {
running = false;
cancelAnimationFrame(frame);
lastTimestamp = null;
stop.disabled = true;
button.disabled = busy;
button.textContent = device ? '再開' : '開始';
}
function release() {
pause();
context?.unconfigure();
sceneTexture?.destroy();
histories.forEach(texture => texture.destroy());
optionsBuffer?.destroy();
device?.destroy();
device = context = optionsBuffer = sceneTexture = scenePipeline = sceneGroup = undefined;
computePipeline = presentPipeline = computeGroups = presentGroups = undefined;
histories = [];
}
function fail(message) {
generation++;
busy = false;
release();
button.disabled = false;
button.textContent = '開始';
[clear, speed, halfLife].forEach(control => { control.disabled = true; });
status.textContent = `実行できませんでした: ${message}`;
}
async function shader(name, token) {
const response = await fetch(new URL(name, import.meta.url));
if (!response.ok) throw new Error(`${name}の取得に失敗しました: ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
throw new Error(`${name}: WGSLはUTF-8 BOMなしで保存してください`);
}
let code;
try { code = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
catch { throw new Error(`${name}: 不正なUTF-8です`); }
if (token !== generation) return null;
const module = device.createShaderModule({ code });
const compilation = await module.getCompilationInfo();
if (token !== generation) return null;
const errors = compilation.messages.filter(message => message.type === 'error');
if (errors.length) throw new Error(`${name}: ${errors.map(message => message.message).join('; ')}`);
return module;
}
async function initialize(token) {
if (!window.isSecureContext) throw new Error('HTTPSまたはlocalhostで開いてください');
if (!navigator.gpu) throw new Error('このブラウザ環境ではWebGPUを利用できません');
const adapter = await navigator.gpu.requestAdapter();
if (token !== generation) return false;
if (!adapter) throw new Error('利用可能なGPUアダプターを取得できませんでした');
const currentDevice = await adapter.requestDevice();
if (token !== generation) { currentDevice.destroy(); return false; }
device = currentDevice;
device.lost.then(({ reason, message }) => {
if (device === currentDevice && reason !== 'destroyed') fail(`GPUデバイスを失いました: ${message || reason}`);
});
device.addEventListener('uncapturederror', event => {
if (device === currentDevice) fail(event.error.message);
});
context = canvas.getContext('webgpu');
if (!context) throw new Error('WebGPUのCanvasを取得できませんでした');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format, alphaMode: 'opaque' });
const sceneShader = await shader('scene.wgsl', token);
if (!sceneShader) return false;
const historyShader = await shader('history.wgsl', token);
if (!historyShader) return false;
const presentShader = await shader('present.wgsl', token);
if (!presentShader) return false;
device.pushErrorScope('validation');
optionsBuffer = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
sceneTexture = device.createTexture({
size: [width, height], format: 'rgba8unorm',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
});
histories = [0, 1].map(() => device.createTexture({
size: [width, height], format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
}));
scenePipeline = await device.createRenderPipelineAsync({
layout: 'auto',
vertex: { module: sceneShader, entryPoint: 'vertexMain' },
fragment: { module: sceneShader, entryPoint: 'fragmentMain', targets: [{ format: 'rgba8unorm' }] },
});
if (token !== generation) return false;
sceneGroup = device.createBindGroup({ layout: scenePipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: { buffer: optionsBuffer } },
] });
computePipeline = await device.createComputePipelineAsync({
layout: 'auto', compute: { module: historyShader, entryPoint: 'computeMain' },
});
if (token !== generation) return false;
computeGroups = [0, 1].map(index => device.createBindGroup({
layout: computePipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: sceneTexture.createView() },
{ binding: 1, resource: histories[index].createView() },
{ binding: 2, resource: histories[1 - index].createView() },
{ binding: 3, resource: { buffer: optionsBuffer } },
],
}));
presentPipeline = await device.createRenderPipelineAsync({
layout: 'auto',
vertex: { module: presentShader, entryPoint: 'vertexMain' },
fragment: { module: presentShader, entryPoint: 'fragmentMain', targets: [{ format }] },
});
if (token !== generation) return false;
const sampler = device.createSampler({ minFilter: 'linear', magFilter: 'linear' });
presentGroups = histories.map(texture => device.createBindGroup({
layout: presentPipeline.getBindGroupLayout(0), entries: [
{ binding: 0, resource: texture.createView() }, { binding: 1, resource: sampler },
],
}));
const error = await device.popErrorScope();
if (token !== generation) return false;
if (error) throw new Error(error.message);
time = 0;
detail('GPU情報', adapter.info?.description || '機種名を取得できませんでした');
detail('Render → Compute → Render', 'シーン → 残像の更新 → Canvas');
detail('履歴テクスチャ', '320×180 rgba8unorm × 2枚 · ping-pong');
detail('Dispatch', '40×23 workgroups · 8×8 threads/group');
return true;
}
function attachment(view) {
return { view, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' };
}
function clearHistory() {
const encoder = device.createCommandEncoder();
for (const texture of histories) {
const pass = encoder.beginRenderPass({ colorAttachments: [attachment(texture.createView())] });
pass.end();
}
device.queue.submit([encoder.finish()]);
readIndex = 0;
}
function draw(delta) {
device.queue.writeBuffer(optionsBuffer, 0, new Float32Array([time, delta, Number(halfLife.value), width / height]));
const writeIndex = 1 - readIndex;
const encoder = device.createCommandEncoder();
const scene = encoder.beginRenderPass({ colorAttachments: [attachment(sceneTexture.createView())] });
scene.setPipeline(scenePipeline);
scene.setBindGroup(0, sceneGroup);
scene.draw(3);
scene.end();
const compute = encoder.beginComputePass();
compute.setPipeline(computePipeline);
compute.setBindGroup(0, computeGroups[readIndex]);
compute.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8));
compute.end();
const present = encoder.beginRenderPass({ colorAttachments: [attachment(context.getCurrentTexture().createView())] });
present.setPipeline(presentPipeline);
present.setBindGroup(0, presentGroups[writeIndex]);
present.draw(3);
present.end();
device.queue.submit([encoder.finish()]);
readIndex = writeIndex;
}
function tick(timestamp) {
if (!running) return;
// Clamp long gaps so background tabs do not jump forward on return.
const delta = lastTimestamp === null ? 1 / 60 : Math.min((timestamp - lastTimestamp) / 1000, 1 / 30);
lastTimestamp = timestamp;
time += delta * Number(speed.value);
try {
draw(delta);
frame = requestAnimationFrame(tick);
} catch (error) { fail(error.message); }
}
async function start() {
if (busy || running) return;
busy = true;
button.disabled = true;
const token = generation;
try {
if (!device) {
info.replaceChildren();
status.textContent = 'WebGPUを初期化しています';
if (!await initialize(token)) return;
device.pushErrorScope('validation');
clearHistory();
draw(1 / 60);
const error = await device.popErrorScope();
if (token !== generation) return;
if (error) throw new Error(error.message);
await device.queue.onSubmittedWorkDone();
if (token !== generation) return;
}
running = true;
lastTimestamp = null;
stop.disabled = false;
[clear, speed, halfLife].forEach(control => { control.disabled = false; });
status.textContent = '描画中 · 動く光点と残像を表示しています';
frame = requestAnimationFrame(tick);
} catch (error) {
if (token === generation) fail(error.message);
} finally {
if (token === generation) { busy = false; button.disabled = running; }
}
}
button.addEventListener('click', start);
stop.addEventListener('click', () => { pause(); status.textContent = '停止中 · 再開で続きから描画します'; });
clear.addEventListener('click', () => {
if (!device || busy) return;
try {
clearHistory();
draw(1 / 60);
lastTimestamp = null;
status.textContent = running ? '履歴をクリアしました · 描画中' : '履歴をクリアしました · 停止中';
} catch (error) { fail(error.message); }
});
speed.addEventListener('input', () => {
document.querySelector('#gpu-speed-value').textContent = `${Number(speed.value).toFixed(1)}倍`;
});
halfLife.addEventListener('input', () => {
document.querySelector('#gpu-half-life-value').textContent = `${Number(halfLife.value).toFixed(1)}秒`;
});
document.addEventListener('visibilitychange', () => {
if (document.hidden && running) { pause(); status.textContent = '停止中 · 再開で続きから描画します'; }
});
window.addEventListener('pagehide', () => {
generation++;
busy = false;
release();
button.disabled = false;
button.textContent = '開始';
[clear, speed, halfLife].forEach(control => { control.disabled = true; });
status.textContent = '開始ボタンで再初期化します';
});