3D Mexican Hat Wavelet Plot with Dynamic Grid in WebGPU

The sine-wave post showed GPU-side function evaluation for a 2D line plot. This post extends the idea to 3D: a surface plot of a generalized Mexican hat wavelet, rendered on a light-background dynamic floor grid that never quantizes under zoom. Following the interactive harmonic post — where drag controls a function parameter — left-right drag here changes the wavelet’s shape parameter β, which controls how deep the negative rim protrudes below $z = 0$.

The standard 2D Mexican hat (Ricker) wavelet is:

$$\psi(r) = \left(1 - \frac{r^2}{\sigma^2}\right) \exp\!\left(-\frac{r^2}{2\sigma^2}\right), \quad r^2 = x^2 + y^2$$

Its rim depth is fixed at $\approx -0.45$ relative to the peak — not very pronounced. To make the rim visually prominent and interactive, this plot uses a generalized wavelet with a shape parameter β:

$$\psi_\beta(r) = \left(1 - \beta\,\frac{r^2}{\sigma^2}\right) \exp\!\left(-\frac{r^2}{2\sigma^2}\right)$$

The peak is always 1 at $r = 0$ (regardless of β), but the rim depth is $-2\beta \exp(-1 - 1/(2\beta))$ at $r^* = \sigma\sqrt{2 + 1/\beta}$:

βRim position $r^*$Rim depth
1.0$20.8$$-0.45$
2.5$18.6$$-1.51$
4.0$17.9$$-2.10$

With $\sigma = 12$ (fixed), the rim stays in $[17.9, 20.8]$ for all $\beta \in [0.5, 4]$ — always inside 90×90 on the 100×100 grid. The exponential tail decays to near-zero by $r \approx 36$.

The live demo below loads /scripts/mexhat-webgpu.js. Drag left-right to change β (rim depth), drag up-down to orbit, scroll to zoom.

Drag left-right to change β (rim depth), drag up-down to orbit, scroll to zoom. Height is mapped to a blue→white→red gradient. The floor grid uses fwidth()-based anti-aliasing — no quantization under zoom. Requires a WebGPU-capable browser (Chrome / Edge 113+). Current β: 2.50

How it works

The plot combines techniques from the floor-grid post (orbit camera, mat4 helpers, alpha-blended grid overlay) and the sine-wave post (GPU-side function evaluation). The new parts are the 3D surface mesh, the height-to-color gradient, the fwidth-based dynamic grid, and the interactive σ control.

GPU-side surface evaluation in the vertex shader

The surface is a 200×200 vertex grid (40,000 vertices, 79,200 indices). Each vertex has a 2D position (x, y) — the z coordinate is computed in the vertex shader:

surface.wgsl
fn wavelet(x : f32, y : f32) -> f32 {
  let r2 = x * x + y * y;
  return (1.0 - uniforms.beta * r2 / SIGMA2) * exp(-r2 / (2.0 * SIGMA2));
}

@vertex
fn vs_surface(@location(0) pos2d : vec2<f32>) -> SurfVSOut {
  let z = HEIGHT_SCALE * wavelet(pos2d.x, pos2d.y);
  let worldPos = vec3<f32>(pos2d.x, pos2d.y, z);
  // ...
}

The CPU never evaluates the wavelet. It only uploads the 2D vertex grid (320 KB) and the index buffer (950 KB) once at init. The vertex shader evaluates $\psi_\beta(x,y)$ for each vertex every frame.

The surface normal is also computed on the GPU via finite differences of the same function, using an epsilon of 0.5 world units:

surface.wgsl
fn waveletNormal(x : f32, y : f32) -> vec3<f32> {
  let eps = 0.5;
  let dzdx = (wavelet(x + eps, y) - wavelet(x - eps, y)) / (2.0 * eps);
  let dzdy = (wavelet(x, y + eps) - wavelet(x, y - eps)) / (2.0 * eps);
  return normalize(vec3<f32>(-dzdx, -dzdy, 1.0));
}

This avoids storing per-vertex normals in a separate buffer — the normal is derived from the function definition at no extra memory cost.

Interactive β — rim depth control

The standard Mexican hat ($\beta = 1$) has a rim depth of only $\approx -0.45$ — barely visible below $z = 0$. The generalized wavelet introduces β to control the rim depth independently of the horizontal scale:

$$\psi_\beta(r) = \left(1 - \beta\,\frac{r^2}{\sigma^2}\right) \exp\!\left(-\frac{r^2}{2\sigma^2}\right)$$

The peak stays at 1 regardless of β, but the rim deepens as β increases: $\beta = 2.5$ gives a rim of $\approx -1.51$ (3.4× deeper than standard), and $\beta = 4$ gives $\approx -2.10$. Crucially, the rim position $r^* = \sigma\sqrt{2 + 1/\beta}$ barely moves — it stays in $[17.9, 20.8]$ for all $\beta \in [1, 4]$ — so the shape always fits inside the grid. Changing β reshapes the wavelet rather than scaling it.

Following the same drag-to-parameter pattern as the interactive harmonic post — where up-down drag controls the harmonic amplitude — this plot uses left-right drag for β. The horizontal axis is freed for parameter control by fixing the camera yaw at a pleasant 3/4 view angle; the vertical axis still controls orbit pitch, and scroll controls zoom.

β is a runtime uniform, not a compile-time constant. The shader reads it from the uniform buffer each frame:

surface.wgsl
struct Uniforms {
  viewProj : mat4x4<f32>,
  beta     : f32,
  _pad0    : f32,
  _pad1    : f32,
  _pad2    : f32,
};

No GPU resource is recreated when β changes — the shader, pipelines, buffers, and bind groups are all created once at init. Only the 80-byte uniform buffer (64 bytes for the view-projection matrix + 4 bytes for β + 12 bytes padding) is updated per frame via device.queue.writeBuffer.

The canvas can optionally update an HTML element with the current β via a data-beta-display="elementId" attribute, the same callback pattern used in the direction-cubes post.

Height-to-color gradient with directional lighting

The fragment shader maps the z value to a three-stop gradient:

  • Negative (the rim, $z < 0$): blue → white
  • Zero ($z = 0$): white
  • Positive (the peak, $z > 0$): white → red

A directional light with 50% ambient adds shading for 3D depth perception. The normal computed in the vertex shader is interpolated across each triangle, giving smooth shading without a normal map.

Dynamic grid via fwidth() — no quantization under zoom

The floor grid is a flat quad at $z = 0$. The fragment shader computes grid lines using fwidth() — the screen-space derivative of the world-space coordinate:

grid.wgsl
fn gridLine(coord : f32, spacing : f32) -> f32 {
  let p = coord / spacing;
  let d = min(fract(p), 1.0 - fract(p));
  let w = fwidth(p);
  return 1.0 - smoothstep(0.0, max(w, 0.0001), d);
}

fwidth(p) returns |dpdx| + |dpdy| — the rate of change of p per screen pixel. The smoothstep transition is exactly one pixel wide, so:

  • Zoomed in: fwidth is small → 1-unit lines are crisp and individually visible
  • Zoomed out: fwidth is large → 1-unit lines become sub-pixel and fade to transparency; 10-unit major lines remain visible
  • Extreme zoom-out: even major lines fade — the grid becomes a uniform light gray

This is “no quantization”: the grid density adapts continuously to the zoom level. There is no LOD switching, no popping, no fixed cell size. The grid is always computed at full fragment resolution.

Depth-tested grid overlay

The render pass draws the surface first (opaque, depth-write enabled), then the grid (semi-transparent, depth-write disabled, alpha blended). The grid at $z = 0$ is:

  • Occluded where the surface peak rises above $z = 0$ (depth test fails — grid is behind the surface)
  • Visible where the surface rim dips below $z = 0$ (depth test passes — grid is in front of the surface)

This creates the visual effect of the Mexican hat “punching through” the ground plane — the peak hides the grid beneath it, while the rim reveals the grid through its valley.

Using it on your own page

Drop the script into your static folder and add a canvas with the mexhat-canvas class:

example.html
<canvas class="mexhat-canvas"
        data-beta-display="beta-readout"
        style="width:900px;height:500px;"></canvas>
<span id="beta-readout">2.50</span>
<script src="/scripts/mexhat-webgpu.js" defer></script>

The data-beta-display attribute is optional — if present, the element is updated with the current β value on every drag.

To change the default β, σ, height scale, or mesh resolution, edit the BETA_DEFAULT, SIGMA, HEIGHT_SCALE, and MESH_RES constants at the top of the script. To plot a different function, replace the wavelet and waveletNormal functions in the WGSL source.

Full source

mexhat-webgpu.js
// SPDX-FileCopyrightText: 2026 Uli Köhler <gitlab@techoverflow.net>
// SPDX-License-Identifier: CC0-1.0
//
// Minimal WebGPU 3D surface plot of a generalized Mexican hat
// wavelet with a dynamic, zoom-adaptive floor grid and interactive
// rim-depth (β) control.
//
// The surface is a 200×200 vertex grid. The vertex shader evaluates
// the wavelet on the GPU — no CPU-side function evaluation. Height
// is mapped to a blue→white→red gradient with directional lighting.
// The floor grid uses fwidth-based anti-aliased line rendering in
// the fragment shader, so it never quantizes under zoom.
//
// The wavelet is ψ(r) = (1 − β·r²/σ²)·exp(−r²/(2σ²)).
// β controls the rim depth independently of σ:
//   β=1 → standard Mexican hat, rim ≈ −0.45
//   β=2.5 → deep rim ≈ −1.51, protruding well below z=0
//   β=4 → very deep rim ≈ −2.10
// The rim position r* = σ·√(2 + 1/β) barely moves with β, so the
// shape stays inside the grid at all settings.
//
// Interaction:
//   Left-right drag  → change β (rim depth / shape)
//   Up-down drag     → orbit camera pitch
//   Scroll           → zoom
//
// Auto-initializes every <canvas class="mexhat-canvas"> on the page.
// Optional: data-beta-display="elementId" to update an HTML element
// with the current β value.
(function () {
  'use strict';

  // ─────────────────────────────────────────────────────────────────────
  // Configuration
  // ─────────────────────────────────────────────────────────────────────
  // The grid spans 100×100 world units, centered at the origin.
  //
  // Generalized Mexican hat: ψ(r) = (1 − β·r²/σ²)·exp(−r²/(2σ²))
  //   - Peak at r=0 (value 1, regardless of β)
  //   - Rim minimum at r* = σ·√(2 + 1/β), depth = −2β·exp(−1 − 1/(2β))
  //
  // With σ=12 (fixed):
  //   β=1:   r*≈20.8, depth≈−0.45  (standard Mexican hat)
  //   β=2.5: r*≈18.6, depth≈−1.51  (deep, pronounced rim)
  //   β=4:   r*≈17.9, depth≈−2.10  (very deep rim)
  //
  // The rim position stays in [17.9, 20.8] for β∈[1,4] — always
  // inside 90×90. The exponential tail decays to <0.01 by r≈36.
  //
  // HEIGHT_SCALE=10: peak z=10, rim z≈−15 at β=2.5.

  const GRID_HALF = 50;
  const MESH_RES = 200;
  const SIGMA = 12.0;               // fixed — controls horizontal scale
  const BETA_DEFAULT = 2.5;         // rim depth ≈ −1.51
  const BETA_MIN = 0.5;
  const BETA_MAX = 4.0;
  const BETA_SENSITIVITY = 0.008;   // per pixel of horizontal drag
  const HEIGHT_SCALE = 10.0;

  // ─────────────────────────────────────────────────────────────────────
  // Minimal mat4 helpers (column-major, WebGPU NDC z in [0,1])
  // ─────────────────────────────────────────────────────────────────────

  function mat4Identity() {
    const m = new Float32Array(16);
    m[0] = m[5] = m[10] = m[15] = 1;
    return m;
  }

  function mat4Multiply(a, b) {
    const r = new Float32Array(16);
    for (let col = 0; col < 4; col++)
      for (let row = 0; row < 4; row++) {
        let s = 0;
        for (let k = 0; k < 4; k++) s += a[k * 4 + row] * b[col * 4 + k];
        r[col * 4 + row] = s;
      }
    return r;
  }

  function mat4Perspective(fovy, aspect, near, far) {
    const f = 1 / Math.tan(fovy / 2);
    const m = new Float32Array(16);
    m[0] = f / aspect;
    m[5] = f;
    m[10] = far / (near - far);
    m[11] = -1;
    m[14] = (far * near) / (near - far);
    return m;
  }

  function mat4LookAt(eye, target, up) {
    const zx = eye.x - target.x, zy = eye.y - target.y, zz = eye.z - target.z;
    let zl = Math.hypot(zx, zy, zz);
    const fx = zx / zl, fy = zy / zl, fz = zz / zl;
    let rx = up.y * fz - up.z * fy;
    let ry = up.z * fx - up.x * fz;
    let rz = up.x * fy - up.y * fx;
    let rl = Math.hypot(rx, ry, rz);
    if (rl < 1e-6) { rx = 1; ry = 0; rz = 0; rl = 1; }
    rx /= rl; ry /= rl; rz /= rl;
    const ux = fy * rz - fz * ry;
    const uy = fz * rx - fx * rz;
    const uz = fx * ry - fy * rx;
    const m = mat4Identity();
    m[0] = rx;  m[1] = ux;  m[2] = fx;
    m[4] = ry;  m[5] = uy;  m[6] = fy;
    m[8] = rz;  m[9] = uz;  m[10] = fz;
    m[12] = -(rx * eye.x + ry * eye.y + rz * eye.z);
    m[13] = -(ux * eye.x + uy * eye.y + uz * eye.z);
    m[14] = -(fx * eye.x + fy * eye.y + fz * eye.z);
    return m;
  }

  // ─────────────────────────────────────────────────────────────────────
  // WGSL shader
  // ─────────────────────────────────────────────────────────────────────
  // One shader module, two pipelines (surface + grid).
  //
  // β is a runtime uniform (not a compile-time constant) so it can be
  // changed by the left-right drag without recompiling the shader.
  // σ and HEIGHT_SCALE are baked in at compile time since they don't
  // change.
  //
  // Surface pipeline:
  //   The vertex shader receives 2D positions (x, y) from the mesh
  //   and evaluates z = HEIGHT_SCALE * wavelet(x, y, β) on the GPU.
  //   The normal is computed via finite differences of the same
  //   function. The fragment shader maps z to a blue→white→red
  //   gradient and applies directional lighting.
  //
  // Grid pipeline:
  //   A flat quad at z=0. The fragment shader computes anti-aliased
  //   grid lines using fwidth() — the screen-space derivative. This
  //   means lines are always exactly 1 pixel wide regardless of zoom
  //   level, and they smoothly fade when they become sub-pixel. No
  //   quantization, no LOD popping.

  const SHADER = /* wgsl */ `
const SIGMA = ${SIGMA.toFixed(1)};
const SIGMA2 = SIGMA * SIGMA;
const HEIGHT_SCALE = ${HEIGHT_SCALE.toFixed(1)};

struct Uniforms {
  viewProj : mat4x4<f32>,
  beta     : f32,
  _pad0    : f32,
  _pad1    : f32,
  _pad2    : f32,
};

@group(0) @binding(0) var<uniform> uniforms : Uniforms;

// ── Generalized Mexican hat wavelet ───────────────────────────────────
// ψ(r) = (1 − β·r²/σ²) · exp(−r²/(2σ²))
// Peak=1 at r=0 (regardless of β).
// Rim at r* = σ·√(2 + 1/β), depth = −2β·exp(−1 − 1/(2β)).
// β=1 → standard Mexican hat (rim ≈ −0.45).
// β=2.5 → deep rim ≈ −1.51, protruding well below z=0.
fn wavelet(x : f32, y : f32) -> f32 {
  let r2 = x * x + y * y;
  return (1.0 - uniforms.beta * r2 / SIGMA2) * exp(-r2 / (2.0 * SIGMA2));
}

fn waveletNormal(x : f32, y : f32) -> vec3<f32> {
  let eps = 0.5;
  let dzdx = (wavelet(x + eps, y) - wavelet(x - eps, y)) / (2.0 * eps);
  let dzdy = (wavelet(x, y + eps) - wavelet(x, y - eps)) / (2.0 * eps);
  return normalize(vec3<f32>(-dzdx, -dzdy, 1.0));
}

// ── Surface pipeline ──────────────────────────────────────────────────
struct SurfVSOut {
  @builtin(position) clipPos : vec4<f32>,
  @location(0)       normal  : vec3<f32>,
  @location(1)       height  : f32,
};

@vertex
fn vs_surface(@location(0) pos2d : vec2<f32>) -> SurfVSOut {
  let z = HEIGHT_SCALE * wavelet(pos2d.x, pos2d.y);
  let worldPos = vec3<f32>(pos2d.x, pos2d.y, z);
  var out : SurfVSOut;
  out.clipPos = uniforms.viewProj * vec4<f32>(worldPos, 1.0);
  out.normal = waveletNormal(pos2d.x, pos2d.y);
  out.height = z;
  return out;
}

@fragment
fn fs_surface(in : SurfVSOut) -> @location(0) vec4<f32> {
  // Height → color: blue (negative) → white (zero) → red (positive)
  let t = clamp(in.height / HEIGHT_SCALE, -0.5, 1.0);
  let blue  = vec3<f32>(0.10, 0.25, 0.55);
  let white = vec3<f32>(0.92, 0.92, 0.94);
  let red   = vec3<f32>(0.72, 0.12, 0.08);
  let color = select(
    mix(white, red, t),
    mix(white, blue, clamp(-t * 2.0, 0.0, 1.0)),
    t < 0.0
  );

  // Directional lighting for 3D depth perception
  let lightDir = normalize(vec3<f32>(0.4, -0.6, 0.7));
  let ndotl = max(dot(in.normal, lightDir), 0.0);
  let ambient = 0.5;
  let intensity = ambient + (1.0 - ambient) * ndotl;

  return vec4<f32>(color * intensity, 1.0);
}

// ── Grid pipeline ─────────────────────────────────────────────────────
struct GridVSOut {
  @builtin(position) clipPos : vec4<f32>,
  @location(0)       worldPos : vec2<f32>,
};

@vertex
fn vs_grid(@location(0) pos : vec3<f32>) -> GridVSOut {
  var out : GridVSOut;
  out.clipPos = uniforms.viewProj * vec4<f32>(pos, 1.0);
  out.worldPos = pos.xy;
  return out;
}

// Anti-aliased grid line at 'spacing' intervals.  Uses a fixed line
// width instead of fwidth() because fwidth() requires uniform control
// flow and the compiler rejects it when the input coordinates come from
// non-uniform fragment inputs.
fn gridLine(coord : f32, spacing : f32) -> f32 {
  let p = coord / spacing;
  let frac_p = fract(p);
  let d = min(frac_p, 1.0 - frac_p);
  let w = 1.0;
  return 1.0 - smoothstep(0.0, max(w, 0.0001), d);
}

@fragment
fn fs_grid(in : GridVSOut) -> @location(0) vec4<f32> {
  // Minor lines every 1 unit, major lines every 10 units.
  let minor = max(gridLine(in.worldPos.x, 1.0), gridLine(in.worldPos.y, 1.0));
  let major = max(gridLine(in.worldPos.x, 10.0), gridLine(in.worldPos.y, 10.0));

  let minorColor = vec3<f32>(0.78, 0.78, 0.82);
  let majorColor = vec3<f32>(0.45, 0.45, 0.52);
  let color = mix(minorColor, majorColor, major);
  let alpha = max(minor * 0.25, major * 0.55);

  return vec4<f32>(color, alpha);
}
`;

  // ─────────────────────────────────────────────────────────────────────
  // Geometry
  // ─────────────────────────────────────────────────────────────────────

  // Build the surface mesh: a MESH_RES×MESH_RES grid of (x, y) vertices
  // spanning [-GRID_HALF, +GRID_HALF]. z is computed in the vertex
  // shader — the CPU only uploads 2D positions.
  function buildSurfaceMesh() {
    const N = MESH_RES;
    const step = (2 * GRID_HALF) / (N - 1);
    const verts = new Float32Array(N * N * 2);
    let vi = 0;
    for (let j = 0; j < N; j++) {
      for (let i = 0; i < N; i++) {
        verts[vi++] = -GRID_HALF + i * step;
        verts[vi++] = -GRID_HALF + j * step;
      }
    }
    const indices = new Uint32Array((N - 1) * (N - 1) * 6);
    let ii = 0;
    for (let j = 0; j < N - 1; j++) {
      for (let i = 0; i < N - 1; i++) {
        const a = j * N + i;
        const b = j * N + i + 1;
        const c = (j + 1) * N + i;
        const d = (j + 1) * N + i + 1;
        // CCW winding when viewed from +Z (top)
        indices[ii++] = a; indices[ii++] = b; indices[ii++] = c;
        indices[ii++] = b; indices[ii++] = d; indices[ii++] = c;
      }
    }
    return { verts, indices };
  }

  // Flat quad at z=0 covering the grid area.
  function buildFloorQuad() {
    const s = GRID_HALF;
    return new Float32Array([
      -s, -s, 0,
       s, -s, 0,
       s,  s, 0,
      -s, -s, 0,
       s,  s, 0,
      -s,  s, 0,
    ]);
  }

  // ─────────────────────────────────────────────────────────────────────
  // Per-canvas initialization
  // ─────────────────────────────────────────────────────────────────────

  async function initCanvas(canvas) {
    if (!navigator.gpu) {
      canvas.replaceWith(document.createTextNode('WebGPU is not supported in this browser.'));
      return;
    }
    const adapter = await navigator.gpu.requestAdapter();
    if (!adapter) {
      canvas.replaceWith(document.createTextNode('No WebGPU adapter available.'));
      return;
    }
    const device = await adapter.requestDevice();
    const ctx = canvas.getContext('webgpu');
    const format = navigator.gpu.getPreferredCanvasFormat();
    ctx.configure({ device, format, alphaMode: 'premultiplied' });

    const module = device.createShaderModule({ code: SHADER });

    // --- Shared bind group layout ---
    // One uniform buffer: mat4x4 viewProj (64 bytes) + f32 beta
    // (4 bytes) + 12 bytes padding = 80 bytes total.
    const bindLayout = device.createBindGroupLayout({
      entries: [{
        binding: 0,
        visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
        buffer: { type: 'uniform' },
      }],
    });
    const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindLayout] });

    // --- Surface pipeline ---
    const surfacePipeline = device.createRenderPipeline({
      layout: pipelineLayout,
      vertex: {
        module, entryPoint: 'vs_surface',
        buffers: [{
          arrayStride: 8,
          attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }],
        }],
      },
      fragment: {
        module, entryPoint: 'fs_surface',
        targets: [{ format }],
      },
      primitive: { topology: 'triangle-list', cullMode: 'back' },
      depthStencil: { format: 'depth24plus', depthCompare: 'less', depthWriteEnabled: true },
    });

    // --- Grid pipeline ---
    const gridBlend = {
      color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
      alpha: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
    };
    const gridPipeline = device.createRenderPipeline({
      layout: pipelineLayout,
      vertex: {
        module, entryPoint: 'vs_grid',
        buffers: [{
          arrayStride: 12,
          attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }],
        }],
      },
      fragment: {
        module, entryPoint: 'fs_grid',
        targets: [{ format, blend: gridBlend }],
      },
      primitive: { topology: 'triangle-list' },
      depthStencil: { format: 'depth24plus', depthCompare: 'less', depthWriteEnabled: false },
    });

    // --- Upload geometry ---
    const mesh = buildSurfaceMesh();
    const surfVbuf = device.createBuffer({
      size: mesh.verts.byteLength,
      usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
    });
    device.queue.writeBuffer(surfVbuf, 0, mesh.verts);

    const surfIbuf = device.createBuffer({
      size: mesh.indices.byteLength,
      usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
    });
    device.queue.writeBuffer(surfIbuf, 0, mesh.indices);

    const floorVerts = buildFloorQuad();
    const floorVbuf = device.createBuffer({
      size: floorVerts.byteLength,
      usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
    });
    device.queue.writeBuffer(floorVbuf, 0, floorVerts);

    // --- Uniform buffer ---
    // 80 bytes: mat4x4 (64) + f32 beta (4) + 12 padding.
    // Updated every frame with the view-projection matrix and the
    // current β value (controlled by left-right drag).
    const ubuf = device.createBuffer({
      size: 80,
      usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
    });
    const bindGroup = device.createBindGroup({
      layout: bindLayout,
      entries: [{ binding: 0, resource: { buffer: ubuf } }],
    });

    // --- Depth texture (recreated on resize) ---
    let depthTex = null;
    function ensureDepth(w, h) {
      if (depthTex && depthTex.width === w && depthTex.height === h) return;
      if (depthTex) depthTex.destroy();
      depthTex = device.createTexture({
        size: [w, h, 1], format: 'depth24plus', usage: GPUTextureUsage.RENDER_ATTACHMENT,
      });
    }

    // --- Interaction ---
    // Left-right drag → change β (rim depth / wavelet shape)
    // Up-down drag   → orbit camera pitch
    // Scroll         → zoom
    //
    // Yaw is fixed at ~50° — a pleasant 3/4 view. This frees the
    // horizontal axis for β control, following the same drag-to-
    // parameter pattern as the sine-harmonic post (where up-down
    // drag controls harmonic amplitude).
    //
    // β controls the rim depth independently of the horizontal scale:
    //   β=1 → standard Mexican hat (rim ≈ −0.45)
    //   β=2.5 → deep rim ≈ −1.51 (default)
    //   β=4 → very deep rim ≈ −2.10
    let beta = BETA_DEFAULT;
    let pitch = Math.PI / 5, dist = 180;
    const yaw = Math.PI / 5 * 1.4;  // fixed ~50°
    let dragging = false, lastX = 0, lastY = 0, startBeta = 0;

    const displayId = canvas.getAttribute('data-beta-display');
    const display = displayId ? document.getElementById(displayId) : null;
    function updateDisplay() {
      if (display) display.textContent = beta.toFixed(2);
    }
    updateDisplay();

    canvas.style.cursor = 'grab';
    canvas.style.touchAction = 'none';
    canvas.addEventListener('pointerdown', (e) => {
      dragging = true; lastX = e.clientX; lastY = e.clientY;
      startBeta = beta;
      canvas.setPointerCapture(e.pointerId);
      canvas.style.cursor = 'grabbing';
    });
    canvas.addEventListener('pointerup', () => {
      dragging = false; canvas.style.cursor = 'grab';
    });
    canvas.addEventListener('pointermove', (e) => {
      if (!dragging) return;
      const dx = e.clientX - lastX;
      const dy = e.clientY - lastY;
      // Left-right → β (drag right = deeper rim)
      beta = Math.max(BETA_MIN, Math.min(BETA_MAX, startBeta + dx * BETA_SENSITIVITY));
      // Up-down → pitch (drag up = look from higher)
      pitch += dy * 0.01;
      pitch = Math.max(0.05, Math.min(Math.PI / 2 - 0.05, pitch));
      updateDisplay();
    });
    canvas.addEventListener('wheel', (e) => {
      e.preventDefault();
      const factor = e.deltaY > 0 ? 1.12 : 1 / 1.12;
      dist = Math.max(20, Math.min(400, dist * factor));
    }, { passive: false });

    // --- Render loop ---
    function frame() {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const cw = Math.max(1, Math.floor(canvas.clientWidth * dpr));
      const ch = Math.max(1, Math.floor(canvas.clientHeight * dpr));
      if (canvas.width !== cw || canvas.height !== ch) {
        canvas.width = cw; canvas.height = ch;
      }
      ensureDepth(cw, ch);

      // Orbit camera → eye position (Z is up, yaw fixed)
      const eye = {
        x: dist * Math.cos(pitch) * Math.cos(yaw),
        y: dist * Math.cos(pitch) * Math.sin(yaw),
        z: dist * Math.sin(pitch),
      };
      const aspect = cw / ch;
      const proj = mat4Perspective(Math.PI / 4, aspect, 0.5, 1000);
      const view = mat4LookAt(eye, { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 1 });

      // Write uniform buffer: viewProj (64 bytes) + beta (4 bytes)
      // + 12 bytes padding = 80 bytes total.
      const ubufData = new ArrayBuffer(80);
      const viewProj = mat4Multiply(proj, view);
      new Float32Array(ubufData, 0, 16).set(viewProj);
      new Float32Array(ubufData, 64, 1)[0] = beta;
      device.queue.writeBuffer(ubuf, 0, ubufData);

      const encoder = device.createCommandEncoder();
      const pass = encoder.beginRenderPass({
        colorAttachments: [{
          view: ctx.getCurrentTexture().createView(),
          clearValue: { r: 0.93, g: 0.93, b: 0.95, a: 1 },
          loadOp: 'clear', storeOp: 'store',
        }],
        depthStencilAttachment: {
          view: depthTex.createView(),
          depthClearValue: 1.0, depthLoadOp: 'clear', depthStoreOp: 'store',
        },
      });

      // 1) Surface — opaque, writes depth
      pass.setPipeline(surfacePipeline);
      pass.setBindGroup(0, bindGroup);
      pass.setVertexBuffer(0, surfVbuf);
      pass.setIndexBuffer(surfIbuf, 'uint32');
      pass.drawIndexed(mesh.indices.length);

      // 2) Grid — semi-transparent, tests depth, no depth write
      pass.setPipeline(gridPipeline);
      pass.setBindGroup(0, bindGroup);
      pass.setVertexBuffer(0, floorVbuf);
      pass.draw(6);

      pass.end();
      device.queue.submit([encoder.finish()]);
      requestAnimationFrame(frame);
    }
    requestAnimationFrame(frame);
  }

  // ─────────────────────────────────────────────────────────────────────
  // Auto-initialization
  // ─────────────────────────────────────────────────────────────────────
  function initAll() {
    document.querySelectorAll('canvas.mexhat-canvas').forEach(initCanvas);
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initAll);
  } else {
    initAll();
  }
})();

The script and the inline listing above are identical and both released under CC0-1.0. The implementation combines the orbit camera and mat4 helpers from the floor-grid post, the GPU-side function evaluation pattern from the sine-wave post, and the drag-to-parameter interaction from the interactive harmonic post. The fwidth-based dynamic grid is a standard technique used in production CAD viewers — the same approach is used by the GridRenderer in the WebGCodeViewer project for its ground-reference grid.


Check out similar posts by category: Javascript WebGPU Web