Rendering a Navigation Axis Triad Using WebGPU

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:

  1. Two pipelines, one shader. The WGSL module has a single vs_main/fs_main pair. The line pipeline renders the three axis shafts as a line-list (6 vertices, 3 segments). The triangle pipeline renders the three cone arrow heads as a triangle-list (39 vertices, 36 indices). Both use depthCompare: 'always' and depthWriteEnabled: false so the triad is always visible regardless of draw order.
  2. 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.
  3. 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.
  4. 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].
  5. 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:

example.html
<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

navigation-axis-webgpu.js
// 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.


Check out similar posts by category: Javascript WebGPU Web