generate.wgsl

@group(0) @binding(0) var outputTexture: texture_storage_2d<rgba8unorm, write>;
@compute @workgroup_size(8, 8, 1)
fn computeMain(@builtin(global_invocation_id) id: vec3u) {
    let size = textureDimensions(outputTexture);
    if (any(id.xy >= size)) { return; }
    let uv = (vec2f(id.xy) + 0.5) / vec2f(size);
    let pi = 3.141592653589793;
    let wave = sin(uv.y * 8.0 * pi + 0.4 * cos(uv.x * 2.0 * pi) * sin(uv.y * pi));
    let band = smoothstep(-0.25, 0.25, wave);
    // Linear reflectance values, not sRGB-encoded colors.
    let color = mix(vec3f(0.42, 0.16, 0.065), vec3f(0.58, 0.27, 0.11), band);
    textureStore(outputTexture, vec2i(id.xy), vec4f(color, 1.0));
}

lighting.wgsl

struct Parameters {
    viewProjection: mat4x4f,
    camera: vec4f,
    lightDirection: vec4f,
    material: vec4f, // roughness, metallic, use texture, display mode
    baseColorAndLight: vec4f, // constant linear base color, light intensity
};
@group(0) @binding(0) var<uniform> parameters: Parameters;
@group(1) @binding(0) var albedoTexture: texture_2d<f32>;
@group(1) @binding(1) var albedoSampler: sampler;
const PI = 3.141592653589793;
struct VertexOutput {
    @builtin(position) position: vec4f,
    @location(0) worldPosition: vec3f,
    @location(1) normal: vec3f,
    @location(2) uv: vec2f,
};
@vertex fn vertexMain(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VertexOutput {
    var output: VertexOutput;
    output.position = parameters.viewProjection * vec4f(position, 1.0);
    output.worldPosition = position; // Identity model transform.
    output.normal = normal;
    output.uv = uv;
    return output;
}
fn distributionGGX(noH: f32, alpha: f32) -> f32 {
    let alpha2 = alpha * alpha;
    let denominator = noH * noH * (alpha2 - 1.0) + 1.0;
    return alpha2 / (PI * denominator * denominator);
}
fn smithG1(noX: f32, alpha: f32) -> f32 {
    let alpha2 = alpha * alpha;
    return (2.0 * noX) / (noX + sqrt(alpha2 + (1.0 - alpha2) * noX * noX));
}
fn fresnelSchlick(voH: f32, f0: vec3f) -> vec3f {
    return f0 + (vec3f(1.0) - f0) * pow(1.0 - voH, 5.0);
}
fn linearToSRGB(color: vec3f) -> vec3f {
    let low = color * 12.92;
    let high = 1.055 * pow(max(color, vec3f(0.0)), vec3f(1.0 / 2.4)) - 0.055;
    return select(high, low, color <= vec3f(0.0031308));
}
@fragment fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
    // Sample before directional branches; textureSample uses implicit derivatives.
    let sampledAlbedo = textureSample(albedoTexture, albedoSampler, input.uv).rgb;
    let baseColor = select(parameters.baseColorAndLight.rgb, sampledAlbedo, parameters.material.z > 0.5);
    let roughness = parameters.material.x;
    let metallic = parameters.material.y;
    let alpha = roughness * roughness;
    let n = normalize(input.normal);
    let v = normalize(parameters.camera.xyz - input.worldPosition);
    let l = normalize(parameters.lightDirection.xyz);
    let noL = clamp(dot(n, l), 0.0, 1.0);
    let noV = clamp(dot(n, v), 0.0, 1.0);
    var radiance = vec3f(0.0);
    let halfVector = l + v;
    if (noL > 0.0 && noV > 0.0 && dot(halfVector, halfVector) > 1e-8) {
        let h = normalize(halfVector);
        let noH = clamp(dot(n, h), 0.0, 1.0);
        let voH = clamp(dot(v, h), 0.0, 1.0);
        let f0 = mix(vec3f(0.04), baseColor, metallic);
        let d = distributionGGX(noH, alpha);
        let g = smithG1(noL, alpha) * smithG1(noV, alpha);
        let f = fresnelSchlick(voH, f0);
        // Keep D, G, F and the Cook-Torrance denominator separate.
        let specular = (d * g * f) / max(4.0 * noL * noV, 1e-8);
        let diffuse = (vec3f(1.0) - f) * (1.0 - metallic) * baseColor / PI;
        var brdf = diffuse + specular;
        if (parameters.material.w > 0.5 && parameters.material.w < 1.5) { brdf = diffuse; }
        if (parameters.material.w > 1.5) { brdf = specular; }
        radiance = brdf * parameters.baseColorAndLight.w * noL;
    }
    // Fixed exposure 1, per-channel Reinhard, then explicit sRGB encoding.
    let mapped = radiance / (vec3f(1.0) + radiance);
    return vec4f(linearToSRGB(mapped), 1.0);
}

controls.txt

<style>
.test07-controls{display:grid;grid-template-columns:minmax(9em,auto) minmax(120px,1fr) 4em;gap:12px;align-items:center;margin:20px 0;max-width:650px}.test07-controls input[type=range]{width:100%;accent-color:var(--accent)}.test07-controls output{text-align:right;font-variant-numeric:tabular-nums}.test07-controls .wide{grid-column:1/-1}.test07-controls select{font:inherit;background:var(--paper);color:var(--ink)}
@media(max-width:480px){.test07-controls{grid-template-columns:minmax(0,1fr) 4em}.test07-controls>label{grid-column:1/-1}}
</style>
<div class="test07-controls">
<label class="wide"><input id="gpu-albedo" type="checkbox" checked disabled> アルベドテクスチャ</label>
<label for="gpu-roughness">Roughness</label><input id="gpu-roughness" type="range" min="0.05" max="1" step="0.01" value="0.35" disabled><output id="gpu-roughness-value" for="gpu-roughness">0.35</output>
<label for="gpu-metallic">Metallic</label><input id="gpu-metallic" type="range" min="0" max="1" step="0.01" value="0" disabled><output id="gpu-metallic-value" for="gpu-metallic">0.00</output>
<label for="gpu-azimuth">ライト方位角</label><input id="gpu-azimuth" type="range" min="-180" max="180" step="1" value="-35" disabled><output id="gpu-azimuth-value" for="gpu-azimuth">-35°</output>
<label for="gpu-elevation">ライト仰角</label><input id="gpu-elevation" type="range" min="-90" max="90" step="1" value="35" disabled><output id="gpu-elevation-value" for="gpu-elevation">35°</output>
<label for="gpu-view">表示</label><select id="gpu-view" disabled><option value="0">合成</option><option value="1">拡散のみ</option><option value="2">鏡面のみ</option></select><span></span>
<div class="wide"><button id="gpu-reset" type="button" disabled>初期値に戻す</button></div>
</div>

main.ts

// Independent Test07 runtime. No shared experiment code or runtime dependencies.
export {};
type Vec3 = [number, number, number];
type Mat4 = Float32Array<ArrayBuffer>;
function element<T extends Element>(selector: string, type: { new(): T }): T {
  const result = document.querySelector(selector);
  if (!(result instanceof type)) throw new Error(`要素が見つかりません: ${selector}`);
  return result;
}
const button = element('#gpu-start', HTMLButtonElement);
const canvas = element('#gpu-canvas', HTMLCanvasElement);
const status = element('#gpu-status', HTMLParagraphElement);
const info = element('#gpu-info', HTMLDListElement);
const albedoControl = element('#gpu-albedo', HTMLInputElement);
const roughnessControl = element('#gpu-roughness', HTMLInputElement);
const metallicControl = element('#gpu-metallic', HTMLInputElement);
const azimuthControl = element('#gpu-azimuth', HTMLInputElement);
const elevationControl = element('#gpu-elevation', HTMLInputElement);
const viewControl = element('#gpu-view', HTMLSelectElement);
const reset = element('#gpu-reset', HTMLButtonElement);
const controls = [albedoControl, roughnessControl, metallicControl, azimuthControl, elevationControl, viewControl, reset];
const near = 0.1;
const camera: Vec3 = [0, 0, 3];
let generation = 0;
let busy = false;
let pending = false;
interface Demo { draw(): void; dispose(): void; }
let demo: Demo | undefined;
// Column-major matrices and column vectors, matching WGSL mat4x4f * vec4f.
function multiply(a: Mat4, b: Mat4): Mat4 {
  const out = new Float32Array(16);
  for (let column = 0; column < 4; column++) {
    for (let row = 0; row < 4; row++) {
      for (let k = 0; k < 4; k++) out[column * 4 + row] += a[k * 4 + row] * b[column * 4 + k];
    }
  }
  return out;
}
function normalize(v: Vec3): Vec3 {
  const length = Math.hypot(...v);
  return [v[0] / length, v[1] / length, v[2] / length];
}
function cross(a: Vec3, b: Vec3): Vec3 {
  return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
}
function dot(a: Vec3, b: Vec3): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function lookAt(eye: Vec3, target: Vec3): Mat4 {
  const z = normalize([eye[0] - target[0], eye[1] - target[1], eye[2] - target[2]]);
  const x = normalize(cross([0, 1, 0], z));
  const y = cross(z, x);
  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 reversedPerspective(aspect: number): Mat4 {
  const f = 1 / Math.tan(Math.PI / 6);
  // Right-handed view space: clip.z = near, clip.w = -view.z.
  // Hence NDC Z = near / distance: near -> 1, infinity -> 0.
  return new Float32Array([f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, 0, -1, 0, 0, near, 0]);
}

function sphereMesh(): { vertices: Float32Array<ArrayBuffer>; indices: Uint16Array<ArrayBuffer> } {
  const longitude = 128, latitude = 64;
  const vertices: number[] = [], indices: number[] = [];
  for (let y = 0; y <= latitude; y++) {
    const v = y / latitude, theta = v * Math.PI;
    for (let x = 0; x <= longitude; x++) {
      const u = x / longitude, phi = u * Math.PI * 2;
      const nx = Math.sin(theta) * Math.cos(phi), ny = Math.cos(theta), nz = Math.sin(theta) * Math.sin(phi);
      vertices.push(nx, ny, nz, nx, ny, nz, u, v);
    }
  }
  for (let y = 0; y < latitude; y++) for (let x = 0; x < longitude; x++) {
    const a = y * (longitude + 1) + x, b = a + 1, c = a + longitude + 1, d = c + 1;
    if (y > 0) indices.push(a, b, c);
    if (y < latitude - 1) indices.push(b, d, c);
  }
  return { vertices: new Float32Array(vertices), indices: new Uint16Array(indices) };
}
function detail(label: string, value: string): void {
  const dt = document.createElement('dt'), dd = document.createElement('dd');
  dt.textContent = label; dd.textContent = value; info.append(dt, dd);
}
function fail(error: unknown): void {
  demo?.dispose(); demo = undefined;
  generation++; busy = false; pending = false;
  button.disabled = false; button.textContent = '実行';
  controls.forEach(control => { control.disabled = true; });
  status.textContent = `実行できませんでした: ${error instanceof Error ? error.message : String(error)}`;
}
function updateOutputs(): void {
  for (const [control, digits, suffix] of [
    [roughnessControl, 2, ''], [metallicControl, 2, ''], [azimuthControl, 0, '°'], [elevationControl, 0, '°'],
  ] satisfies [HTMLInputElement, number, string][]) {
    element(`#${control.id}-value`, HTMLOutputElement).value = Number(control.value).toFixed(digits) + suffix;
  }
}
async function initialize(token: number): Promise<Demo> {
  if (!window.isSecureContext || !navigator.gpu) throw new Error('WebGPU対応ブラウザでHTTPSまたはlocalhostから開いてください');
  const check = (): void => { if (token !== generation) throw new Error('初期化を中断しました'); };
  const adapter = await navigator.gpu.requestAdapter();
  check();
  if (!adapter) throw new Error('GPUアダプターを取得できませんでした');
  const device = await adapter.requestDevice();
  const buffers: GPUBuffer[] = [];
  let depth: GPUTexture | undefined;
  let texture: GPUTexture | undefined;
  let context: GPUCanvasContext | null = null;
  const dispose = (): void => {
    if (token === generation) context?.unconfigure();
    buffers.forEach(buffer => buffer.destroy());
    depth?.destroy();
    texture?.destroy();
    device.destroy();
  };
  try {
    check();
    context = canvas.getContext('webgpu');
    if (!context) throw new Error('WebGPU Canvasを取得できませんでした');
    const activeContext = context;
    const format = navigator.gpu.getPreferredCanvasFormat();
    activeContext.configure({ device, format, alphaMode: 'opaque' });
    device.lost.then(({ reason, message }) => {
      if (token === generation && reason !== 'destroyed') fail(new Error(`GPUデバイスを失いました: ${message || reason}`));
    });
    device.addEventListener('uncapturederror', event => {
      if (token === generation) fail(event.error);
    });
    async function shader(name: string): Promise<GPUShaderModule> {
      const response = await fetch(new URL(name, import.meta.url));
      if (!response.ok) throw new Error(`${name}: HTTP ${response.status}`);
      const bytes = new Uint8Array(await response.arrayBuffer());
      if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) throw new Error(`${name}: UTF-8 BOMは使用できません`);
      let code: string;
      try { code = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
      catch { throw new Error(`${name}: 不正なUTF-8です`); }
      check();
      const module = device.createShaderModule({ code });
      const compilation = await module.getCompilationInfo();
      check();
      const errors = compilation.messages.filter(message => message.type === 'error');
      if (errors.length) throw new Error(`${name}: ${errors.map(message => message.message).join('; ')}`);
      return module;
    }
    const lightingShader = await shader('lighting.wgsl');
    const generateShader = await shader('generate.wgsl');
    device.pushErrorScope('validation');
    function buffer(data: Float32Array<ArrayBuffer> | Uint16Array<ArrayBuffer>, usage: GPUBufferUsageFlags): GPUBuffer {
      const result = device.createBuffer({ size: data.byteLength, usage: usage | GPUBufferUsage.COPY_DST });
      buffers.push(result);
      device.queue.writeBuffer(result, 0, data);
      return result;
    }
    const mesh = sphereMesh();
    const vertices = buffer(mesh.vertices, GPUBufferUsage.VERTEX);
    const indices = buffer(mesh.indices, GPUBufferUsage.INDEX);
    const uniform = buffer(new Float32Array(32), GPUBufferUsage.UNIFORM);
    depth = device.createTexture({
      size: [canvas.width, canvas.height], format: 'depth32float',
      usage: GPUTextureUsage.RENDER_ATTACHMENT,
    });
    const depthView = depth.createView();
    texture = device.createTexture({
      size: [256, 256], format: 'rgba8unorm', mipLevelCount: 1,
      usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
    });
    const textureView = texture.createView();
    const generatePipeline = await device.createComputePipelineAsync({
      layout: 'auto', compute: { module: generateShader, entryPoint: 'computeMain' },
    });
    check();
    const generateGroup = device.createBindGroup({ layout: generatePipeline.getBindGroupLayout(0), entries: [
      { binding: 0, resource: textureView },
    ] });
    // Generate once during initialization. draw() never dispatches Compute.
    const generateEncoder = device.createCommandEncoder();
    const generatePass = generateEncoder.beginComputePass();
    generatePass.setPipeline(generatePipeline);
    generatePass.setBindGroup(0, generateGroup);
    generatePass.dispatchWorkgroups(32, 32);
    generatePass.end();
    device.queue.submit([generateEncoder.finish()]);
    const objectLayout = device.createBindGroupLayout({ entries: [
      { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform', minBindingSize: 128 } },
    ] });
    const textureLayout = device.createBindGroupLayout({ entries: [
      { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
      { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
    ] });
    const layout = device.createPipelineLayout({ bindGroupLayouts: [objectLayout, textureLayout] });
    const pipeline = await device.createRenderPipelineAsync({
      layout,
      vertex: { module: lightingShader, entryPoint: 'vertexMain', buffers: [{ arrayStride: 32, attributes: [
        { shaderLocation: 0, offset: 0, format: 'float32x3' },
        { shaderLocation: 1, offset: 12, format: 'float32x3' },
        { shaderLocation: 2, offset: 24, format: 'float32x2' },
      ] }] },
      fragment: { module: lightingShader, entryPoint: 'fragmentMain', targets: [{ format }] },
      primitive: { topology: 'triangle-list', cullMode: 'back', frontFace: 'ccw' },
      depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'greater' },
    });
    check();
    const objectGroup = device.createBindGroup({ layout: objectLayout, entries: [
      { binding: 0, resource: { buffer: uniform } },
    ] });
    const sampler = device.createSampler({
      minFilter: 'linear', magFilter: 'linear', addressModeU: 'repeat', addressModeV: 'clamp-to-edge',
    });
    const textureGroup = device.createBindGroup({ layout: textureLayout, entries: [
      { binding: 0, resource: textureView }, { binding: 1, resource: sampler },
    ] });
    const error = await device.popErrorScope();
    check();
    if (error) throw new Error(error.message);
    const viewProjection = multiply(reversedPerspective(canvas.width / canvas.height), lookAt(camera, [0, 0, 0]));
    function draw(): void {
      const azimuth = Number(azimuthControl.value) * Math.PI / 180;
      const elevation = Number(elevationControl.value) * Math.PI / 180;
      const light: Vec3 = [Math.cos(elevation) * Math.sin(azimuth), Math.sin(elevation), Math.cos(elevation) * Math.cos(azimuth)];
      const data = new Float32Array(32);
      data.set(viewProjection, 0);
      data.set([...camera, 0], 16);
      data.set([...light, 0], 20);
      data.set([Number(roughnessControl.value), Number(metallicControl.value), albedoControl.checked ? 1 : 0, Number(viewControl.value)], 24);
      data.set([0.42, 0.16, 0.065, 4.0], 28);
      device.queue.writeBuffer(uniform, 0, data);
      const encoder = device.createCommandEncoder();
      const pass = encoder.beginRenderPass({
        colorAttachments: [{ view: activeContext.getCurrentTexture().createView(),
          clearValue: { r: 0.025, g: 0.035, b: 0.045, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
        depthStencilAttachment: { view: depthView, depthClearValue: 0, depthLoadOp: 'clear', depthStoreOp: 'store' },
      });
      pass.setPipeline(pipeline);
      pass.setBindGroup(0, objectGroup);
      pass.setBindGroup(1, textureGroup);
      pass.setVertexBuffer(0, vertices);
      pass.setIndexBuffer(indices, 'uint16');
      pass.drawIndexed(mesh.indices.length);
      pass.end();
      device.queue.submit([encoder.finish()]);
    }
    device.pushErrorScope('validation');
    draw();
    const drawError = await device.popErrorScope();
    check();
    if (drawError) throw new Error(drawError.message);
    await device.queue.onSubmittedWorkDone();
    check();
    detail('GPU情報', adapter.info.description || '機種名を取得できませんでした');
    detail('メッシュ', `${mesh.vertices.length / 8}頂点 / ${mesh.indices.length / 3}三角形 · UV球`);
    detail('モデル', 'Lambert + GGX D / 非相関Smith G / Schlick F');
    detail('アルベド', '256×256 rgba8unorm · 線形値 · Compute生成は初期化時の1回');
    detail('描画', 'Reversed Z · 直接光のみ · UI変更時に再描画');
    return { draw, dispose };
  } catch (error) { dispose(); throw error; }
}

async function render(): Promise<void> {
  if (busy) { pending = true; return; }
  busy = true; button.disabled = true;
  const token = generation;
  try {
    if (!demo) {
      info.replaceChildren(); status.textContent = 'WebGPUを初期化しています';
      const initialized = await initialize(token);
      if (token !== generation) { initialized.dispose(); return; }
      demo = initialized;
    }
    demo.draw();
    status.textContent = '描画完了 · UI変更時に再描画します';
    button.textContent = '再描画';
    controls.forEach(control => { control.disabled = false; });
  } catch (error) { if (token === generation) fail(error); }
  finally {
    if (token === generation) {
      busy = false; button.disabled = false;
      if (pending) { pending = false; void render(); }
    }
  }
}
button.addEventListener('click', () => { void render(); });
for (const control of [roughnessControl, metallicControl, azimuthControl, elevationControl]) {
  control.addEventListener('input', () => { updateOutputs(); if (demo) void render(); });
}
for (const control of [albedoControl, viewControl]) control.addEventListener('change', () => { if (demo) void render(); });
reset.addEventListener('click', () => {
  albedoControl.checked = true; roughnessControl.value = '0.35'; metallicControl.value = '0';
  azimuthControl.value = '-35'; elevationControl.value = '35'; viewControl.value = '0';
  updateOutputs(); if (demo) void render();
});
window.addEventListener('pagehide', () => {
  demo?.dispose(); demo = undefined; generation++; busy = false; pending = false;
  button.disabled = false; button.textContent = '実行';
  controls.forEach(control => { control.disabled = true; });
  status.textContent = '実行ボタンで再初期化します';
});
updateOutputs();