Source: core/simulationEngines/webgpu/webGpuRayRenderer.js

/*
 * Copyright 2026 The Ray Optics Simulation authors and contributors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 */

import CanvasRenderer from '../../CanvasRenderer.js';

const BUFFER_USAGE_COPY_DST = 0x0008;
const BUFFER_USAGE_UNIFORM = 0x0040;
const BUFFER_USAGE_STORAGE = 0x0080;
const TEXTURE_USAGE_RENDER_ATTACHMENT = 0x0010;
const FLOATS_PER_RECORD = 16;

function createRasterWgsl(fixedPointScale) {
  return `
struct Uniforms {
  viewport: vec4f,
  sizeAndCount: vec4f,
};

struct ReadyGeometry {
  p0p1: vec4f,
  color: vec4f,
  style: vec4f,
  extra: vec4f,
};

struct Pixel {
  r: atomic<u32>,
  g: atomic<u32>,
  b: atomic<u32>,
  overflow: atomic<u32>,
};

struct VertexOutput {
  @builtin(position) position: vec4f,
  @location(0) @interpolate(flat) screenP0: vec2f,
  @location(1) @interpolate(flat) screenP1: vec2f,
  @location(2) @interpolate(flat) color: vec4f,
  @location(3) @interpolate(flat) style: vec4f,
  @location(4) @interpolate(flat) extra: vec2f,
};

struct FragmentInput {
  @location(0) @interpolate(flat) screenP0: vec2f,
  @location(1) @interpolate(flat) screenP1: vec2f,
  @location(2) @interpolate(flat) color: vec4f,
  @location(3) @interpolate(flat) style: vec4f,
  @location(4) @interpolate(flat) extra: vec2f,
};

@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var<storage, read> geometry: array<ReadyGeometry>;
@group(0) @binding(2) var<storage, read_write> pixels: array<Pixel>;

fn toScreen(point: vec2f) -> vec2f {
  return point * uniforms.viewport.z + uniforms.viewport.xy;
}

fn toClip(point: vec2f) -> vec4f {
  let size = uniforms.sizeAndCount.xy;
  return vec4f(point.x / size.x * 2.0 - 1.0,
               1.0 - point.y / size.y * 2.0, 0.0, 1.0);
}

@vertex
fn vertexMain(
  @builtin(vertex_index) vertexIndex: u32,
  @builtin(instance_index) instanceIndex: u32,
) -> VertexOutput {
  let item = geometry[instanceIndex];
  let p0 = toScreen(item.p0p1.xy);
  let p1 = toScreen(item.p0p1.zw);
  let geometryKind = item.extra.x;
  let isPoint = geometryKind > 0.5 && geometryKind < 1.5;
  var position: vec2f;
  if (isPoint) {
    let corner = array<vec2f, 6>(
      vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
      vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0)
    )[vertexIndex];
    // Keep a half-pixel fringe around subpixel points so the fragment shader
    // can integrate their fractional coverage instead of center-sampling them
    // into either zero or one whole pixel.
    position = p0 + corner * (0.5 * (item.extra.y + 1.0));
  } else {
    let delta = p1 - p0;
    let length = max(length(delta), 1e-20);
    let direction = delta / length;
    let side = vec2f(-direction.y, direction.x);
    let endIndex = select(0.0, 1.0,
      vertexIndex == 1u || vertexIndex == 2u || vertexIndex == 4u);
    let sideSign = select(-1.0, 1.0,
      vertexIndex == 2u || vertexIndex == 4u || vertexIndex == 5u);
    var halfWidth = 0.5 * item.style.x + 1.0;
    if (geometryKind > 1.5) {
      let sideSlope = 0.5 * (item.style.w - item.style.x) / length;
      let sideExpansion = sqrt(1.0 + sideSlope * sideSlope);
      halfWidth = 0.5 * mix(item.style.x, item.style.w, endIndex) +
        sideExpansion;
    }
    position = mix(p0, p1, endIndex) + side * sideSign * halfWidth;
  }
  var output: VertexOutput;
  output.position = toClip(position);
  output.screenP0 = p0;
  output.screenP1 = p1;
  output.color = item.color;
  output.style = item.style;
  output.extra = item.extra.xy;
  return output;
}

@fragment
fn fragmentMain(input: FragmentInput,
                @builtin(position) frag: vec4f) -> @location(0) vec4f {
  let size = vec2u(uniforms.sizeAndCount.xy);
  let pixelCoord = vec2u(frag.xy);
  if (pixelCoord.x >= size.x || pixelCoord.y >= size.y) { discard; }
  var coverage = 1.0;
  let colorMode = u32(uniforms.sizeAndCount.w);
  let geometryKind = input.extra.x;
  if (geometryKind > 0.5 && geometryKind < 1.5) {
    let pointSize = max(input.extra.y, 0.0);
    let relative = frag.xy - input.screenP0;
    if (colorMode == 4u) {
      coverage = select(0.0, 1.0,
        abs(relative.x) <= 0.5 * pointSize &&
        abs(relative.y) <= 0.5 * pointSize);
    } else {
      let maximumCoverage = min(1.0, pointSize);
      let xCoverage = clamp(
        0.5 * pointSize + 0.5 - abs(relative.x),
        0.0, maximumCoverage);
      let yCoverage = clamp(
        0.5 * pointSize + 0.5 - abs(relative.y),
        0.0, maximumCoverage);
      coverage = xCoverage * yCoverage;
    }
  } else {
    let delta = input.screenP1 - input.screenP0;
    let lineLength = max(length(delta), 1e-20);
    let direction = delta / lineLength;
    let relative = frag.xy - input.screenP0;
    let signedSide = dot(relative, vec2f(-direction.y, direction.x));
    // Analytic AA is intentionally only applied to the two longitudinal
    // sides.  Segment and arrow ends remain hard, matching the GPU contract.
    if (geometryKind > 1.5) {
      let along = dot(relative, direction);
      if (along < 0.0 || along > lineLength) { discard; }
      let sideSlope = 0.5 * (input.style.w - input.style.x) / lineLength;
      let halfWidth = 0.5 * mix(
        input.style.x, input.style.w, along / lineLength
      );
      let sideExpansion = sqrt(1.0 + sideSlope * sideSlope);
      let signedDistance = (abs(signedSide) - halfWidth) / sideExpansion;
      let maximumCoverage = min(
        1.0, max(0.0, 2.0 * halfWidth / sideExpansion));
      coverage = clamp(
        0.5 - signedDistance, 0.0, maximumCoverage);
    } else {
      let maximumCoverage = min(1.0, max(input.style.x, 0.0));
      coverage = clamp(
        0.5 * input.style.x + 0.5 - abs(signedSide),
        0.0, maximumCoverage
      );
    }
    if (colorMode == 4u) {
      // Colorized intensity is a measurement view rather than translucent
      // light.  Keep its two longitudinal edges hard.
      coverage = select(0.0, 1.0, coverage >= 0.5);
    }
    let dashOn = input.style.y;
    let dashOff = input.style.z;
    if (geometryKind < 0.5 && dashOn > 0.0 && dashOff > 0.0) {
      let along = clamp(dot(relative, direction), 0.0, lineLength);
      let period = dashOn + dashOff;
      let withinDash = along - floor(along / period) * period;
      if (colorMode == 4u) {
        if (withinDash >= dashOn) { discard; }
      } else {
        var signedDashDistance: f32;
        if (withinDash <= dashOn) {
          signedDashDistance = -min(withinDash, dashOn - withinDash);
        } else {
          signedDashDistance = min(
            withinDash - dashOn, period - withinDash);
        }
        let maximumDashCoverage = min(1.0, dashOn);
        coverage *= clamp(
          0.5 - signedDashDistance, 0.0, maximumDashCoverage);
      }
    }
  }
  if (coverage <= 0.0) { discard; }
  var value: vec3f;
  if (colorMode == 0u) {
    // Legacy theme colors are accumulated as optical density.  Applying
    // coverage directly to density would produce 1-(1-alpha)^coverage rather
    // than the canvas-like alpha*coverage edge.  Convert back to alpha,
    // apply coverage, and return to density before the atomic addition.
    let density = input.color.a;
    let alpha = 1.0 - exp(-density);
    let coveredDensity = -log(max(1.0 - alpha * coverage, 1e-7));
    let hue = select(vec3f(0.0), input.color.rgb / density, density > 0.0);
    value = hue * coveredDensity;
  } else if (colorMode == 5u) {
    // Canvas rasterization stores geometric coverage in pixel alpha.  Its
    // simulated-color post-pass then multiplies the recovered linear
    // wavelength intensity by that alpha.  Apply coverage in linear space to
    // match an isolated Canvas ray, including subpixel-width rays.
    value = input.color.rgb * coverage;
  } else {
    value = input.color.rgb * coverage;
  }
  value = max(value, vec3f(0.0));
  let amountf = round(min(value * ${fixedPointScale}.0,
                          vec3f(4294967040.0)));
  let amounts = vec3u(u32(amountf.r), u32(amountf.g), u32(amountf.b));
  let index = pixelCoord.y * size.x + pixelCoord.x;
  if (amounts.r > 0u) {
    let old = atomicAdd(&pixels[index].r, amounts.r);
    if (old > 4294967295u - amounts.r) {
      atomicStore(&pixels[index].overflow, 1u);
    }
  }
  if (amounts.g > 0u) {
    let old = atomicAdd(&pixels[index].g, amounts.g);
    if (old > 4294967295u - amounts.g) {
      atomicStore(&pixels[index].overflow, 1u);
    }
  }
  if (amounts.b > 0u) {
    let old = atomicAdd(&pixels[index].b, amounts.b);
    if (old > 4294967295u - amounts.b) {
      atomicStore(&pixels[index].overflow, 1u);
    }
  }
  return vec4f(0.0);
}
`;
}

function createPresentWgsl(fixedPointScale) {
  return `
struct Uniforms {
  viewport: vec4f,
  sizeAndCount: vec4f,
};
struct PresentOutput {
  @builtin(position) position: vec4f,
  @location(0) uv: vec2f,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var<storage, read> pixelWords: array<u32>;

@vertex
fn vertexMain(@builtin(vertex_index) index: u32) -> PresentOutput {
  let positions = array<vec2f, 3>(
    vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
  var output: PresentOutput;
  output.position = vec4f(positions[index], 0.0, 1.0);
  output.uv = positions[index] * vec2f(0.5, -0.5) + vec2f(0.5);
  return output;
}

fn brightnessToColor(brightness: f32) -> vec4f {
  if (brightness > 100.0) {
    return vec4f(1.0, 0.0, 0.0, 1.0);
  }
  if (brightness > 10.0) {
    let t = (log2(brightness) - log2(10.0)) /
            (log2(100.0) - log2(10.0));
    return vec4f(mix(vec3f(1.0, 0.5, 0.0), vec3f(1.0, 0.0, 0.0), t), 1.0);
  }
  if (brightness > 1.0) {
    let t = (log2(brightness) - log2(1.0)) /
            (log2(10.0) - log2(1.0));
    return vec4f(mix(vec3f(1.0, 1.0, 0.0), vec3f(1.0, 0.5, 0.0), t), 1.0);
  }
  if (brightness > 0.1) {
    let t = (log2(brightness) - log2(0.1)) /
            (log2(1.0) - log2(0.1));
    return vec4f(mix(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 1.0, 0.0), t), 1.0);
  }
  if (brightness > 0.01) {
    let t = (log2(brightness) - log2(0.01)) /
            (log2(0.1) - log2(0.01));
    return vec4f(mix(vec3f(0.0, 1.0, 1.0), vec3f(0.0, 1.0, 0.0), t), 1.0);
  }
  if (brightness > 0.001) {
    let t = (log2(brightness) - log2(0.001)) /
            (log2(0.01) - log2(0.001));
    return vec4f(mix(vec3f(0.0, 0.0, 1.0), vec3f(0.0, 1.0, 1.0), t), 1.0);
  }
  if (brightness > 0.0001) {
    let t = (log2(brightness) - log2(0.0001)) /
            (log2(0.001) - log2(0.0001));
    return vec4f(mix(vec3f(0.3, 0.0, 0.3), vec3f(0.0, 0.0, 1.0), t), 1.0);
  }
  let t = (log2(max(brightness, 1e-7)) - log2(1e-7)) /
          (log2(0.0001) - log2(1e-7));
  return vec4f(mix(vec3f(0.0), vec3f(0.3, 0.0, 0.3), t), t);
}

fn toneMapAdditive(color: vec3f, mode: u32) -> vec4f {
  if (mode == 0u) {
    // Default/legacy colors are accumulated as optical density.  This is
    // exactly source-over/screen for a single color and intentionally only an
    // approximation when differently colored theme rays overlap.
    let density = max(max(color.r, color.g), color.b);
    let opacity = 1.0 - exp(-density);
    let mapped = select(vec3f(0.0), color / density * opacity, density > 0.0);
    return vec4f(mapped, clamp(opacity, 0.0, 1.0));
  }
  if (mode == 5u) {
    // Simulated wavelengths accumulate optical density per channel.  Match
    // CanvasRenderer.applyColorTransformation by storing the normalized hue
    // in RGB and the largest channel density in alpha.  Unlike Canvas, this
    // path avoids the intermediate u8 quantization.
    let factor = max(max(color.r, color.g), color.b);
    let opacity = clamp(factor, 0.0, 1.0);
    let mapped = select(vec3f(0.0), color / factor * opacity, factor > 0.0);
    return vec4f(mapped, opacity);
  }
  if (mode == 3u) {
    let luminance = dot(color, vec3f(0.2126, 0.7152, 0.0722));
    // This is algebraically the Reinhard luminance ratio, but unlike
    // (luminance / (1 + luminance)) / luminance it is defined at black.
    let rgb = pow(color / (1.0 + luminance), vec3f(1.0 / 2.2));
    let maximum = max(max(color.r, color.g), color.b);
    return vec4f(rgb, pow(maximum, 1.0 / 2.2));
  }
  if (mode == 4u) {
    let brightness = max(max(color.r, color.g), color.b);
    let mapped = brightnessToColor(brightness);
    return vec4f(mapped.rgb * 0.8, 0.0);
  }
  var value = max(color, vec3f(0.0));
  let maximum = max(max(value.r, value.g), value.b);
  if (mode == 1u) {
    if (maximum > 1.0) { value /= maximum; }
    return vec4f(pow(value, vec3f(1.0 / 2.2)),
                 pow(min(maximum, 1.0), 1.0 / 2.2));
  }
  return vec4f(pow(value, vec3f(1.0 / 2.2)),
               pow(maximum, 1.0 / 2.2));
}

@fragment
fn fragmentMain(input: PresentOutput) -> @location(0) vec4f {
  let size = vec2u(uniforms.sizeAndCount.xy);
  let coord = min(vec2u(input.uv * vec2f(size)), size - vec2u(1u));
  let index = coord.y * size.x + coord.x;
  let base = index * 4u;
  if (pixelWords[base + 3u] != 0u) {
    return vec4f(1.0);
  }
  let raw = vec3u(pixelWords[base], pixelWords[base + 1u],
                  pixelWords[base + 2u]);
  let color = vec3f(raw) / ${fixedPointScale}.0;
  let mode = u32(uniforms.sizeAndCount.w);
  return toneMapAdditive(color, mode);
}
`;
}

/**
 * Backend-neutral ready-geometry sink.  The CPU primitive event loop writes
 * the same lines and image points that the GPU pre-render pass will eventually
 * write.  In Node it delegates to CanvasRenderer; in browsers it retains
 * packed records for the raster-atomic passes below.
 */
export class WebGpuReadyRayRenderer {
  constructor({ ctx = null, origin, scale, lengthScale }) {
    this.origin = origin;
    this.scale = scale;
    this.lengthScale = lengthScale;
    this.canvasRenderer = ctx
      ? new CanvasRenderer(ctx, origin, scale, lengthScale, null, null)
      : null;
    this.records = [];
    this.flushedRecordCount = 0;
  }

  drawPoint(point, color = [1, 1, 1, 1], size = 5) {
    if (this.canvasRenderer) {
      this.canvasRenderer.drawPoint(point, color, size);
      return;
    }
    this.records.push(createRecord(
      point, point, color, 0, [], 'point',
      size * this.lengthScale * this.scale
    ));
  }

  drawLine(line, color, showArrow, dash, width = 1) {
    if (this.canvasRenderer) {
      this.canvasRenderer.drawLine(line, color, showArrow, dash, width);
      return;
    }
    const directionX = line.p2.x - line.p1.x;
    const directionY = line.p2.y - line.p1.y;
    const magnitude = Math.hypot(directionX, directionY);
    if (!(magnitude > 0)) return;
    const extent = 2 * (Math.abs(line.p1.x) + Math.abs(line.p1.y) + 1e6);
    const unitX = directionX / magnitude;
    const unitY = directionY / magnitude;
    this.pushSegment(
      { x: line.p1.x - unitX * extent, y: line.p1.y - unitY * extent },
      { x: line.p1.x + unitX * extent, y: line.p1.y + unitY * extent },
      color, dash, width
    );
  }

  drawRay(ray, color, showArrow, dash, width = 1) {
    if (this.canvasRenderer) {
      this.canvasRenderer.drawRay(ray, color, showArrow, dash, width);
      return;
    }
    const dx = ray.p2.x - ray.p1.x;
    const dy = ray.p2.y - ray.p1.y;
    const magnitude = Math.hypot(dx, dy);
    if (!(magnitude > 0)) return;
    const extent = 2e6 / Math.max(this.scale, 1e-12);
    const unitX = dx / magnitude;
    const unitY = dy / magnitude;
    const end = {
      x: ray.p1.x + dx / magnitude * extent,
      y: ray.p1.y + dy / magnitude * extent
    };
    const arrowSize = 5 * this.lengthScale;
    const baseWidth = width * this.lengthScale;
    if (!showArrow || arrowSize < baseWidth * 1.2) {
      this.pushSegment(ray.p1, end, color, dash, width);
      return;
    }
    const arrowDistance = 150 * this.lengthScale;
    const arrowFront = {
      x: ray.p1.x + unitX * arrowDistance,
      y: ray.p1.y + unitY * arrowDistance,
    };
    const arrowBack = {
      x: arrowFront.x + unitX * arrowSize,
      y: arrowFront.y + unitY * arrowSize,
    };
    this.pushSegment(ray.p1, arrowFront, color, dash, width);
    this.pushArrow(
      arrowFront, arrowBack, color, arrowSize, baseWidth
    );
    this.pushSegment(arrowBack, end, color, dash, width);
  }

  drawSegment(segment, color, showArrow, dash, width = 1) {
    if (this.canvasRenderer) {
      this.canvasRenderer.drawSegment(segment, color, showArrow, dash, width);
      return;
    }
    const dx = segment.p2.x - segment.p1.x;
    const dy = segment.p2.y - segment.p1.y;
    const length = Math.hypot(dx, dy);
    if (!(length > 0)) return;
    const arrowSize = Math.min(length * 0.15, 5 * this.lengthScale);
    const baseWidth = width * this.lengthScale;
    if (!showArrow || arrowSize < baseWidth * 1.2) {
      this.pushSegment(segment.p1, segment.p2, color, dash, width);
      return;
    }
    const unitX = dx / length;
    const unitY = dy / length;
    const arrowCenter = {
      x: segment.p1.x + dx * 0.67,
      y: segment.p1.y + dy * 0.67,
    };
    const arrowFront = {
      x: arrowCenter.x - unitX * arrowSize * 0.5,
      y: arrowCenter.y - unitY * arrowSize * 0.5,
    };
    const arrowBack = {
      x: arrowCenter.x + unitX * arrowSize * 0.5,
      y: arrowCenter.y + unitY * arrowSize * 0.5,
    };
    this.pushSegment(segment.p1, arrowFront, color, dash, width);
    this.pushArrow(
      arrowFront, arrowBack, color, arrowSize, baseWidth
    );
    this.pushSegment(arrowBack, segment.p2, color, dash, width);
  }

  pushSegment(p0, p1, color, dash, width) {
    this.records.push(createRecord(
      p0, p1, color,
      width * this.lengthScale * this.scale,
      (dash ?? []).map(value => value * this.lengthScale * this.scale),
      'line', 0
    ));
  }

  pushArrow(front, back, color, frontWidth, backWidth) {
    this.records.push(createRecord(
      front,
      back,
      color,
      frontWidth * this.scale,
      [],
      'arrow',
      0,
      backWidth * this.scale
    ));
  }

  applyColorTransformation() {}
  flush() {}
  destroy() {
    this.records.length = 0;
    this.canvasRenderer = null;
  }

  takeNewRecords() {
    const records = this.records.slice(this.flushedRecordCount);
    this.flushedRecordCount = this.records.length;
    return records;
  }
}

export class WebGpuAtomicRayRasterizer {
  constructor(device, output, fixedPointScale = 1048576) {
    this.device = device;
    this.output = output;
    this.fixedPointScale = fixedPointScale;
    this.width = 0;
    this.height = 0;
    this.pixelBuffer = null;
    this.uniformBuffer = null;
    this.geometryBuffer = null;
    this.geometryCapacity = 0;
    this.bindGroup = null;
    this.presentBindGroup = null;
    this.dummyTexture = null;
    this.dummyView = null;
    this.rasterPipeline = null;
    this.presentPipeline = null;
  }

  async initialize() {
    this.device.pushErrorScope?.('validation');
    const rasterModule = this.device.createShaderModule({
      code: createRasterWgsl(this.fixedPointScale)
    });
    const presentModule = this.device.createShaderModule({
      code: createPresentWgsl(this.fixedPointScale)
    });
    try {
      await validateShaderModule(rasterModule, 'raster-atomic');
      await validateShaderModule(presentModule, 'tone-map');
      const createPipeline = descriptor =>
        this.device.createRenderPipelineAsync
          ? this.device.createRenderPipelineAsync(descriptor)
          : Promise.resolve(this.device.createRenderPipeline(descriptor));
      this.rasterPipeline = await createPipeline({
        layout: 'auto',
        vertex: { module: rasterModule, entryPoint: 'vertexMain' },
        fragment: {
          module: rasterModule,
          entryPoint: 'fragmentMain',
          targets: [{ format: this.output.format, writeMask: 0 }],
        },
        primitive: { topology: 'triangle-list' },
      });
      this.presentPipeline = await createPipeline({
        layout: 'auto',
        vertex: { module: presentModule, entryPoint: 'vertexMain' },
        fragment: {
          module: presentModule,
          entryPoint: 'fragmentMain',
          targets: [{ format: this.output.format }],
        },
        primitive: { topology: 'triangle-list' },
      });
    } finally {
      const validationError = await this.device.popErrorScope?.();
      if (validationError) throw validationError;
    }
  }

  ensureSize(width, height) {
    width = Math.max(1, Math.trunc(width));
    height = Math.max(1, Math.trunc(height));
    if (width === this.width && height === this.height && this.pixelBuffer) {
      return;
    }
    this.width = width;
    this.height = height;
    this.pixelBuffer?.destroy?.();
    this.uniformBuffer?.destroy?.();
    this.dummyTexture?.destroy?.();
    this.pixelBuffer = this.device.createBuffer({
      size: width * height * 16,
      usage: BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST,
    });
    this.uniformBuffer = this.device.createBuffer({
      size: 32,
      usage: BUFFER_USAGE_UNIFORM | BUFFER_USAGE_COPY_DST,
    });
    this.dummyTexture = this.device.createTexture({
      size: [width, height],
      format: this.output.format,
      usage: TEXTURE_USAGE_RENDER_ATTACHMENT,
    });
    this.dummyView = this.dummyTexture.createView();
    this.device.queue.writeBuffer(
      this.pixelBuffer, 0, new Uint32Array(width * height * 4)
    );
    this.rebuildBindGroups();
  }

  ensureGeometryCapacity(recordCount) {
    const required = Math.max(1, recordCount) * FLOATS_PER_RECORD * 4;
    if (required <= this.geometryCapacity) return;
    this.geometryCapacity = nextPowerOfTwo(required);
    this.geometryBuffer?.destroy?.();
    this.geometryBuffer = this.device.createBuffer({
      size: this.geometryCapacity,
      usage: BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST,
    });
    this.rebuildBindGroups();
  }

  rebuildBindGroups() {
    if (!this.uniformBuffer || !this.pixelBuffer) return;
    this.presentBindGroup = this.device.createBindGroup({
      layout: this.presentPipeline.getBindGroupLayout(0),
      entries: [
        { binding: 0, resource: { buffer: this.uniformBuffer } },
        { binding: 1, resource: { buffer: this.pixelBuffer } },
      ],
    });
    if (!this.geometryBuffer) return;
    this.bindGroup = this.device.createBindGroup({
      layout: this.rasterPipeline.getBindGroupLayout(0),
      entries: [
        { binding: 0, resource: { buffer: this.uniformBuffer } },
        { binding: 1, resource: { buffer: this.geometryBuffer } },
        { binding: 2, resource: { buffer: this.pixelBuffer } },
      ],
    });
  }

  async draw(
    records,
    { origin, scale, colorMode, simulateColors = false },
    { isCancelled = null, resetAccumulation = false } = {}
  ) {
    if (isCancelled?.()) return false;
    const size = this.output.getSize?.() ?? this.output.size;
    const width = size?.width ?? 1;
    const height = size?.height ?? 1;
    this.ensureSize(width, height);
    this.ensureGeometryCapacity(records.length);
    if (records.length > 0) {
      const packed = packRecords(records, colorMode, simulateColors);
      this.device.queue.writeBuffer(this.geometryBuffer, 0, packed);
    }
    const uniforms = new Float32Array([
      origin.x, origin.y, scale, 0,
      this.width, this.height, records.length,
      colorModeId(colorMode, simulateColors)
    ]);
    this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
    const view = await this.output.acquireView(this.device);
    if (isCancelled?.()) return false;
    const encoder = this.device.createCommandEncoder();
    if (resetAccumulation) encoder.clearBuffer(this.pixelBuffer);
    if (records.length > 0) {
      const raster = encoder.beginRenderPass({
        colorAttachments: [{
          view: this.dummyView,
          clearValue: { r: 0, g: 0, b: 0, a: 0 },
          loadOp: 'clear',
          storeOp: 'store',
        }],
      });
      raster.setPipeline(this.rasterPipeline);
      raster.setBindGroup(0, this.bindGroup);
      raster.draw(6, records.length);
      raster.end();
    }
    const present = encoder.beginRenderPass({
      colorAttachments: [{
        view,
        clearValue: { r: 0, g: 0, b: 0, a: 0 },
        loadOp: 'clear',
        storeOp: 'store',
      }],
    });
    present.setPipeline(this.presentPipeline);
    present.setBindGroup(0, this.presentBindGroup);
    present.draw(3);
    present.end();
    this.device.queue.submit([encoder.finish()]);
    await waitForSubmittedWork(this.device);
    return !isCancelled?.();
  }

  async drawGpuGeometry(
    geometryBuffer,
    recordCount,
    { origin, scale, colorMode, simulateColors = false },
    { isCancelled = null, resetAccumulation = false } = {}
  ) {
    if (isCancelled?.()) return false;
    const size = this.output.getSize?.() ?? this.output.size;
    this.ensureSize(size?.width ?? 1, size?.height ?? 1);
    this.device.queue.writeBuffer(this.uniformBuffer, 0, new Float32Array([
      origin.x, origin.y, scale, 0,
      this.width, this.height, recordCount,
      colorModeId(colorMode, simulateColors)
    ]));
    const geometryBindGroup = this.device.createBindGroup({
      layout: this.rasterPipeline.getBindGroupLayout(0),
      entries: [
        { binding: 0, resource: { buffer: this.uniformBuffer } },
        { binding: 1, resource: { buffer: geometryBuffer } },
        { binding: 2, resource: { buffer: this.pixelBuffer } },
      ],
    });
    const view = await this.output.acquireView(this.device);
    if (isCancelled?.()) return false;
    const encoder = this.device.createCommandEncoder({
      label: 'WebGPU raster native ready geometry',
    });
    if (resetAccumulation) encoder.clearBuffer(this.pixelBuffer);
    if (recordCount > 0) {
      const raster = encoder.beginRenderPass({
        colorAttachments: [{
          view: this.dummyView,
          clearValue: { r: 0, g: 0, b: 0, a: 0 },
          loadOp: 'clear',
          storeOp: 'store',
        }],
      });
      raster.setPipeline(this.rasterPipeline);
      raster.setBindGroup(0, geometryBindGroup);
      raster.draw(6, recordCount);
      raster.end();
    }
    const present = encoder.beginRenderPass({
      colorAttachments: [{
        view,
        clearValue: { r: 0, g: 0, b: 0, a: 0 },
        loadOp: 'clear',
        storeOp: 'store',
      }],
    });
    present.setPipeline(this.presentPipeline);
    present.setBindGroup(0, this.presentBindGroup);
    present.draw(3);
    present.end();
    this.device.queue.submit([encoder.finish()]);
    await waitForSubmittedWork(this.device);
    return !isCancelled?.();
  }

  async drawGpuGeometryIndirect(
    geometryBuffer,
    drawIndirectBuffer,
    { origin, scale, colorMode, simulateColors = false },
    { isCancelled = null, resetAccumulation = false } = {}
  ) {
    const prepared = await this.prepareGpuGeometryIndirect(
      geometryBuffer,
      { origin, scale, colorMode, simulateColors },
      { isCancelled }
    );
    if (!prepared) return false;
    const encoder = this.device.createCommandEncoder({
      label: 'WebGPU raster native ready geometry indirect',
    });
    this.encodeGpuGeometryIndirect(
      encoder,
      drawIndirectBuffer,
      prepared,
      { resetAccumulation }
    );
    this.device.queue.submit([encoder.finish()]);
    await waitForSubmittedWork(this.device);
    return !isCancelled?.();
  }

  async prepareGpuGeometryIndirect(
    geometryBuffer,
    { origin, scale, colorMode, simulateColors = false },
    { isCancelled = null } = {}
  ) {
    if (isCancelled?.()) return null;
    const size = this.output.getSize?.() ?? this.output.size;
    this.ensureSize(size?.width ?? 1, size?.height ?? 1);
    this.device.queue.writeBuffer(this.uniformBuffer, 0, new Float32Array([
      origin.x, origin.y, scale, 0,
      this.width, this.height, 0,
      colorModeId(colorMode, simulateColors)
    ]));
    const geometryBindGroup = this.device.createBindGroup({
      layout: this.rasterPipeline.getBindGroupLayout(0),
      entries: [
        { binding: 0, resource: { buffer: this.uniformBuffer } },
        { binding: 1, resource: { buffer: geometryBuffer } },
        { binding: 2, resource: { buffer: this.pixelBuffer } },
      ],
    });
    const view = await this.output.acquireView(this.device);
    if (isCancelled?.()) return null;
    return { geometryBindGroup, view };
  }

  encodeGpuGeometryIndirect(
    encoder,
    drawIndirectBuffer,
    { geometryBindGroup, view },
    { resetAccumulation = false } = {}
  ) {
    if (resetAccumulation) encoder.clearBuffer(this.pixelBuffer);
    const raster = encoder.beginRenderPass({
      colorAttachments: [{
        view: this.dummyView,
        clearValue: { r: 0, g: 0, b: 0, a: 0 },
        loadOp: 'clear',
        storeOp: 'store',
      }],
    });
    raster.setPipeline(this.rasterPipeline);
    raster.setBindGroup(0, geometryBindGroup);
    raster.drawIndirect(drawIndirectBuffer, 0);
    raster.end();
    const present = encoder.beginRenderPass({
      colorAttachments: [{
        view,
        clearValue: { r: 0, g: 0, b: 0, a: 0 },
        loadOp: 'clear',
        storeOp: 'store',
      }],
    });
    present.setPipeline(this.presentPipeline);
    present.setBindGroup(0, this.presentBindGroup);
    present.draw(3);
    present.end();
  }

  waitForSubmittedWork() {
    return waitForSubmittedWork(this.device);
  }

  async clear({
    origin = { x: 0, y: 0 },
    scale = 1,
    colorMode = 'default',
    simulateColors = false,
  } = {}) {
    const size = this.output.getSize?.() ?? this.output.size;
    this.ensureSize(size?.width ?? 1, size?.height ?? 1);
    this.device.queue.writeBuffer(this.uniformBuffer, 0, new Float32Array([
      origin.x, origin.y, scale, 0,
      this.width, this.height, 0,
      colorModeId(colorMode, simulateColors)
    ]));
    const view = await this.output.acquireView(this.device);
    const encoder = this.device.createCommandEncoder({
      label: 'WebGPU clear and present light accumulation',
    });
    encoder.clearBuffer(this.pixelBuffer);
    const present = encoder.beginRenderPass({
      colorAttachments: [{
        view,
        clearValue: { r: 0, g: 0, b: 0, a: 0 },
        loadOp: 'clear',
        storeOp: 'store',
      }],
    });
    present.setPipeline(this.presentPipeline);
    present.setBindGroup(0, this.presentBindGroup);
    present.draw(3);
    present.end();
    this.device.queue.submit([encoder.finish()]);
    await waitForSubmittedWork(this.device);
    return true;
  }

  destroy() {
    this.pixelBuffer?.destroy?.();
    this.uniformBuffer?.destroy?.();
    this.geometryBuffer?.destroy?.();
    this.dummyTexture?.destroy?.();
    this.pixelBuffer = null;
    this.uniformBuffer = null;
    this.geometryBuffer = null;
    this.dummyTexture = null;
    this.dummyView = null;
  }
}

async function waitForSubmittedWork(device) {
  await device.queue.onSubmittedWorkDone?.();
}

/**
 * Node has no built-in WebGPU implementation on the supported test runtime.
 * This small rasterizer executes the same ready-record, fixed accumulation
 * and tone-map contract into Canvas ImageData.  It is intentionally aimed at
 * deterministic smoke tests, not as a high-throughput replacement backend.
 */
export class WebGpuCanvasRayRasterizer {
  constructor(ctx, fixedPointScale = 1048576) {
    this.ctx = ctx;
    this.fixedPointScale = fixedPointScale;
    this.width = 0;
    this.height = 0;
    this.accumulation = null;
    this.overflow = null;
  }

  ensureSize() {
    const width = Math.max(1, this.ctx.canvas.width);
    const height = Math.max(1, this.ctx.canvas.height);
    if (width === this.width && height === this.height && this.accumulation) {
      return;
    }
    this.width = width;
    this.height = height;
    this.accumulation = new Float64Array(width * height * 3);
    this.overflow = new Uint8Array(width * height);
  }

  clear() {
    this.ensureSize();
    this.accumulation.fill(0);
    this.overflow.fill(0);
    this.ctx.clearRect(0, 0, this.width, this.height);
    // Canvas 2D clearing is immediately visible and needs no presentation.
    return true;
  }

  async draw(records, {
    origin,
    scale,
    colorMode,
    simulateColors = false
  }, { resetAccumulation = false } = {}) {
    this.ensureSize();
    if (resetAccumulation) {
      this.accumulation.fill(0);
      this.overflow.fill(0);
    }
    for (const record of records) {
      const contribution = encodeWebGpuColorContribution(
        record.color, colorMode, simulateColors
      );
      this.rasterRecord(
        record, contribution, origin, scale, colorMode, simulateColors
      );
    }
    const image = this.ctx.createImageData(this.width, this.height);
    for (let pixel = 0; pixel < this.width * this.height; pixel++) {
      const outputOffset = pixel * 4;
      if (this.overflow[pixel]) {
        image.data.set([255, 255, 255, 255], outputOffset);
        continue;
      }
      const inputOffset = pixel * 3;
      const mapped = toneMapWebGpuColorContribution([
        this.accumulation[inputOffset],
        this.accumulation[inputOffset + 1],
        this.accumulation[inputOffset + 2],
      ], colorMode, simulateColors);
      image.data[outputOffset] = toByte(mapped[0]);
      image.data[outputOffset + 1] = toByte(mapped[1]);
      image.data[outputOffset + 2] = toByte(mapped[2]);
      image.data[outputOffset + 3] = toByte(mapped[3]);
    }
    this.ctx.putImageData(image, 0, 0);
    return true;
  }

  rasterRecord(
    record,
    contribution,
    origin,
    scale,
    colorMode,
    simulateColors
  ) {
    const p0 = {
      x: record.p0.x * scale + origin.x,
      y: record.p0.y * scale + origin.y,
    };
    if (record.isPoint) {
      const half = 0.5 * (record.pointSize + 1);
      this.visitBounds(
        p0.x - half, p0.y - half, p0.x + half, p0.y + half,
        (x, y, pixel) => {
          const coverage = calculateWebGpuPointCoverage(
            x + 0.5 - p0.x,
            y + 0.5 - p0.y,
            record.pointSize,
            colorMode
          );
          if (coverage > 0) {
            this.add(
              pixel, contribution, coverage, colorMode, simulateColors
            );
          }
        }
      );
      return;
    }
    const p1 = {
      x: record.p1.x * scale + origin.x,
      y: record.p1.y * scale + origin.y,
    };
    const dx = p1.x - p0.x;
    const dy = p1.y - p0.y;
    const length = Math.hypot(dx, dy);
    if (!(length > 1e-20)) return;
    const ux = dx / length;
    const uy = dy / length;
    const extent = 0.5 * Math.max(record.width, record.endWidth) + 1;
    this.visitBounds(
      Math.min(p0.x, p1.x) - extent,
      Math.min(p0.y, p1.y) - extent,
      Math.max(p0.x, p1.x) + extent,
      Math.max(p0.y, p1.y) + extent,
      (x, y, pixel) => {
        const relativeX = x + 0.5 - p0.x;
        const relativeY = y + 0.5 - p0.y;
        const along = relativeX * ux + relativeY * uy;
        if (along < 0 || along > length) return;
        const signedSide = relativeX * -uy + relativeY * ux;
        let coverage = record.kind === 'arrow'
          ? calculateWebGpuArrowCoverage(
            signedSide,
            along,
            length,
            record.width,
            record.endWidth,
            colorMode
          )
          : calculateWebGpuLineCoverage(
            signedSide, record.width, colorMode
          );
        if (
          record.kind !== 'arrow' &&
          record.dashOn > 0 && record.dashOff > 0
        ) {
          coverage *= calculateWebGpuDashCoverage(
            along, record.dashOn, record.dashOff, colorMode
          );
        }
        if (coverage > 0) {
          this.add(
            pixel, contribution, coverage, colorMode, simulateColors
          );
        }
      }
    );
  }

  visitBounds(minX, minY, maxX, maxY, callback) {
    const startX = Math.max(0, Math.floor(minX));
    const startY = Math.max(0, Math.floor(minY));
    const endX = Math.min(this.width - 1, Math.ceil(maxX));
    const endY = Math.min(this.height - 1, Math.ceil(maxY));
    for (let y = startY; y <= endY; y++) {
      for (let x = startX; x <= endX; x++) {
        callback(x, y, y * this.width + x);
      }
    }
  }

  add(pixel, contribution, coverage, colorMode, simulateColors) {
    const covered = applyWebGpuAnalyticCoverage(
      contribution, coverage, colorMode, simulateColors
    );
    const offset = pixel * 3;
    for (let channel = 0; channel < 3; channel++) {
      const next = this.accumulation[offset + channel] +
        covered[channel];
      if (next * this.fixedPointScale > 0xffffffff) {
        this.overflow[pixel] = 1;
      } else {
        this.accumulation[offset + channel] = next;
      }
    }
  }

  destroy() {
    this.accumulation = null;
    this.overflow = null;
    this.ctx = null;
  }
}

function createRecord(
  p0,
  p1,
  color,
  width,
  dash,
  kind,
  pointSize,
  endWidth = width
) {
  return {
    p0, p1,
    color: normalizeColor(color),
    width: Math.max(0, width ?? 1),
    endWidth: Math.max(0, endWidth ?? 1),
    dashOn: dash?.[0] ?? 0,
    dashOff: dash?.[1] ?? 0,
    kind,
    isPoint: kind === 'point',
    pointSize,
  };
}

function normalizeColor(color) {
  if (color?.r !== undefined) return [color.r, color.g, color.b, color.a];
  return [color?.[0] ?? 1, color?.[1] ?? 1, color?.[2] ?? 1,
    color?.[3] ?? 1];
}

function packRecords(records, colorMode, simulateColors) {
  const packed = new Float32Array(records.length * FLOATS_PER_RECORD);
  for (let index = 0; index < records.length; index++) {
    const record = records[index];
    const offset = index * FLOATS_PER_RECORD;
    packed.set([record.p0.x, record.p0.y, record.p1.x, record.p1.y], offset);
    packed.set(
      encodeWebGpuColorContribution(record.color, colorMode, simulateColors),
      offset + 4
    );
    packed.set([
      record.width, record.dashOn, record.dashOff, record.endWidth
    ], offset + 8);
    packed.set([
      geometryKindId(record.kind), record.pointSize, 0, 0
    ], offset + 12);
  }
  return packed;
}

function geometryKindId(kind) {
  if (kind === 'point') return 1;
  if (kind === 'arrow') return 2;
  return 0;
}

export function encodeWebGpuColorContribution(
  color,
  colorMode,
  simulateColors
) {
  if (colorMode !== 'default') {
    const r = color[0] * color[3];
    const g = color[1] * color[3];
    const b = color[2] * color[3];
    const maximum = Math.max(r, g, b);
    if (!(maximum > 0)) return [0, 0, 0, 0];
    if (colorMode === 'colorizedIntensity') {
      return [maximum, maximum, maximum, 1];
    }
    const rr = r ** 2.2;
    const gg = g ** 2.2;
    const bb = b ** 2.2;
    const ratio = maximum / Math.max(rr, gg, bb);
    return [rr * ratio, gg * ratio, bb * ratio, 1];
  }
  if (simulateColors) {
    return [
      -Math.log1p(-Math.min(Math.max(color[0], 0), 1 - 1e-7)),
      -Math.log1p(-Math.min(Math.max(color[1], 0), 1 - 1e-7)),
      -Math.log1p(-Math.min(Math.max(color[2], 0), 1 - 1e-7)),
      1
    ];
  }
  const alpha = Math.min(Math.max(color[3], 0), 1 - 1e-7);
  const density = -Math.log1p(-alpha);
  return [color[0] * density, color[1] * density,
    color[2] * density, density];
}

export function calculateWebGpuLineCoverage(
  signedSide,
  width,
  colorMode
) {
  if (colorMode === 'colorizedIntensity') {
    return Math.abs(signedSide) <= 0.5 * width ? 1 : 0;
  }
  const maximumCoverage = Math.min(1, Math.max(0, width));
  return Math.min(maximumCoverage, Math.max(
    0, 0.5 * width + 0.5 - Math.abs(signedSide)
  ));
}

export function calculateWebGpuArrowCoverage(
  signedSide,
  along,
  length,
  frontWidth,
  backWidth,
  colorMode
) {
  if (along < 0 || along > length || !(length > 0)) return 0;
  const sideSlope = 0.5 * (backWidth - frontWidth) / length;
  const halfWidth = 0.5 * (
    frontWidth + (backWidth - frontWidth) * along / length
  );
  const sideExpansion = Math.hypot(1, sideSlope);
  const signedDistance = (
    Math.abs(signedSide) - halfWidth
  ) / sideExpansion;
  if (colorMode === 'colorizedIntensity') {
    return signedDistance <= 0 ? 1 : 0;
  }
  const maximumCoverage = Math.min(
    1, Math.max(0, 2 * halfWidth / sideExpansion)
  );
  return Math.min(
    maximumCoverage, Math.max(0, 0.5 - signedDistance)
  );
}

export function calculateWebGpuPointCoverage(
  relativeX,
  relativeY,
  size,
  colorMode
) {
  const resolvedSize = Math.max(0, size);
  if (colorMode === 'colorizedIntensity') {
    return Math.abs(relativeX) <= 0.5 * resolvedSize &&
      Math.abs(relativeY) <= 0.5 * resolvedSize ? 1 : 0;
  }
  const maximumCoverage = Math.min(1, resolvedSize);
  const xCoverage = Math.min(maximumCoverage, Math.max(
    0, 0.5 * resolvedSize + 0.5 - Math.abs(relativeX)
  ));
  const yCoverage = Math.min(maximumCoverage, Math.max(
    0, 0.5 * resolvedSize + 0.5 - Math.abs(relativeY)
  ));
  return xCoverage * yCoverage;
}

export function calculateWebGpuDashCoverage(
  along,
  dashOn,
  dashOff,
  colorMode
) {
  if (!(dashOn > 0) || !(dashOff > 0)) return 1;
  const period = dashOn + dashOff;
  const withinDash = along - Math.floor(along / period) * period;
  if (colorMode === 'colorizedIntensity') {
    return withinDash < dashOn ? 1 : 0;
  }
  const signedDistance = withinDash <= dashOn
    ? -Math.min(withinDash, dashOn - withinDash)
    : Math.min(withinDash - dashOn, period - withinDash);
  const maximumCoverage = Math.min(1, dashOn);
  return Math.min(maximumCoverage, Math.max(
    0, 0.5 - signedDistance
  ));
}

export function applyWebGpuAnalyticCoverage(
  contribution,
  coverage,
  colorMode,
  simulateColors
) {
  if (colorMode === 'default' && !simulateColors) {
    const density = contribution[3];
    if (!(density > 0)) return [0, 0, 0, contribution[3]];
    const alpha = 1 - Math.exp(-density);
    const coveredDensity = -Math.log1p(-alpha * coverage);
    return [
      contribution[0] / density * coveredDensity,
      contribution[1] / density * coveredDensity,
      contribution[2] / density * coveredDensity,
      coveredDensity
    ];
  }
  if (colorMode === 'default' && simulateColors) {
    return [
      contribution[0] * coverage,
      contribution[1] * coverage,
      contribution[2] * coverage,
      contribution[3]
    ];
  }
  return [
    contribution[0] * coverage,
    contribution[1] * coverage,
    contribution[2] * coverage,
    contribution[3]
  ];
}

function colorModeId(mode, simulateColors) {
  if (mode === 'default' && simulateColors) return 5;
  switch (mode) {
    case 'default': return 0;
    case 'linear': return 1;
    case 'linearRGB': return 2;
    case 'reinhard': return 3;
    case 'colorizedIntensity': return 4;
    default: return 0;
  }
}

export function toneMapWebGpuColorContribution(
  color,
  mode,
  simulateColors
) {
  if (mode === 'default') {
    if (simulateColors) {
      const factor = Math.max(...color);
      const opacity = Math.min(Math.max(factor, 0), 1);
      const rgb = factor > 0
        ? color.map(value => value / factor * opacity)
        : [0, 0, 0];
      return [...rgb, opacity];
    }
    const density = Math.max(...color);
    const opacity = 1 - Math.exp(-density);
    const rgb = density > 0
      ? color.map(value => value / density * opacity)
      : [0, 0, 0];
    return [...rgb, opacity];
  }
  const maximum = Math.max(...color);
  if (mode === 'colorizedIntensity') {
    const mapped = brightnessToColorJs(maximum);
    return [mapped[0] * 0.8, mapped[1] * 0.8, mapped[2] * 0.8, 0];
  }
  if (mode === 'reinhard') {
    const luminance =
      color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722;
    const scale = 1 / (1 + luminance);
    return [
      (color[0] * scale) ** (1 / 2.2),
      (color[1] * scale) ** (1 / 2.2),
      (color[2] * scale) ** (1 / 2.2),
      maximum ** (1 / 2.2)
    ];
  }
  const divisor = mode === 'linear' && maximum > 1 ? maximum : 1;
  return [
    (color[0] / divisor) ** (1 / 2.2),
    (color[1] / divisor) ** (1 / 2.2),
    (color[2] / divisor) ** (1 / 2.2),
    Math.min(maximum, mode === 'linear' ? 1 : maximum) ** (1 / 2.2)
  ];
}

function brightnessToColorJs(brightness) {
  const interpolate = (left, right, t) => left.map(
    (value, index) => value + (right[index] - value) * t
  );
  if (brightness > 100) return [1, 0, 0, 1];
  const bands = [
    [10, 100, [1, 0.5, 0], [1, 0, 0]],
    [1, 10, [1, 1, 0], [1, 0.5, 0]],
    [0.1, 1, [0, 1, 0], [1, 1, 0]],
    [0.01, 0.1, [0, 1, 1], [0, 1, 0]],
    [0.001, 0.01, [0, 0, 1], [0, 1, 1]],
    [0.0001, 0.001, [0.3, 0, 0.3], [0, 0, 1]],
  ];
  for (const [minimum, maximum, left, right] of bands) {
    if (brightness > minimum) {
      const t = (Math.log2(brightness) - Math.log2(minimum)) /
        (Math.log2(maximum) - Math.log2(minimum));
      return [...interpolate(left, right, t), 1];
    }
  }
  const t = (Math.log2(Math.max(brightness, 1e-7)) - Math.log2(1e-7)) /
    (Math.log2(0.0001) - Math.log2(1e-7));
  return [...interpolate([0, 0, 0], [0.3, 0, 0.3], t), t];
}

function toByte(value) {
  return Math.round(Math.min(1, Math.max(0, value)) * 255);
}

function nextPowerOfTwo(value) {
  return 2 ** Math.ceil(Math.log2(Math.max(value, 4)));
}

async function validateShaderModule(module, label) {
  if (!module.getCompilationInfo) return;
  const info = await module.getCompilationInfo();
  const errors = info.messages.filter(message => message.type === 'error');
  if (errors.length === 0) return;
  const details = errors.map(message => {
    const position = message.lineNum
      ? `:${message.lineNum}:${message.linePos ?? 0}`
      : '';
    return `${label}${position} ${message.message}`;
  }).join('\n');
  throw new Error(`WebGPU shader compilation failed:\n${details}`);
}