MNIST Classifier
Draw a digit and classify it with ONNX Runtime Web on WebGPU. Render the GPU-resident logits through a non-owning vgpu buffer wrap.
// Reads the ten GPU-resident logits ONNX Runtime Web produced and draws the
// class probabilities.
//
// The softmax is computed here, so the 40 bytes of output are never copied or
// read back to the CPU. Class labels are static DOM text below the canvas.
//
// The look is deliberately flat and matches the docs UI: the Geist gray scale
// for the chrome, one blue accent for the data, no gradients and no glows.
// Everything is laid out in framebuffer pixels and antialiased with signed
// distance fields, so the hairlines stay one pixel wide at any canvas size.
// The chart uses the full width: class `i` owns the column `[i, i + 1) / 10`,
// which a plain ten-column DOM grid mirrors exactly, so every label sits under
// its own bar with no magic padding.
struct Uniforms {
resolution: vec2f,
/// 1.0 once a real inference result is bound, 0.0 for the idle state.
has_result: f32,
/// Side length of the model input in texels (28). Kept for the pipeline's
/// uniform layout; the chart itself needs no input geometry.
input_size: f32,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var<storage, read> logits: array<f32>;
/// The normalized 28x28 input stays bound: `createVisualizer().render()` owns
/// this slot and the drawing surface next to the canvas already shows the ink,
/// so the chart deliberately does not draw the tensor a second time.
@group(0) @binding(2) var<storage, read> digit: array<f32>;
const CLASSES: u32 = 10u;
/// Chart band, as fractions of the height: 1.0 probability and the axis.
const CHART_TOP: f32 = 0.13;
const CHART_BASE: f32 = 0.855;
/// Every class stays visible even at a probability of ~0, as a small nub.
const MIN_BAR: f32 = 0.014;
/// Docs palette, written straight into the non-sRGB target: the panel tone the
/// docs use for code and cards, then the same gray ramp for the chrome.
const BG: vec3f = vec3f(0.039, 0.039, 0.039); // #0a0a0a gray-1
const TRACK: vec3f = vec3f(0.098, 0.098, 0.098); // #191919 gray-3
const RULE: vec3f = vec3f(0.133, 0.133, 0.133); // #222222 gray-4
const AXIS: vec3f = vec3f(0.192, 0.192, 0.192); // #313131 gray-6
const TICK: vec3f = vec3f(0.227, 0.227, 0.227); // #3a3a3a gray-7
/// The data uses a single hue: full accent for the argmax, the same blue
/// stepped down the scale for every other class.
const ACCENT: vec3f = vec3f(0.000, 0.439, 0.953); // #0070f3 blue-9
const ACCENT_DIM: vec3f = vec3f(0.059, 0.204, 0.376); // #0f3460 blue-4
/// Numerically stable softmax over the ten logits.
fn probability(index: u32) -> f32 {
var maximum = logits[0];
for (var i = 1u; i < CLASSES; i = i + 1u) {
maximum = max(maximum, logits[i]);
}
var total = 0.0;
for (var i = 0u; i < CLASSES; i = i + 1u) {
total = total + exp(logits[i] - maximum);
}
return exp(logits[index] - maximum) / total;
}
fn best_class() -> u32 {
var best = 0u;
for (var i = 1u; i < CLASSES; i = i + 1u) {
if (logits[i] > logits[best]) { best = i; }
}
return best;
}
/// Signed distance to a rounded box, in the same units as `p`.
fn sd_round_box(p: vec2f, half_size: vec2f, radius: f32) -> f32 {
let r = min(radius, min(half_size.x, half_size.y));
let q = abs(p) - half_size + vec2f(r);
return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - r;
}
/// One-pixel analytic coverage for a distance expressed in pixels.
fn coverage(distance_px: f32) -> f32 {
return clamp(0.5 - distance_px, 0.0, 1.0);
}
@fragment
fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let resolution = max(uniforms.resolution, vec2f(1.0));
let px = position.xy;
let width = resolution.x;
let height = resolution.y;
let has_result = uniforms.has_result > 0.5;
var color = BG;
// ---------------------------------------------------------------- chart ---
let column_w = width / f32(CLASSES);
let top = CHART_TOP * height;
let base = CHART_BASE * height;
let span = base - top;
let bar_half = min(column_w * 0.30, 0.075 * height);
// Just enough rounding to read as a drawn shape rather than a raw quad.
let radius = max(1.5, 0.004 * height);
let index = u32(clamp(px.x / column_w, 0.0, f32(CLASSES) - 1.0));
let center_x = (f32(index) + 0.5) * column_w;
let winner = best_class();
// The rules and the axis stop a little short of the border, with a one-pixel
// antialiased end instead of a fade.
let inset = 0.02 * width;
let rule_mask = clamp(min(px.x - inset, width - inset - px.x) + 0.5, 0.0, 1.0);
// An empty track per class shows the 0..1 range the bar is measured against.
let track = sd_round_box(
px - vec2f(center_x, (top + base) * 0.5),
vec2f(bar_half, span * 0.5),
radius,
);
color = mix(color, TRACK, coverage(track));
// Reference rules at 25 / 50 / 75 / 100 % of probability, one weight for all.
for (var k = 1u; k <= 4u; k = k + 1u) {
let y = base - span * f32(k) * 0.25;
color = mix(color, RULE, coverage(abs(px.y - y) - 0.5) * rule_mask);
}
// The axis goes under the bars so their bases stay unbroken.
color = mix(color, AXIS, coverage(abs(px.y - base) - 0.5) * rule_mask);
if (has_result) {
let bar_h = max(probability(index) * span, MIN_BAR * height);
let bar = sd_round_box(
px - vec2f(center_x, base - bar_h * 0.5),
vec2f(bar_half, bar_h * 0.5),
radius,
);
// Flat fill: the argmax is simply the one bar at full accent brightness.
let fill = select(ACCENT_DIM, ACCENT, index == winner);
color = mix(color, fill, coverage(bar));
}
// One tick per class, pointing at the static DOM label below. The winner's
// tick picks up the accent, which keeps the highlight in the shader.
let tick = sd_round_box(
px - vec2f(center_x, base + 0.026 * height),
vec2f(0.6, 0.015 * height),
0.6,
);
var tick_color = TICK;
if (has_result && index == winner) { tick_color = ACCENT; }
color = mix(color, tick_color, coverage(tick));
return vec4f(color, 1.0);
}