A direction-cubes toolbar is the row of small 3D cubes you see in CAD viewers (e.g. FreeCAD’s navigation cube) where each cube highlights a different face. Clicking a cube snaps the main 3D view to that orientation — top, front, right, iso, etc. Unlike the single draggable orientation cube from the previous post, this widget is a grid of buttons: seven cubes rendered to one canvas, each pre-rotated to the same isometric view but highlighting a different face.
The live demo below loads /scripts/direction-cubes-webgpu.js. Click any
cube and the selected direction is shown below the canvas.
Click a cube to select a view direction.
How it works
The widget is a single self-contained script. The interesting parts, beyond the basic WebGPU bootstrap from the previous post, are:
- One canvas, seven cubes via viewports. A single render pass draws all
seven cubes. Before each cube’s draw call,
pass.setViewport()andpass.setScissorRect()restrict rendering to that cube’s grid cell. This is far cheaper than seven separate canvases or seven render passes. - One bind group, dynamic uniform offsets. All cubes share the same
bind group; only the offset into the uniform buffer changes per draw
(
pass.setBindGroup(0, bindGroup, [i * UNIFORM_SLOT_SIZE])). WebGPU requires dynamic-offset uniform slots to be 256-byte aligned, soUNIFORM_SLOT_SIZE = 256even though the actual uniform data is only 80 bytes (onemat4x4<f32>+ onef32+ padding). - Shared isometric view, per-cube highlight. Every cube uses the same
viewProjmatrix (camera at(2, -2, 2)looking at the origin, up = +Z). The only per-cube uniform that changes ishighlightedFace, ani32that selects which face the fragment shader renders brighter and more opaque. Theisocube uses-1so no face is highlighted. - Two pipelines, one shader. The WGSL module contains two vertex and
two fragment entry points. The face pipeline draws semi-transparent
triangles with premultiplied-alpha blending; the edge pipeline draws
the cube wireframe as a
line-listwithdepthCompare: 'always'so the edges stay visible on top of the faces. Faces usedepthWriteEnabled: falseso edges are never occluded by face geometry. - Click → grid cell → direction. The canvas click handler divides the
canvas into a 4×2 grid, maps the click to a cell index, and looks up the
direction name from
['iso','top','front','right','left','back','bottom']. The script dispatches adirectionselectedCustomEventon the canvas and optionally calls a global callback named in the canvas’sdata-onselectattribute.
The opacity tiers — 10% for unmarked faces, 50% for edges, 65% for the
highlighted face — are what make the selected face pop without needing
per-face colors. The highlighted face uses a fixed light gray (0.85)
regardless of its orientation, so it is always the brightest face in every
cube.
Using it on your own page
Drop /scripts/direction-cubes-webgpu.js into your static folder, add a canvas with the
nav-dircubes-canvas class, and wire up a callback via data-onselect:
<canvas class="nav-dircubes-canvas"
data-onselect="onDirSelected"
style="width:640px;height:160px;"></canvas>
<p id="result"></p>
<script>
function onDirSelected(dir) {
document.getElementById('result').textContent =
'Selected: ' + dir;
}
</script>
<script src="/scripts/direction-cubes-webgpu.js" defer></script>The callback receives one of 'iso', 'top', 'front', 'right',
'left', 'back', 'bottom'. You can also listen for the
directionselected CustomEvent on the canvas directly (the selected
direction is in event.detail).
Size the canvas with CSS; the script handles devicePixelRatio scaling and
depth-texture resizing internally.
Full source
// SPDX-FileCopyrightText: 2026 Uli Köhler <gitlab@techoverflow.net>
// SPDX-License-Identifier: CC0-1.0
//
// Minimal WebGPU "direction cubes" navigation toolbar.
// Renders a 4x2 grid of small isometric cubes on a single canvas. Each cube
// highlights a different face (or none for the iso cube) to indicate which
// view direction it will snap the camera to. Clicking a cube emits a
// 'directionselected' CustomEvent on the canvas.
//
// Auto-initializes every <canvas class="nav-dircubes-canvas"> on the page.
// Optional: set data-onselect="myCallback" on the canvas to receive the
// selected direction name as a string ('iso','top','front','right','left',
// 'back','bottom').
//
// Why an IIFE? We wrap everything in an immediately-invoked function
// expression so that none of the helpers, constants, or the WGSL source
// leak into the global scope. The script is intended to be included
// verbatim with <script src=... defer>, possibly alongside other scripts
// on the same page, so isolation matters.
(function () {
'use strict';
// ─────────────────────────────────────────────────────────────────────
// Minimal mat4 helpers (column-major, WebGPU NDC z in [0,1])
// ─────────────────────────────────────────────────────────────────────
// WebGPU / WGSL stores matrices column-major, i.e. element [col*4+row].
// We mirror that layout here so we can upload the Float32Array directly
// to a uniform buffer without any transpose step.
// We only implement the handful of matrices we need: identity, multiply,
// perspective, and lookAt. No dependency on gl-matrix or similar — the
// goal is a single self-contained file.
function mat4Identity() {
const m = new Float32Array(16);
m[0] = m[5] = m[10] = m[15] = 1;
return m;
}
// Returns a * b in the sense that (a * b) * v applies b first, then a.
// This is the convention used everywhere in this file: the view-projection
// is built as proj * view, so a point is transformed view→clip.
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;
}
// Right-handed perspective projection matrix.
// The camera looks down -Z, so visible points have z < 0.
// This is the WebGPU convention: clip space z is in [0, 1] (not [-1, 1]
// as in OpenGL), which is why m[10] = far/(near-far) and m[14] has the
// extra near*far term. Getting this wrong is the #1 source of "my
// geometry disappeared" bugs when porting WebGL code to WebGPU.
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;
}
// Right-handed lookAt matrix: places the camera at 'eye' looking toward
// 'target' with 'up' as the screen-up direction. Returns a view matrix
// (world→view). eye, target, up are plain {x,y,z} objects.
//
// The math:
// forward = normalize(eye - target) (points from target to eye)
// right = normalize(cross(up, forward))
// trueUp = cross(forward, right) (re-orthogonalized up)
// The matrix rows are [right | trueUp | forward], and the translation
// column is -dot(axis, eye) for each axis, which moves the world so the
// camera sits at the origin.
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;
// right = normalize(cross(up, forward))
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);
// Degenerate case: up is parallel to forward. Fall back to (1,0,0)
// so we don't produce NaNs.
if (rl < 1e-6) { rx = 1; ry = 0; rz = 0; rl = 1; }
rx /= rl; ry /= rl; rz /= rl;
// trueUp = cross(forward, right) — already unit length.
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;
}
// ─────────────────────────────────────────────────────────────────────
// Direction metadata
// ─────────────────────────────────────────────────────────────────────
// Each cube in the grid corresponds to one view direction. The cube
// highlights the face that the camera will be looking at when snapped
// to that direction. The 'iso' cube highlights no face (all faces are
// equally faint) because the isometric view doesn't look straight at
// any single face.
// Face indices — these must match the order in which faces are defined
// in buildCubeFaces() below, because the faceIdx attribute stored in
// each vertex is compared against these in the fragment shader.
const FACE_PX = 0, FACE_NX = 1, FACE_PY = 2, FACE_NY = 3, FACE_PZ = 4, FACE_NZ = 5;
// Which face to highlight for each direction (-1 = none / iso).
// The mapping is intuitive: 'top' looks down at the +Z face, 'front'
// looks at the -Y face (because the camera looks down -Y in our
// coordinate system), etc.
const HIGHLIGHT = {
iso: -1,
top: FACE_PZ,
bottom: FACE_NZ,
front: FACE_NY,
back: FACE_PY,
right: FACE_PX,
left: FACE_NX,
};
// Grid layout: 4 columns x 2 rows = 8 cells, but we only use 7
// (the 8th cell is left empty). The order of DIRECTIONS determines
// which cube appears in which cell, left-to-right, top-to-bottom.
const COLS = 4, ROWS = 2;
const DIRECTIONS = ['iso', 'top', 'front', 'right', 'left', 'back', 'bottom'];
// All cubes share the same isometric view; only the highlighted face
// differs. The eye position (2, -2, 2) looks at the origin from the
// front-right-top corner, which is the standard CAD isometric view.
// Up = +Z so the Z axis points up on screen.
const ISO_EYE = { x: 2, y: -2, z: 2 };
const ISO_UP = { x: 0, y: 0, z: 1 };
// Styling constants. The cubes are rendered semi-transparent so they
// look like ghosted wireframe boxes with one highlighted face, similar
// to the navigation cube in FreeCAD / Fusion 360.
const GRAY = [0.6, 0.6, 0.6];
const ALPHA_EDGE = 0.5; // edges — most visible
const ALPHA_FACE = 0.1; // normal faces — barely visible
const ALPHA_MARKED = 0.65; // highlighted face — clearly visible
// ─────────────────────────────────────────────────────────────────────
// Cube geometry: 6 faces x 4 verts (pos, normal, faceIdx) + 36 indices
// ─────────────────────────────────────────────────────────────────────
// Each face is a quad (4 vertices) with an outward normal and a faceIdx
// attribute. The faceIdx is what the fragment shader uses to decide
// whether this face is the highlighted one for the current cube.
//
// The vertex layout per vertex is 7 floats:
// [px, py, pz, nx, ny, nz, faceIdx]
// = 28 bytes, matching arrayStride: 7*4 in the pipeline.
function buildCubeFaces() {
const S = 0.5; // half-edge length → cube spans [-0.5, 0.5] on each axis
// Each face: 4 verts, [px,py,pz, nx,ny,nz, faceIdx]
// The winding is chosen so that triangles face outward, but since we
// render with cullMode: 'none' it doesn't strictly matter.
const data = [
// +X (right) - faceIdx 0
[ S,-S,-S, 1, 0, 0, 0], [ S, S,-S, 1, 0, 0, 0], [ S, S, S, 1, 0, 0, 0], [ S,-S, S, 1, 0, 0, 0],
// -X (left) - faceIdx 1
[-S,-S, S, -1, 0, 0, 1], [-S, S, S, -1, 0, 0, 1], [-S, S,-S, -1, 0, 0, 1], [-S,-S,-S, -1, 0, 0, 1],
// +Y (back) - faceIdx 2
[-S, S,-S, 0, 1, 0, 2], [-S, S, S, 0, 1, 0, 2], [ S, S, S, 0, 1, 0, 2], [ S, S,-S, 0, 1, 0, 2],
// -Y (front) - faceIdx 3
[-S,-S, S, 0,-1, 0, 3], [ S,-S, S, 0,-1, 0, 3], [ S,-S,-S, 0,-1, 0, 3], [-S,-S,-S, 0,-1, 0, 3],
// +Z (top) - faceIdx 4
[-S,-S, S, 0, 0, 1, 4], [-S, S, S, 0, 0, 1, 4], [ S, S, S, 0, 0, 1, 4], [ S,-S, S, 0, 0, 1, 4],
// -Z (bottom) - faceIdx 5
[ S,-S,-S, 0, 0,-1, 5], [ S, S,-S, 0, 0,-1, 5], [-S, S,-S, 0, 0,-1, 5], [-S,-S,-S, 0, 0,-1, 5],
];
// Flatten the nested array into a contiguous Float32Array.
const verts = new Float32Array(data.length * 7);
for (let i = 0; i < data.length; i++) verts.set(data[i], i * 7);
// 6 faces × 2 triangles × 3 indices = 36 indices.
// Each face's 4 vertices are split into two triangles: (0,1,2) and (0,2,3).
const indices = new Uint32Array([
0,1,2, 0,2,3, 4,5,6, 4,6,7, 8,9,10, 8,10,11,
12,13,14, 12,14,15, 16,17,18, 16,18,19, 20,21,22, 20,22,23,
]);
return { vertices: verts, indices };
}
// 12 edges × 2 endpoints = 24 line vertices.
// The edges are the cube's wireframe, drawn on top of the faces with
// higher opacity so the cube outline is always clearly visible.
function buildCubeEdges() {
const S = 0.5;
// 8 corner positions of the cube.
const c = [
[-S,-S,-S],[ S,-S,-S],[ S, S,-S],[-S, S,-S], // bottom face (z = -S)
[-S,-S, S],[ S,-S, S],[ S, S, S],[-S, S, S], // top face (z = +S)
];
// 12 edges as pairs of corner indices:
// 4 bottom edges, 4 top edges, 4 vertical edges.
const edges = [
[0,1],[1,2],[2,3],[3,0], // bottom face
[4,5],[5,6],[6,7],[7,4], // top face
[0,4],[1,5],[2,6],[3,7], // vertical edges
];
// Flatten into a Float32Array of positions (no colors — the edge
// fragment shader uses a fixed color from the uniform/constants).
const verts = new Float32Array(edges.length * 2 * 3);
let i = 0;
for (const [a, b] of edges) {
verts[i++] = c[a][0]; verts[i++] = c[a][1]; verts[i++] = c[a][2];
verts[i++] = c[b][0]; verts[i++] = c[b][1]; verts[i++] = c[b][2];
}
return verts;
}
// ─────────────────────────────────────────────────────────────────────
// WGSL shader
// ─────────────────────────────────────────────────────────────────────
// One shader module, two pipelines (faces + edges). The shader has
// four entry points: vs_face/fs_face for the semi-transparent cube
// faces, and vs_edge/fs_edge for the wireframe edges.
//
// The uniform struct contains:
// viewProj — the view-projection matrix (same for all cubes)
// highlightedFace — which face index to highlight (-1 = none)
//
// The ${...} template literals below are interpolated by JavaScript at
// shader creation time, baking the opacity/color constants directly
// into the WGSL source. This avoids extra uniforms and keeps the
// per-frame uniform data minimal (just viewProj + highlightedFace).
const SHADER = /* wgsl */ `
struct Uniforms {
viewProj: mat4x4<f32>,
highlightedFace: f32,
_pad0: f32,
_pad1: f32,
_pad2: f32,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
// ── Face pipeline ──
// Each face vertex carries a position, a normal (for lighting), and a
// faceIdx (to check if this face is the highlighted one).
struct FaceVertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) faceIdx: f32,
};
struct FaceVertexOutput {
@builtin(position) clipPosition: vec4<f32>,
@location(0) normal: vec3<f32>,
@location(1) faceIdx: f32,
};
@vertex
fn vs_face(input: FaceVertexInput) -> FaceVertexOutput {
var out: FaceVertexOutput;
out.clipPosition = uniforms.viewProj * vec4<f32>(input.position, 1.0);
out.normal = input.normal;
out.faceIdx = input.faceIdx;
return out;
}
@fragment
fn fs_face(input: FaceVertexOutput) -> @location(0) vec4<f32> {
let faceIdx = i32(input.faceIdx);
let highlighted = i32(uniforms.highlightedFace);
var alpha = ${ALPHA_FACE.toFixed(2)};
var color: vec3<f32>;
if (highlighted >= 0 && faceIdx == highlighted) {
// Highlighted face: fixed light color so it is always clearly the
// lightest face, regardless of which face it is or its orientation
// relative to the light direction. Without this, a highlighted
// face that happens to face away from the light would look darker
// than an unhighlighted face facing toward it, which is confusing.
color = vec3<f32>(0.85, 0.85, 0.85);
alpha = ${ALPHA_MARKED.toFixed(2)};
} else {
// Normal faces: simple Lambertian shading with a fixed light
// direction. The 0.5 ambient term ensures even back-facing faces
// are partially visible.
let lightDir = normalize(vec3<f32>(0.4, -0.4, 0.8));
let ndotl = max(dot(normalize(input.normal), lightDir), 0.0);
let base = vec3<f32>(${GRAY[0].toFixed(1)}, ${GRAY[1].toFixed(1)}, ${GRAY[2].toFixed(1)});
color = base * (0.5 + 0.5 * ndotl);
}
// Premultiplied alpha: the pipeline blend state expects color values
// to be multiplied by alpha before output. This is why we return
// (color * alpha, alpha) rather than (color, alpha).
return vec4<f32>(color * alpha, alpha);
}
// ── Edge pipeline ──
// Edges only need a position; the color is fixed (derived from GRAY
// and ALPHA_EDGE, baked in at compile time).
struct EdgeVertexInput {
@location(0) position: vec3<f32>,
};
struct EdgeVertexOutput {
@builtin(position) clipPosition: vec4<f32>,
};
@vertex
fn vs_edge(input: EdgeVertexInput) -> EdgeVertexOutput {
var out: EdgeVertexOutput;
out.clipPosition = uniforms.viewProj * vec4<f32>(input.position, 1.0);
return out;
}
@fragment
fn fs_edge(input: EdgeVertexOutput) -> @location(0) vec4<f32> {
let alpha = ${ALPHA_EDGE.toFixed(2)};
let base = vec3<f32>(${GRAY[0].toFixed(1)}, ${GRAY[1].toFixed(1)}, ${GRAY[2].toFixed(1)});
// Premultiplied alpha, same as faces.
return vec4<f32>(base * alpha, alpha);
}
`;
// ─────────────────────────────────────────────────────────────────────
// Dynamic uniform offset constant
// ─────────────────────────────────────────────────────────────────────
// WebGPU requires that uniform buffer offsets used with dynamic offset
// bind groups be multiples of 256 bytes. Our actual uniform data is
// only 80 bytes (one mat4x4 + one f32 + padding), but we must pad each
// slot to 256 bytes. The uniform buffer is therefore allocated as
// 256 * 7 = 1792 bytes, with each cube's data at offset i * 256.
const UNIFORM_SLOT_SIZE = 256;
// ─────────────────────────────────────────────────────────────────────
// Per-canvas initialization
// ─────────────────────────────────────────────────────────────────────
// Called once for each <canvas class="nav-dircubes-canvas"> on the page.
// Sets up the WebGPU device, pipelines, geometry buffers, click handler,
// and starts the render loop. All GPU resources are captured in the
// closure so the render loop can access them without global state.
async function initCanvas(canvas) {
// --- WebGPU bootstrap ---
// navigator.gpu is the entry point. If it's missing, the browser
// does not support WebGPU at all. We replace the canvas with a text
// node so the page doesn't show a broken empty rectangle.
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();
// Configure the canvas's WebGPU context. alphaMode: 'premultiplied'
// lets the canvas blend with the page background — important here
// because we clear to transparent and want the canvas background to
// show through between the cube grid cells.
const ctx = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
ctx.configure({ device, format, alphaMode: 'premultiplied' });
// Compile the WGSL shader and build the geometry.
const shader = device.createShaderModule({ code: SHADER });
const faces = buildCubeFaces();
const edges = buildCubeEdges();
// --- Upload geometry to GPU buffers ---
// All geometry is static — uploaded once, never modified.
// Face vertex buffer: 24 vertices × 7 floats × 4 bytes = 672 bytes.
const faceVbuf = device.createBuffer({
size: faces.vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(faceVbuf, 0, faces.vertices);
// Edge vertex buffer: 24 vertices × 3 floats × 4 bytes = 288 bytes.
const edgeVbuf = device.createBuffer({
size: edges.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(edgeVbuf, 0, edges);
// Index buffer for faces: 36 uint32 indices.
const ibuf = device.createBuffer({
size: faces.indices.byteLength,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(ibuf, 0, faces.indices);
// --- Uniform buffer ---
// One 256-byte slot per cube, 7 cubes total = 1792 bytes.
// Each frame we write each cube's viewProj + highlightedFace into
// its slot, then use dynamic offsets to select the right slot per
// draw call.
const ubuf = device.createBuffer({
size: UNIFORM_SLOT_SIZE * DIRECTIONS.length,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
// --- Bind group with dynamic offset ---
// We create a SINGLE bind group that references the entire uniform
// buffer. At draw time we pass a dynamic offset (i * 256) to select
// which cube's data to use. This is much cheaper than creating 7
// separate bind groups and is the idiomatic WebGPU pattern for
// rendering many instances with per-instance uniforms.
//
// hasDynamicOffset: true tells WebGPU that the offset will be
// supplied at setBindGroup() time rather than being fixed at bind
// group creation time.
const bindGroupLayout = device.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform', hasDynamicOffset: true },
}],
});
const bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [{ binding: 0, resource: { buffer: ubuf, offset: 0, size: UNIFORM_SLOT_SIZE } }],
});
// --- Blend state ---
// Premultiplied alpha blending: the fragment shader outputs
// (color * alpha, alpha), and the blend state uses srcFactor: 'one'
// (not 'src-alpha') to match. This is the correct blend setup for
// premultiplied alpha and avoids double-darkening at overlap edges.
const blendState = {
color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
};
// --- Pipelines ---
// Two pipelines sharing the same bind group layout and shader module:
// facePipeline — renders semi-transparent triangles for cube faces
// edgePipeline — renders the wireframe edges as lines
//
// The face pipeline uses depthCompare: 'less' so that closer faces
// occlude farther ones (correct depth ordering), but
// depthWriteEnabled: false so faces don't write depth — this allows
// the edges (drawn after faces) to always pass the depth test.
//
// The edge pipeline uses depthCompare: 'always' so edges are never
// occluded by face geometry, giving the cube a clean wireframe
// outline regardless of viewing angle.
const facePipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
vertex: {
module: shader, entryPoint: 'vs_face',
// Vertex layout: 7 floats per vertex = 28 bytes.
// offset 0: position (float32x3)
// offset 12: normal (float32x3)
// offset 24: faceIdx (float32)
buffers: [{
arrayStride: 7 * 4,
attributes: [
{ shaderLocation: 0, offset: 0, format: 'float32x3' },
{ shaderLocation: 1, offset: 12, format: 'float32x3' },
{ shaderLocation: 2, offset: 24, format: 'float32' },
],
}],
},
fragment: {
module: shader, entryPoint: 'fs_face',
targets: [{ format, blend: blendState }],
},
primitive: { topology: 'triangle-list', cullMode: 'none' },
depthStencil: { format: 'depth24plus', depthCompare: 'less', depthWriteEnabled: false },
});
const edgePipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
vertex: {
module: shader, entryPoint: 'vs_edge',
// Vertex layout: 3 floats per vertex = 12 bytes (position only).
buffers: [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }],
},
fragment: {
module: shader, entryPoint: 'fs_edge',
targets: [{ format, blend: blendState }],
},
primitive: { topology: 'line-list' },
depthStencil: { format: 'depth24plus', depthCompare: 'always', depthWriteEnabled: false },
});
// --- Depth texture ---
// Recreated whenever the canvas size changes. Even though edges use
// depthCompare: 'always', the face pipeline uses depthCompare: 'less'
// and thus needs a valid depth attachment.
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,
});
}
// --- Click handling: map canvas coords to grid cell → direction ---
// The canvas is divided into a COLS×ROWS grid. A click anywhere in
// cell (col, row) selects the direction at index row*COLS + col.
// This is a simple bounding-box hit test — no per-pixel picking
// needed, which keeps the code simple and fast.
function hitTest(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
const cellW = rect.width / COLS;
const cellH = rect.height / ROWS;
const col = Math.floor(x / cellW);
const row = Math.floor(y / cellH);
const idx = row * COLS + col;
// idx 7 (the 8th cell) is empty, so reject it.
if (idx < 0 || idx >= DIRECTIONS.length) return null;
return DIRECTIONS[idx];
}
canvas.style.cursor = 'pointer';
canvas.addEventListener('click', (e) => {
const dir = hitTest(e.clientX, e.clientY);
if (!dir) return;
// Dispatch a CustomEvent so callers can use addEventListener.
canvas.dispatchEvent(new CustomEvent('directionselected', { detail: dir }));
// Also support the data-onselect attribute for simple use cases
// where the caller just wants to pass a global callback name.
const cbName = canvas.getAttribute('data-onselect');
if (cbName && typeof window[cbName] === 'function') window[cbName](dir);
});
// --- Render loop ---
// Runs once per animation frame. Each frame:
// 1. Resize canvas + depth texture if the CSS size changed.
// 2. Compute the shared view-projection matrix (same for all cubes).
// 3. Write per-cube uniform data (viewProj + highlightedFace).
// 4. Record a single render pass with 7 cubes, each in its own
// viewport cell, drawn as faces then edges.
// 5. Submit and schedule the next frame.
function frame() {
// Handle HiDPI: render at devicePixelResolution but cap at 2×.
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);
// Each cube occupies one cell of the grid. The viewport and
// scissor rect for each cube are set to its cell's pixel bounds.
const cellW = cw / COLS;
const cellH = ch / ROWS;
// The aspect ratio for the perspective projection is per-cell,
// not per-canvas, because each cube is rendered into its own
// square-ish viewport.
const aspect = cellW / cellH;
// All cubes share the same isometric view. Only the highlighted
// face differs per cube, and that's a uniform, not a matrix.
const view = mat4LookAt(ISO_EYE, { x: 0, y: 0, z: 0 }, ISO_UP);
const proj = mat4Perspective(35 * Math.PI / 180, aspect, 0.1, 10);
const viewProj = mat4Multiply(proj, view);
// Write per-cube uniform data. Each cube gets its own 256-byte
// slot in the uniform buffer. We only write the first 20 floats
// (80 bytes): 16 for viewProj + 1 for highlightedFace + 3 padding.
// The rest of the 256-byte slot is unused but must be allocated
// due to the 256-byte alignment requirement for dynamic offsets.
const slot = new Float32Array(UNIFORM_SLOT_SIZE / 4);
for (let i = 0; i < DIRECTIONS.length; i++) {
slot.set(viewProj, 0);
slot[16] = HIGHLIGHT[DIRECTIONS[i]];
// writeBuffer with offset=i*256, data=slot, length=20 floats=80 bytes.
device.queue.writeBuffer(ubuf, i * UNIFORM_SLOT_SIZE, slot, 0, 20);
}
// Record the render pass. Clear to transparent so the canvas
// background shows through between and around the cubes.
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: ctx.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: 'clear', storeOp: 'store',
}],
depthStencilAttachment: {
view: depthTex.createView(),
depthClearValue: 1.0, depthLoadOp: 'clear', depthStoreOp: 'store',
},
});
// Draw each cube into its grid cell.
// The key technique here: setViewport + setScissorRect restrict
// rendering to the cell's pixel bounds, then the dynamic offset
// in setBindGroup selects the per-cube uniform data. All 7 cubes
// are drawn in a single render pass, which is much more efficient
// than 7 separate passes.
for (let i = 0; i < DIRECTIONS.length; i++) {
const col = i % COLS;
const row = Math.floor(i / COLS);
// Viewport: maps NDC [-1,1] to the cell's pixel rectangle.
// Scissor: clips rasterization to the same rectangle, so cubes
// never bleed into neighboring cells.
pass.setViewport(col * cellW, row * cellH, cellW, cellH, 0, 1);
pass.setScissorRect(col * cellW, row * cellH, cellW, cellH);
// Draw faces: 36 indices (6 faces × 2 triangles × 3 indices).
// The dynamic offset [i * 256] selects this cube's uniform slot.
pass.setPipeline(facePipeline);
pass.setBindGroup(0, bindGroup, [i * UNIFORM_SLOT_SIZE]);
pass.setVertexBuffer(0, faceVbuf);
pass.setIndexBuffer(ibuf, 'uint32');
pass.drawIndexed(faces.indices.length);
// Draw edges: 24 vertices (12 edges × 2 endpoints).
// Same dynamic offset — the edge shader also reads viewProj
// from the uniform buffer.
pass.setPipeline(edgePipeline);
pass.setBindGroup(0, bindGroup, [i * UNIFORM_SLOT_SIZE]);
pass.setVertexBuffer(0, edgeVbuf);
pass.draw(edges.length / 3);
}
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
// ─────────────────────────────────────────────────────────────────────
// Auto-initialization
// ─────────────────────────────────────────────────────────────────────
// Find every <canvas class="nav-dircubes-canvas"> on the page and
// initialize it. If the document is still loading (script ran from
// <head> or with defer), wait for DOMContentLoaded so the canvases
// exist. If the document is already ready (script injected late),
// initialize immediately.
function initAll() {
document.querySelectorAll('canvas.nav-dircubes-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 version here is a stripped-down, dependency-free port that keeps the two key techniques (WebGPU viewports for grid cells, dynamic uniform offsets for per-cube data) that make the widget cheap to render.