A navigation axis triad is the small red/green/blue arrow indicator you see in the corner of 3D viewers (Blender, FreeCAD, CAD tools). It shows the orientation of the X/Y/Z axes and rotates together with the camera so you can always tell which way is up. Unlike the direction-cubes toolbar — which is a grid of clickable cube buttons — the triad is a single set of three colored lines with cone arrow heads.
This post shows a minimal, dependency-free WebGPU implementation that
renders the triad and lets you drag to rotate it. The full source is at the
bottom; the live demo below loads the exact same script from
/scripts/navigation-axis-webgpu.js.
Drag to rotate. X=red, Y=green, Z=blue. Requires a WebGPU-capable browser (Chrome / Edge 113+).
How it works
The triad is a single self-contained script with no external libraries.
The vertex shader applies a standard perspective MVP matrix
(proj * translate(0,0,-2.2) * rotateY(yaw) * rotateX(pitch)), giving
the arrows a sense of depth — arrows pointing toward the camera appear
slightly larger than those pointing away.
The moving parts are:
- Two pipelines, one shader. The WGSL module has a single
vs_main/fs_mainpair. The line pipeline renders the three axis shafts as aline-list(6 vertices, 3 segments). The triangle pipeline renders the three cone arrow heads as atriangle-list(39 vertices, 36 indices). Both usedepthCompare: 'always'anddepthWriteEnabled: falseso the triad is always visible regardless of draw order. - Cone arrow heads. Each arrow head is a 12-segment cone: 1 tip vertex
- 12 ring vertices = 13 vertices, 12 triangles. The ring radius is
AXIS_HEAD_SIZE * 0.25— narrow enough to look like an arrow, not a dunce cap. The ring is built by finding two vectors perpendicular to the axis direction and sweeping a circle around the cone base.
- 12 ring vertices = 13 vertices, 12 triangles. The ring radius is
- Uniform buffer. A single
mat4x4<f32>(64 bytes) holds the model-view-projection matrix. The render loop rebuilds it each frame from a yaw/pitch pair driven by pointer drag events. - Separate position and color vertex buffers. Each pipeline binds two
vertex buffers:
@location(0)for positions,@location(1)for colors. The color buffer is per-vertex so each axis (and its arrow head) gets its own RGB color: X=red[0.9, 0.2, 0.2], Y=green[0.2, 0.8, 0.2], Z=blue[0.2, 0.4, 0.9]. - Interaction. Pointer events drive yaw/pitch; pitch is clamped to just under ±90° so the triad never flips over.
Using it on your own page
Drop /scripts/navigation-axis-webgpu.js into your static folder and add a canvas with the
nav-axis-canvas class — the script auto-initializes every matching
canvas on the page:
<canvas class="nav-axis-canvas"
style="width:240px;height:240px;"></canvas>
<script src="/scripts/navigation-axis-webgpu.js" defer></script>Size the canvas with CSS (width/height); 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 navigation axis triad.
// Renders three colored axis arrows (X=red, Y=green, Z=blue) as lines plus
// cone arrow heads, rotated by a yaw/pitch pair controlled by dragging.
// Auto-initializes every <canvas class="nav-axis-canvas"> on the page.
//
// 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';
// ─────────────────────────────────────────────────────────────────────
// Geometry constants
// ─────────────────────────────────────────────────────────────────────
// All coordinates are in a small unit cube around the origin. The
// perspective camera (set up later) places the triad at z = -2.2 and
// uses a 45° field of view, so AXIS_LENGTH = 0.7 fills a good portion
// of the canvas without the arrow heads touching the edges.
// Length of each axis shaft, from the origin to the base of the arrow
// head. The arrow head sits on top of this length, so the total axis
// extent is AXIS_LENGTH + AXIS_HEAD_SIZE.
const AXIS_LENGTH = 0.7;
// Length of the cone arrow head along the axis direction. The cone's
// base radius is derived from this (see headRad below).
const AXIS_HEAD_SIZE = 0.15;
// Axis colors in linear RGB (0-1). These are written directly to the
// fragment shader output without any gamma correction — WebGPU's
// swapchain is treated as sRGB by the browser, so the values you pick
// here are the values you see on screen.
// The convention X=red / Y=green / Z=blue matches Blender, FreeCAD and
// most other CAD tools, which makes the triad instantly recognizable.
const COLORS = {
X: [0.9, 0.2, 0.2], // red
Y: [0.2, 0.8, 0.2], // green
Z: [0.2, 0.4, 0.9], // blue
};
// ─────────────────────────────────────────────────────────────────────
// Minimal mat4 helpers (column-major)
// ─────────────────────────────────────────────────────────────────────
// 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,
// rotation around X and Y, perspective, and translation. 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 MVP is built
// as proj * view * model, so a point is transformed model→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;
}
// Rotation around the X axis by `a` radians (right-handed).
function mat4RotationX(a) {
const c = Math.cos(a), s = Math.sin(a);
const m = mat4Identity();
m[5] = c; m[6] = s;
m[9] = -s; m[10] = c;
return m;
}
// Rotation around the Y axis by `a` radians (right-handed).
function mat4RotationY(a) {
const c = Math.cos(a), s = Math.sin(a);
const m = mat4Identity();
m[0] = c; m[2] = -s;
m[8] = s; m[10] = c;
return m;
}
// 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;
}
// Pure translation matrix (no rotation or scale).
function mat4Translation(x, y, z) {
const m = mat4Identity();
m[12] = x; m[13] = y; m[14] = z;
return m;
}
// ─────────────────────────────────────────────────────────────────────
// Geometry: axis lines + cone arrow heads
// ─────────────────────────────────────────────────────────────────────
// The triad is drawn in two passes:
// 1. Lines — three line segments from the origin to each axis tip.
// 2. Triangles — three cones, one at each axis tip.
// We use separate vertex buffers for positions and colors so that the
// same color buffer can be shared conceptually and the vertex layout
// in the pipeline stays simple (two bindings, each float32x3).
// 3 line segments (origin → tip), 6 vertices, laid out as
// [x0,y0,z0, x1,y1,z1, ...]. Each axis starts at (0,0,0) and ends at
// (AXIS_LENGTH, 0, 0) etc. The arrow head cone is placed *after* the
// tip, so the visible arrow extends slightly beyond AXIS_LENGTH.
const lineVertices = new Float32Array([
0, 0, 0, AXIS_LENGTH, 0, 0, // X axis: origin → (L, 0, 0)
0, 0, 0, 0, AXIS_LENGTH, 0, // Y axis: origin → (0, L, 0)
0, 0, 0, 0, 0, AXIS_LENGTH, // Z axis: origin → (0, 0, L)
]);
// Per-vertex colors: both endpoints of each segment share the axis
// color, so the whole line is a solid color.
const lineColors = new Float32Array([
...COLORS.X, ...COLORS.X,
...COLORS.Y, ...COLORS.Y,
...COLORS.Z, ...COLORS.Z,
]);
// Line-list index buffer: pairs of vertex indices forming segments.
// (0,1) = X segment, (2,3) = Y segment, (4,5) = Z segment.
const lineIndices = new Uint32Array([0, 1, 2, 3, 4, 5]);
// Build a single cone arrow head and append it to the given arrays.
//
// The cone is described by:
// - 1 tip vertex at `tip`
// - 12 ring vertices arranged in a circle around `base`, where
// base = tip - dir * headLen
// - 12 triangles, each connecting the tip to two adjacent ring verts
//
// `dir` must be a unit vector pointing along the axis (e.g. [1,0,0]).
// `vertexOffset` is the index of the tip vertex within the global
// vertex array, so the caller can pack multiple arrow heads into one
// buffer without index collisions.
//
// Why a cone and not a simple pyramid? A 12-segment cone is cheap
// (36 indices) but looks round enough at the small sizes used here,
// and it avoids the faceted look of a 4-sided pyramid.
function addArrowHead(positions, colors, indices, vertexOffset, tip, dir, color) {
const segments = 12;
const headLen = AXIS_HEAD_SIZE;
// The base radius is 25% of the head length. This ratio is what
// makes the arrow look like an arrow rather than a dunce cap: too
// wide and it reads as a cone, too narrow and it disappears.
const headRad = AXIS_HEAD_SIZE * 0.25;
// Tip vertex (the pointy end of the arrow).
positions.push(tip[0], tip[1], tip[2]);
colors.push(...color);
// Base center = tip moved backwards along the axis by headLen.
// The ring of vertices is centered here and lies in the plane
// perpendicular to `dir`.
const baseX = tip[0] - dir[0] * headLen;
const baseY = tip[1] - dir[1] * headLen;
const baseZ = tip[2] - dir[2] * headLen;
// To build a circle in the plane perpendicular to `dir`, we need two
// unit vectors perp1 and perp2 that are both perpendicular to `dir`
// and to each other. Any point on the ring is then
// base + (perp1 * cos(θ) + perp2 * sin(θ)) * headRad
//
// perp1 is constructed by crossing `dir` with a non-parallel axis.
// We pick (0,0,1) as the fallback axis unless `dir` is nearly
// parallel to it (i.e. the Z axis), in which case we use (0,1,0)
// instead. This avoids a degenerate cross product.
let perp1;
if (Math.abs(dir[2]) < 0.9) {
// dir is not (near) the Z axis → cross with (0,0,1) is safe.
// cross(dir, (0,0,1)) = (dir.y*1 - dir.z*0, dir.z*0 - dir.x*1, ...) = (-dir.y, dir.x, 0)
perp1 = [-dir[1], dir[0], 0];
} else {
// dir is (near) (0,0,±1) → cross with (0,1,0) instead.
// cross(dir, (0,1,0)) = (dir.z*0 - 0*1, 0*0 - dir.x*0, dir.x*1 - dir.y*0) = (0, -dir.z, dir.y)...
// simplified for dir≈(0,0,1): (0, -1, 0) which is fine.
perp1 = [0, -dir[2], dir[1]];
}
const p1len = Math.hypot(perp1[0], perp1[1], perp1[2]);
perp1 = [perp1[0] / p1len, perp1[1] / p1len, perp1[2] / p1len];
// perp2 = dir × perp1 — already unit length because dir and perp1
// are orthogonal unit vectors.
const perp2 = [
dir[1] * perp1[2] - dir[2] * perp1[1],
dir[2] * perp1[0] - dir[0] * perp1[2],
dir[0] * perp1[1] - dir[1] * perp1[0],
];
// Generate the ring vertices by sweeping θ around the circle.
for (let i = 0; i < segments; i++) {
const angle = (i / segments) * Math.PI * 2;
const c = Math.cos(angle);
const s = Math.sin(angle);
positions.push(
baseX + (perp1[0] * c + perp2[0] * s) * headRad,
baseY + (perp1[1] * c + perp2[1] * s) * headRad,
baseZ + (perp1[2] * c + perp2[2] * s) * headRad,
);
colors.push(...color);
}
// Build the 12 side triangles of the cone. Each triangle connects
// the tip (vertexOffset) to two adjacent ring vertices. The winding
// order (tip → ring[i] → ring[i+1]) is consistent but doesn't matter
// here because we render with cullMode: 'none'.
for (let i = 0; i < segments; i++) {
indices.push(
vertexOffset, // tip
vertexOffset + 1 + i, // ring[i]
vertexOffset + 1 + ((i + 1) % segments), // ring[i+1] (wraps)
);
}
}
// Build all three arrow heads (X, Y, Z) into a single set of buffers.
// Each arrow head contributes 13 vertices (1 tip + 12 ring), so the
// offset is advanced by 13 after each one.
function buildArrowHeads() {
const positions = [];
const colors = [];
const indices = [];
let offset = 0;
// X axis: tip at (AXIS_LENGTH, 0, 0), pointing in +X.
addArrowHead(positions, colors, indices, offset,
[AXIS_LENGTH, 0, 0], [1, 0, 0], COLORS.X);
offset += 13;
// Y axis: tip at (0, AXIS_LENGTH, 0), pointing in +Y.
addArrowHead(positions, colors, indices, offset,
[0, AXIS_LENGTH, 0], [0, 1, 0], COLORS.Y);
offset += 13;
// Z axis: tip at (0, 0, AXIS_LENGTH), pointing in +Z.
addArrowHead(positions, colors, indices, offset,
[0, 0, AXIS_LENGTH], [0, 0, 1], COLORS.Z);
return {
positions: new Float32Array(positions),
colors: new Float32Array(colors),
indices: new Uint32Array(indices),
};
}
// ─────────────────────────────────────────────────────────────────────
// WGSL shader
// ─────────────────────────────────────────────────────────────────────
// One shader module, two pipelines (lines + triangles). The vertex
// stage transforms positions by the MVP matrix and passes the per-vertex
// color through to the fragment stage. The fragment stage outputs the
// color at full opacity (alpha = 1.0) — there is no lighting, the
// colors are flat.
//
// The uniform buffer contains a single mat4x4<f32> (64 bytes). Both
// pipelines bind the same buffer, so we only update it once per frame.
const SHADER = /* wgsl */ `
struct Uniforms {
mvp: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) color: vec3<f32>,
};
struct VertexOutput {
@builtin(position) clipPosition: vec4<f32>,
@location(0) color: vec3<f32>,
};
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var output: VertexOutput;
// Standard MVP transform: model-space position → clip-space position.
// The w component is 1.0 because 'position' is a point, not a direction.
output.clipPosition = uniforms.mvp * vec4<f32>(input.position, 1.0);
// Pass the per-vertex color through unchanged; the fragment shader
// will output it directly.
output.color = input.color;
return output;
}
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
// Flat color, fully opaque. No lighting calculation — the triad is
// meant to be a bright, high-contrast indicator, not a shaded object.
return vec4<f32>(input.color, 1.0);
}
`;
// ─────────────────────────────────────────────────────────────────────
// Per-canvas initialization
// ─────────────────────────────────────────────────────────────────────
// Called once for each <canvas class="nav-axis-canvas"> on the page.
// Sets up the WebGPU device, pipelines, geometry buffers, 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 (e.g. Firefox without flags, or
// an older browser). 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;
}
// requestAdapter() picks a physical GPU. If null, no usable GPU is
// available (e.g. headless environment without GPU drivers).
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
canvas.replaceWith(document.createTextNode('No WebGPU adapter available.'));
return;
}
// requestDevice() creates a logical device — the handle through
// which all subsequent GPU operations are issued.
const device = await adapter.requestDevice();
// Configure the canvas's WebGPU context. getPreferredCanvasFormat()
// returns the optimal swapchain texture format for the platform
// (typically 'bgra8unorm' on Windows, 'rgba8unorm' elsewhere).
// alphaMode: 'premultiplied' lets the canvas blend with the page
// background — important here because we clear to transparent and
// want the white canvas background to show through.
const ctx = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
ctx.configure({ device, format, alphaMode: 'premultiplied' });
// Compile the WGSL shader into a shader module.
const shader = device.createShaderModule({ code: SHADER });
// --- Pipelines ---
// We create two render pipelines that share the same shader module
// but differ in their primitive topology:
// - triPipeline renders cone arrow heads as triangles
// - linePipeline renders axis shafts as lines
//
// Both pipelines use layout: 'auto', which makes WebGPU derive the
// bind group layout from the shader. This is convenient but means
// we must fetch the layout from the pipeline later (via
// getBindGroupLayout(0)) when creating bind groups.
//
// Both pipelines use depthCompare: 'always' and depthWriteEnabled:
// false. This is deliberate: the triad is an overlay indicator that
// should always be fully visible, never occluded by itself. With
// depth testing on, the cone triangles would sometimes hide the
// line shafts behind them depending on rotation, which looks broken.
// Triangle pipeline for arrow heads (no depth test — always visible)
const triPipeline = device.createRenderPipeline({
layout: 'auto',
vertex: {
module: shader,
entryPoint: 'vs_main',
// Two vertex buffers, each with arrayStride 12 (one vec3<f32>):
// binding 0 → @location(0) position
// binding 1 → @location(1) color
buffers: [
{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] },
{ arrayStride: 12, attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x3' }] },
],
},
fragment: { module: shader, entryPoint: 'fs_main', targets: [{ format }] },
primitive: { topology: 'triangle-list' },
depthStencil: { format: 'depth24plus', depthCompare: 'always', depthWriteEnabled: false },
});
// Line pipeline for axis shafts (no depth test — always visible).
// Identical to the triangle pipeline except for the topology.
const linePipeline = device.createRenderPipeline({
layout: 'auto',
vertex: {
module: shader,
entryPoint: 'vs_main',
buffers: [
{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] },
{ arrayStride: 12, attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x3' }] },
],
},
fragment: { module: shader, entryPoint: 'fs_main', targets: [{ format }] },
primitive: { topology: 'line-list' },
depthStencil: { format: 'depth24plus', depthCompare: 'always', depthWriteEnabled: false },
});
// --- Upload geometry to GPU buffers ---
// Each buffer is created with VERTEX (or INDEX) usage plus COPY_DST
// so we can write to it via queue.writeBuffer. The geometry is
// static, so we upload once and never touch it again.
// Line geometry: positions, colors, and indices for the 3 axis shafts.
const lineBuf = device.createBuffer({
size: lineVertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(lineBuf, 0, lineVertices);
const lineColBuf = device.createBuffer({
size: lineColors.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(lineColBuf, 0, lineColors);
const lineIdxBuf = device.createBuffer({
size: lineIndices.byteLength,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(lineIdxBuf, 0, lineIndices);
// Arrow head geometry: positions, colors, and indices for the 3 cones.
const arrows = buildArrowHeads();
const arrowBuf = device.createBuffer({
size: arrows.positions.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(arrowBuf, 0, arrows.positions);
const arrowColBuf = device.createBuffer({
size: arrows.colors.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(arrowColBuf, 0, arrows.colors);
const arrowIdxBuf = device.createBuffer({
size: arrows.indices.byteLength,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(arrowIdxBuf, 0, arrows.indices);
// --- Uniform buffer ---
// Holds a single mat4x4<f32> (the MVP matrix) = 64 bytes.
// Updated every frame via queue.writeBuffer. We create one bind
// group per pipeline that references this buffer; both bind groups
// point at the same 64 bytes, so updating the buffer once updates
// both pipelines.
const ubuf = device.createBuffer({
size: 64,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const triBindGroup = device.createBindGroup({
layout: triPipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: ubuf } }],
});
const lineBindGroup = device.createBindGroup({
layout: linePipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: ubuf } }],
});
// --- Depth texture ---
// Even though we use depthCompare: 'always' (so depth never rejects
// fragments), WebGPU still requires a depth-stencil attachment if
// the pipeline declares a depthStencil state. We recreate the
// texture whenever the canvas size changes to match the new
// dimensions. The format 'depth24plus' is widely supported.
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: drag to rotate ---
// We track a yaw/pitch pair and update it from pointer movement.
// Yaw rotates around the Y axis (horizontal drag), pitch around the
// X axis (vertical drag). Pitch is clamped to just under ±90° so
// the triad never flips upside down, which would be disorienting.
//
// setPointerCapture ensures we keep receiving pointermove events
// even if the pointer leaves the canvas while dragging.
let yaw = 0.6, pitch = 0.4;
let dragging = false, lastX = 0, lastY = 0;
canvas.style.cursor = 'grab';
canvas.style.touchAction = 'none'; // prevent scrolling on touch devices
canvas.addEventListener('pointerdown', (e) => {
dragging = true; lastX = e.clientX; lastY = e.clientY;
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, dy = e.clientY - lastY;
lastX = e.clientX; lastY = e.clientY;
// 0.01 rad per pixel — a full screen-width drag ≈ 6.3 rad ≈ 360°.
yaw += dx * 0.01;
pitch += dy * 0.01;
const lim = Math.PI / 2 - 0.05;
pitch = Math.max(-lim, Math.min(lim, pitch));
});
// --- Render loop ---
// Runs once per animation frame via requestAnimationFrame. Each
// frame:
// 1. Resize canvas + depth texture if the CSS size changed.
// 2. Rebuild the MVP matrix from the current yaw/pitch.
// 3. Upload the MVP to the uniform buffer.
// 4. Record a render pass: clear, draw lines, draw triangles.
// 5. Submit and schedule the next frame.
function frame() {
// Handle HiDPI: render at devicePixelResolution but cap at 2× to
// avoid excessive fill rate on high-DPI phones. clientWidth is
// the CSS pixel size; canvas.width is the drawing buffer size.
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);
// Build the MVP matrix:
// mvp = proj * view * rot
// where:
// proj = perspective(45°, aspect, 0.1, 100)
// view = translate(0, 0, -2.2) — push the triad away from camera
// rot = rotateY(yaw) * rotateX(pitch)
//
// The camera is at the origin looking down -Z. translate(0,0,-2.2)
// moves the triad to z = -2.2, which is well within the [0.1, 100]
// near/far range. The 45° FOV combined with the 2.2 distance makes
// the 0.7-length axes fill a comfortable portion of the view.
const aspect = cw / ch;
const proj = mat4Perspective(Math.PI / 4, aspect, 0.1, 100);
const view = mat4Translation(0, 0, -2.2);
const rot = mat4Multiply(mat4RotationY(yaw), mat4RotationX(pitch));
const mvp = mat4Multiply(proj, mat4Multiply(view, rot));
device.queue.writeBuffer(ubuf, 0, mvp);
// Record the render pass. We clear the color attachment to fully
// transparent (alpha = 0) so the canvas's CSS background shows
// through. The depth attachment is cleared to 1.0 (farthest).
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',
},
});
// Pass 1: draw the 3 axis line segments (6 indices).
pass.setPipeline(linePipeline);
pass.setBindGroup(0, lineBindGroup);
pass.setVertexBuffer(0, lineBuf); // @location(0) positions
pass.setVertexBuffer(1, lineColBuf); // @location(1) colors
pass.setIndexBuffer(lineIdxBuf, 'uint32');
pass.drawIndexed(6);
// Pass 2: draw the 3 cone arrow heads (3 × 12 triangles = 108
// indices). Same bind group (same MVP), different vertex buffers.
pass.setPipeline(triPipeline);
pass.setBindGroup(0, triBindGroup);
pass.setVertexBuffer(0, arrowBuf);
pass.setVertexBuffer(1, arrowColBuf);
pass.setIndexBuffer(arrowIdxBuf, 'uint32');
pass.drawIndexed(arrows.indices.length);
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
// ─────────────────────────────────────────────────────────────────────
// Auto-initialization
// ─────────────────────────────────────────────────────────────────────
// Find every <canvas class="nav-axis-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-axis-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 uses a standard perspective MVP so the arrows have depth, while
keeping depthCompare: 'always' so the triad is always visible regardless
of viewing angle.