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:
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:
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:
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:
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:
fwidthis small → 1-unit lines are crisp and individually visible - Zoomed out:
fwidthis 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:
<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
// 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.