Part of #114.
Rescoped for 0.2.0. The original proposal bundled two utilities:
canvasSizing()(the write side of canvas sizing) andscaledByDpr()(rescaling tuned constants by the effective DPR). The sizing half is done — it shipped assurface()inpackages/vgpu-api/src/surface.ts. OnlyscaledByDpr()is still missing, so this issue now asks for that helper alone. The full original proposal is preserved at the bottom.
What surface() already covers
surface(gpu, canvas, opts) owns the canvas size end to end:
dpr?: number | [min, max]— a fixed DPR or a clamp range applied todevicePixelRatio. Covers both clamp directions the original issue called out (Eve'smin(dpr, 2)cap and triangle-led-4'smax(dpr, 1)floor) with a single option.autoResize— observes the canvas layout box and re-applies size + DPR automatically; defaults totruefor layout-backed canvases and is rejected for canvases that have no layout box.onResize(cb)— fires with{ width, height, dpr, surface }after each applied change, returning an unsubscribe function. This is the cue to rebuild size-dependent render targets.surface.dpr— the clamped DPR actually in effect (internallyeffectiveDpr()), i.e. the value you would feed toscaledByDpr().- Internal
layoutCanvasSize()/setCanvasSize()do the guarded, rounded write tocanvas.width/height, so no consumer hand-rolls the measure → clamp → write chain any more.
So CanvasSizing, dprClamp, observe and apply() from the original proposal all have shipped equivalents. Nothing further is requested there.
What is still missing: scaledByDpr()
The second bug class from the original writeup is untouched: constants tuned at one DPR (BLOOM_RADIUS = 16, imprint cell size) are silently wrong at another. There is no primitive for it today, and the workaround that shipped in Eve — const safe = Number.isFinite(dpr) && dpr > 0 ? dpr : 2 — is itself the hazard: a silent fallback renders at the wrong scale instead of failing.
export function scaledByDpr(value: number, dpr: number, opts: {
/** REQUIRED — the DPR the constant was tuned at. No default, deliberately. */
readonly referenceDpr: number;
readonly round?: "round" | "floor" | "ceil"; // default: no rounding
readonly min?: number;
}): number;
// value * (dpr / referenceDpr), then round, then min.Semantics.
- Pure, no DOM. Works in workers, offscreen targets and headless harnesses — it takes a DPR number, never a canvas.
referenceDpris required. The whole bug class is an implicit tuning assumption; a default (2or1) re-hides it. This is the one place ergonomics deliberately lose to safety.- Throws on invalid input: a non-finite or non-positive
dprorreferenceDprraises the standard validation error rather than falling back. No silent defaults. - Application order is fixed: scale →
round→min.
Example
import { surface } from "vgpu";
const view = surface(gpu, canvas, { dpr: [1, 2] });
// constants tuned at DPR 2, now explicit about it:
const bloomRadius = scaledByDpr(BLOOM_RADIUS, view.dpr, { referenceDpr: 2, round: "round", min: 0 });
const cellSize = scaledByDpr(DEFAULT_IMPRINT_CELL_SIZE, view.dpr, { referenceDpr: 2 });
view.onResize(({ dpr }) => {
// re-derive DPR-tuned constants when the surface re-clamps
});Same example without the util
// two near-identical helpers, both hiding the tuning assumption
function scaleBloomRadiusForDpr(dpr: number): number {
const safe = Number.isFinite(dpr) && dpr > 0 ? dpr : 2; // silent fallback = the bug
return Math.max(0, Math.round(BLOOM_RADIUS * (safe / 2))); // "/ 2" is implicit referenceDpr
}
function scaleImprintCellForDpr(dpr: number): number {
const safe = Number.isFinite(dpr) && dpr > 0 ? dpr : 2;
return DEFAULT_IMPRINT_CELL_SIZE * (safe / 2);
}Open questions
- Placement. Free function exported from
vgpu, or a@vgpu/render/utilshelper? It has noGpudependency, so it does not need the(gpu, ...)first-argument convention ofeffect()/draw()/geometry(). - A
surfaceconvenience overload (scaledByDpr(value, view, { referenceDpr })) — rejected in the original writeup because coupling to a DOM-backed object kills the worker/headless use case. Still rejected unless there is demand.
Original proposal (pre-0.2.0)
Part of #114.
Part of #114 (dogfood API). Package: @vgpu/render/utils.
Context
canvasResolution (packages/render/src/utils/canvas-resolution.ts) only reads canvas.width/height. Every consumer hand-rolls the write side — measure CSS size, resolve/clamp devicePixelRatio, guarded write to canvas.width/height:
- Eve hero (
apps/docs/.../eve-logo-shader/index.tsx:342-351):resizeCanvasclamps DPR toMAX_DEVICE_PIXEL_RATIO(max 2),floor(rect × dpr), no-change guard — called both on mount and per frame. - triangle-led-4 (
front/.../triangle-led-4/bridge/dimensions.ts:55-58):resolveDevicePixelRatioclamps the opposite direction (max(dpr, 1), plus a mobile max). Same pattern, different clamp —dprClamp: [min, max]expresses both.
Separately, Eve hit the same bug twice: constants (BLOOM_RADIUS = 16, imprint cell size) tuned at DPR 2 were silently 2× too large at DPR 1. The fix added two near-identical ~13-line scaling helpers plus a safeDevicePixelRatioOrDefault guard — and that guard is itself the hazard: a worker/offline context passing undefined quietly renders at the wrong scale. The bug survived precisely because of an implicit default and a silent fallback, so this API has deliberately hard edges: referenceDpr is required and invalid DPR throws.
None of this exists in vgpu today; canvasResolution stays as the read-only contract (for consumers that don't own sizing), and canvasSizing becomes its write-side companion.
Proposed API
// -- 8a: write-side companion to canvasResolution ---------------------------
export interface CanvasSizing {
/** Device-pixel size last applied to the canvas. */
readonly width: number;
readonly height: number;
/** Clamped DPR actually in effect (use this for scaledByDpr). */
readonly dpr: number;
/** Re-measure CSS size + devicePixelRatio, clamp, write canvas.width/height
* only if changed. Returns true when dimensions changed. */
apply(): boolean;
dispose(): void;
}
export function canvasSizing(canvas: HTMLCanvasElement, opts?: {
/** number = max clamp (the common perf cap); tuple = [min, max]. Default: none. */
readonly dprClamp?: number | readonly [min: number, max: number];
/** Auto-apply on size/DPR changes. Default: false (pull via apply()). */
readonly observe?: boolean;
/** Fires after each applied change (not for the initial sizing). */
readonly onResize?: (size: { width: number; height: number; dpr: number }) => void;
}): CanvasSizing;Semantics.
- Construction measures and applies once — the util's job is sizing the canvas; deferring the first write would leave
sizing.width ≠ canvas.width. - Measurement:
devicePixelContentBoxSizevia ResizeObserver when supported (exact device pixels; immune to floor-vs-round fractional-DPR mismatch). Fallback:round(getBoundingClientRect() × clampedDpr)—round, notfloor, to match what the observer reports (≤1px change vs Eve's floor; changelog-note it). observe: true: observes with{ box: "device-pixel-content-box" }where supported — this fires on browser zoom and cross-monitor DPR changes even when CSS size is unchanged, no matchMedia needed. Fallback (Safari): content-box ResizeObserver plus a re-armedmatchMedia("(resolution: <dpr>dppx)")listener to catch pure-DPR changes. Observer auto-applies;apply()stays callable either way (idempotent).apply()is the only mutation point in pull mode — the guarded write tocanvas.width/height(which resets the drawing buffer) is the caller's cue to rebuild size-dependent render targets; hence the boolean return.- SSR: safe by construction — requires a live
HTMLCanvasElement, so it can only run client-side. Notypeof windowsoft-guards. Workers/OffscreenCanvas are out of scope (no CSS box); pass a DPR across and usescaledByDpr. - No device coupling: does not touch GPUCanvasContext or device limits; callers min against
device.limits.maxTextureDimension2Dif needed (docs note).
// -- 8b: scale a tuned constant to the actual DPR ---------------------------
export function scaledByDpr(value: number, dpr: number, opts: {
/** REQUIRED — the DPR the constant was tuned at. No default, deliberately. */
readonly referenceDpr: number;
readonly round?: "round" | "floor" | "ceil"; // default: no rounding
readonly min?: number;
}): number;
// value * (dpr / referenceDpr), then round, then min.
// Throws ValidationError when dpr or referenceDpr is not finite-positive
// (matches vgpu's existing ValidationError convention).Pure function, no DOM — works in workers and offline harnesses (scripts/render-eve-5.ts).
Example
const sizing = canvasSizing(canvas, { dprClamp: [1, 2] });
// per frame (Eve's pull style):
if (sizing.apply()) rebuildTargets(sizing.width, sizing.height);
// the silent DPR-2 tuning bug, now explicit:
const bloomRadius = scaledByDpr(BLOOM_RADIUS, sizing.dpr, { referenceDpr: 2, round: "round", min: 0 });
const cellSize = scaledByDpr(DEFAULT_IMPRINT_CELL_SIZE, sizing.dpr, { referenceDpr: 2 });Same example without the util
// eve-logo-shader/index.tsx:342-351 — hand-rolled write side
function resizeCanvas(canvas: HTMLCanvasElement | null) {
if (!canvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DEVICE_PIXEL_RATIO);
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width * dpr)); // floor ≠ observer px
const height = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
// no zoom / cross-monitor DPR listener; caller re-detects change by re-measuring
// constants.ts — two near-identical helpers, both hiding the tuning assumption
function scaleBloomRadiusForDpr(dpr: number): number {
const safe = Number.isFinite(dpr) && dpr > 0 ? dpr : 2; // silent fallback = the bug
return Math.max(0, Math.round(BLOOM_RADIUS * (safe / 2))); // "/ 2" is implicit referenceDpr
}
function scaleImprintCellForDpr(dpr: number): number {
const safe = Number.isFinite(dpr) && dpr > 0 ? dpr : 2;
return DEFAULT_IMPRINT_CELL_SIZE * (safe / 2);
}~40 lines per consumer, duplicated with opposite clamp directions across Eve and triangle-led-4, and the safe = ... : 2 fallback is exactly how the wrong-scale render shipped.
Alternatives
- Merge into
canvasResolution— rejected. Read-only vs canvas-mutating are different contracts; silently changing an existing export's behavior is a bad trade. Both coexist:canvasSizingfor owners of the canvas size,canvasResolutionfor read-only consumers (itswidth/heightreflect whatevercanvasSizing.apply()wrote, so they compose cleanly). sizing.scaled(value, opts)method instead of standalonescaledByDpr— rejected. The pure function must run in workers/offline harnesses with no canvas; coupling it to a DOM object kills the main use case that motivated the throw-on-invalid-DPR edge.- Default
referenceDpr: 2(or1) — rejected. The entire bug class is an implicit tuning assumption; a default re-hides it. This is the one place ergonomics deliberately lose to safety. - Ambient
DprContextthreaded through the renderer — rejected as framework-shaped; two small explicit primitives beat one ambient context.
Activity
0No activity yet.