Source

src/wasm.ts

import { WASM_ABI, WASM_BASE64, WASM_BYTE_LENGTH } from "./wasm/kernel";

/**
 * Experimental WASM acceleration for `Float16Image` pixel loops.
 *
 * Disabled by default: enable explicitly with `setWasmEnabled(true)`.
 * The WASM kernel is inlined as base64 (no `.wasm` asset URL handling), and
 * every accelerated call falls back to plain JS when WebAssembly is
 * unavailable, denied (e.g. CSP without `wasm-unsafe-eval`), or fails for any
 * other reason. The WASM path never throws.
 *
 * Measured reality (Node 24, gain over 12 M values): JS ~12 ms vs WASM ~51 ms
 * with only ~3 ms of that in copies. Engines with native `Float16Array`
 * convert via hardware F16C instructions that this scalar kernel can't beat,
 * so keep the flag off for gain. The loader stays as tested infrastructure
 * for future compute-heavy kernels where arithmetic intensity amortizes the
 * copy cost.
 */

type KernelExports = {
  memory: WebAssembly.Memory;
  ABI_VERSION: WebAssembly.Global;
  arena_reset: () => void;
  arena_alloc: (size: number) => number;
  gain_run: (ptr: number, quads: number, gr: number, gg: number, gb: number, ga: number) => void;
};

function envOptIn(): boolean {
  try {
    const proc = (globalThis as unknown as { process?: { env?: Record<string, string | undefined> } }).process;
    return proc?.env?.["HDR_CANVAS_WASM"] === "1";
  } catch {
    return false;
  }
}

let enabled = envOptIn();
let cached: KernelExports | null = null;
let failed = false;

/** Enables or disables the experimental WASM acceleration (default: disabled). */
export function setWasmEnabled(value: boolean): void {
  enabled = value;
  if (value) {
    // Allow a retry after a previous failure.
    failed = false;
  }
}

/** Whether WASM acceleration is enabled (opt-in via `setWasmEnabled(true)`). */
export function isWasmEnabled(): boolean {
  return enabled;
}

/** Whether a WASM kernel instance is currently instantiated and cached. */
export function isWasmReady(): boolean {
  return cached !== null;
}

/**
 * Minimum `Float16Array` length (values, not pixels) for dispatching to WASM.
 * Below this the call + copy overhead outweighs the kernel win.
 */
export const WASM_MIN_VALUES = 1 << 20;

function decodeBase64(b64: string): Uint8Array {
  const g = globalThis as unknown as {
    Buffer?: { from(s: string, enc: string): Uint8Array };
    atob?: (s: string) => string;
  };
  if (typeof g.Buffer?.from === "function") {
    return g.Buffer.from(b64, "base64");
  }
  if (typeof g.atob === "function") {
    const bin = g.atob(b64);
    const out = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) {
      out[i] = bin.charCodeAt(i);
    }
    return out;
  }
  throw new Error("no base64 decoder available");
}

function ensureInstance(): KernelExports | null {
  if (cached !== null) {
    return cached;
  }
  if (failed || typeof WebAssembly === "undefined") {
    return null;
  }
  try {
    const raw = decodeBase64(WASM_BASE64);
    if (raw.length !== WASM_BYTE_LENGTH) {
      failed = true;
      return null;
    }
    // Copy into a fresh Uint8Array so the type is Uint8Array<ArrayBuffer>,
    // as required by WebAssembly.Module.
    const bytes = Uint8Array.from(raw);
    const mod = new WebAssembly.Module(bytes);
    const instance = new WebAssembly.Instance(mod, {});
    const exports = instance.exports as unknown as KernelExports;
    if (exports.ABI_VERSION?.value !== WASM_ABI || typeof exports.gain_run !== "function") {
      failed = true;
      return null;
    }
    cached = exports;
    return cached;
  } catch {
    failed = true;
    return null;
  }
}

/**
 * Runs the WASM gain kernel in place over `data` (raw f16 bits, RGBA quads).
 * Alpha is multiplied by `ga` (pass `1` to preserve it).
 *
 * @returns `true` if the kernel ran, `false` if the caller should use the JS
 * implementation instead. Never throws.
 */
export function tryApplyGainWasm(data: Float16Array, gr: number, gg: number, gb: number, ga: number): boolean {
  try {
    if (data.length === 0 || data.length % 4 !== 0) {
      return false;
    }
    const kernel = ensureInstance();
    if (kernel === null) {
      return false;
    }
    // Views are (re)created per call because growing memory detaches buffers.
    const source = new Uint16Array(data.buffer, data.byteOffset, data.length);
    kernel.arena_reset();
    const ptr = kernel.arena_alloc(data.length * 2);
    if (ptr === 0) {
      return false;
    }
    new Uint16Array(kernel.memory.buffer, ptr, data.length).set(source);
    kernel.gain_run(ptr, data.length / 4, gr, gg, gb, ga);
    source.set(new Uint16Array(kernel.memory.buffer, ptr, data.length));
    return true;
  } catch {
    return false;
  }
}