Estimating VRAM Footprint Per Zoom Level
The exact sub-problem here is turning a viewport and a tile schema into a single number: how many bytes of VRAM the resident tile set will occupy at a given zoom level. That number is the input every budgeting decision depends on — it sets the capacityBytes ceiling the cache enforces, it tells you whether a zoom transition will momentarily need two levels resident at once, and it flags ahead of time when a dense tile schema will cross maxBufferSize or the device’s real memory. Guessing it wrong in either direction is costly: an over-estimate wastes VRAM the renderer could spend on textures, while an under-estimate admits more tiles than fit and loses the device on a deep zoom. The estimate has two factors — how many tiles are in view at a zoom, and how many bytes each tile’s buffers cost — and this page derives both, then multiplies them in runnable TypeScript. It is the calibration companion to VRAM budget management across tile zoom levels: the budget policy assumes a ceiling, and this is where the ceiling comes from.
The count of tiles in view is set by the viewport, not the zoom: a level holds tiles worldwide, but the camera only ever sees the handful that cover the canvas. For a canvas device pixels, a tile edge of pixels, and a prefetch ring of tiles on each side, the resident count is
The accounts for a viewport that straddles a tile boundary, so a column or row is partially visible on each edge; the adds the prefetch ring on both sides. With per-tile buffer costs (vertex), (index), and (uniform), each rounded up to the allocation granularity , the expected resident footprint at that zoom is
Because depends only on the viewport, the footprint is roughly constant across zoom levels for a fixed tile schema — the danger is not any single level but the transition between two, where both levels are briefly resident and the peak is close to .
Runnable reference implementation
The functions below compute tiles-in-view, per-tile footprint, and the resident total, then sweep a zoom range to produce a peak that includes the two-level transition overlap. Every byte count is rounded up to the allocation granularity so the estimate matches what the driver actually reserves rather than the logical data size.
export interface TileSchema {
vertexBytes: number; // positions + attributes at a representative tile density
indexBytes: number; // triangle or line indices
uniformBytes: number; // per-tile transform and style block
}
export interface Viewport {
widthPx: number; // canvas width in device pixels (CSS px × devicePixelRatio)
heightPx: number; // canvas height in device pixels
tilePx: number; // tile edge in pixels, usually 256 or 512
ring: number; // prefetch tiles per side, usually 1
}
const ALLOC_GRANULARITY = 256; // bytes; buffers are reserved in aligned blocks
// N_view: tiles the camera can see, including partial edges and the prefetch ring.
export function tilesInView(vp: Viewport): number {
const cols = Math.ceil(vp.widthPx / vp.tilePx) + 2 * vp.ring + 1;
const rows = Math.ceil(vp.heightPx / vp.tilePx) + 2 * vp.ring + 1;
return cols * rows;
}
// One tile's resident footprint, each buffer rounded up to the alloc block.
export function tileFootprintBytes(s: TileSchema, g = ALLOC_GRANULARITY): number {
const align = (n: number) => Math.ceil(n / g) * g;
return align(s.vertexBytes) + align(s.indexBytes) + align(s.uniformBytes);
}
// Expected resident bytes at one zoom level for this viewport and schema.
export function residentBytesAtZoom(vp: Viewport, s: TileSchema): number {
return tilesInView(vp) * tileFootprintBytes(s);
}
// A tile schema can vary by zoom: deep levels often carry denser geometry.
export type SchemaForZoom = (z: number) => TileSchema;
export interface ZoomFootprint {
zoom: number;
tiles: number;
perTileBytes: number;
residentBytes: number;
}
// Sweep a zoom range, reporting the footprint at each level.
export function footprintByZoom(
vp: Viewport,
schemaFor: SchemaForZoom,
minZoom: number,
maxZoom: number,
): ZoomFootprint[] {
const rows: ZoomFootprint[] = [];
for (let z = minZoom; z <= maxZoom; z++) {
const s = schemaFor(z);
const perTile = tileFootprintBytes(s);
const tiles = tilesInView(vp);
rows.push({ zoom: z, tiles, perTileBytes: perTile, residentBytes: tiles * perTile });
}
return rows;
}
// Peak VRAM during interaction: a zoom transition holds two adjacent levels
// resident at once, so the budget ceiling must cover the largest such pair.
export function peakTransitionBytes(rows: ZoomFootprint[]): number {
let peak = 0;
for (let i = 0; i < rows.length; i++) {
const here = rows[i].residentBytes;
const next = i + 1 < rows.length ? rows[i + 1].residentBytes : 0;
peak = Math.max(peak, here + next); // both levels briefly resident
}
return peak;
}
Driving it with a concrete viewport and schema produces the ceiling the tile cache consumes and a table you can log to confirm no level crosses the device budget. The peak, not the per-level figure, is what capacityBytes in building an LRU VRAM cache for tile buffers must accommodate.
const viewport: Viewport = {
widthPx: 1600 * window.devicePixelRatio,
heightPx: 1200 * window.devicePixelRatio,
tilePx: 256,
ring: 1,
};
// Denser geometry at deeper zoom: vertex bytes grow with the level.
const schemaFor: SchemaForZoom = (z) => ({
vertexBytes: 64 * 1024 + z * 24 * 1024, // ~64 KiB base, growing with detail
indexBytes: 24 * 1024,
uniformBytes: 256,
});
const rows = footprintByZoom(viewport, schemaFor, 4, 14);
const ceiling = peakTransitionBytes(rows); // feed to the tile cache as capacityBytes
for (const r of rows) {
const mib = (r.residentBytes / (1024 * 1024)).toFixed(1);
console.log(`z${r.zoom}: ${r.tiles} tiles × ${r.perTileBytes} B = ${mib} MiB resident`);
}
console.log(`peak transition: ${(ceiling / (1024 * 1024)).toFixed(1)} MiB`);
Because tilesInView folds in the device pixel ratio, a high-DPI display quietly quadruples the resident count relative to the same CSS-pixel canvas at ratio 1 — a common reason a budget tuned on a standard monitor loses the device on a retina laptop. Feeding the device pixel canvas size, not the CSS size, is what keeps the estimate honest.
Parameter reference
Every input to the footprint model, with guidance for tiled spatial workloads. These tables scroll horizontally on narrow viewports.
| Parameter | Typical value | Spatial-workload guidance |
|---|---|---|
widthPx / heightPx |
canvas × devicePixelRatio |
Use device pixels, not CSS pixels; a ratio of 2 quadruples tilesInView and the footprint with it. |
tilePx |
256 or 512 | Larger tiles mean fewer, heavier buffers: halves tilesInView but roughly quadruples per-tile bytes at the same density. |
ring |
1 | Prefetch tiles per side. Each extra ring adds a full border of tiles to the resident count; 0 stalls on pan, 2 inflates VRAM. |
vertexBytes |
32–256 KiB | The dominant term; measure it from a representative dense tile, not an empty ocean tile, or the estimate under-counts busy areas. |
indexBytes |
8–48 KiB | Scales with primitive count; for line tiles it can rival vertexBytes. |
uniformBytes |
256 | Per-tile transform and style; rounds up to a full 256-byte block even when logically smaller. |
ALLOC_GRANULARITY |
256 | Buffers reserve in aligned blocks; matches minStorageBufferOffsetAlignment and keeps the estimate above the logical size. |
| Peak vs per-level | peakTransitionBytes |
The budget ceiling must cover the two-level transition peak, not the steady per-level figure. |
The device pixel ratio is the input that bites first: it is easy to model the footprint in CSS pixels, see a comfortable 30 MiB, and then lose the device on a retina display where the real resident set is 120 MiB. Always resolve the canvas to device pixels before calling tilesInView. For authoritative wording on the buffer size and alignment limits these estimates must respect, consult the W3C WebGPU specification.
Failure modes
- Under-count from CSS pixels. Modeling the viewport in CSS pixels rather than device pixels ignores
devicePixelRatio, so the estimate is a quarter of reality on a 2× display and the budget admits four times too many tiles. Detection: the device is lost on high-DPI hardware but not on a standard monitor at the same zoom. Fix: multiply canvas dimensions bywindow.devicePixelRatiobefore callingtilesInView. - Ignoring the transition peak. Sizing the ceiling to a single level’s
residentBytesmisses that a zoom crossing holds two levels resident at once, so VRAM briefly doubles and the largest allocation fails. Detection: device loss only at the instant of a zoom step, never during a steady pan. Fix: sizecapacityBytestopeakTransitionBytes, which sums each adjacent level pair. - Sparse-tile calibration. Measuring
vertexBytesfrom an empty or ocean tile under-counts dense urban tiles by an order of magnitude, so the budget fits the average and fails over a city. Detection: footprint estimate tracks reality over water but diverges over dense land. Fix: calibrate the schema against the densest tile the dataset produces, or varyschemaForby region as well as zoom. - Granularity mismatch. Summing logical buffer sizes without rounding to the allocation block under-counts every tile by up to 255 bytes times three buffers, which compounds across dozens of resident tiles. Detection: real VRAM sits consistently above the estimate by a few megabytes. Fix: keep the
alignrounding intileFootprintBytesand matchALLOC_GRANULARITYto the device’s alignment.
Backend / Python interop note
When a Python service tiles and pre-packs the geometry, it can report the exact per-tile buffer sizes back to the client so the footprint model uses measured bytes instead of assumed ones. Emitting the vertex and index byte counts alongside each tile lets the client size its budget to the real schema, and lets the server refuse to ship a tile whose vertex buffer would exceed maxBufferSize.
import numpy as np
def tile_buffer_sizes(vertices: np.ndarray, indices: np.ndarray, align: int = 256) -> dict:
"""Report aligned buffer byte counts for one packed tile.
vertices: contiguous float32 array, packed vec2/vec3 positions + attributes.
indices: contiguous uint32 array of primitive indices.
"""
def aligned(nbytes: int) -> int:
return -(-nbytes // align) * align # round up to the alloc block
v = aligned(np.ascontiguousarray(vertices, dtype=np.float32).nbytes)
i = aligned(np.ascontiguousarray(indices, dtype=np.uint32).nbytes)
u = align # one 256-byte uniform block per tile
max_buffer = 256 * 1024 * 1024 # default maxBufferSize; keep tiles under it
assert v <= max_buffer, "tile vertex buffer exceeds maxBufferSize; split the tile"
return {"vertexBytes": v, "indexBytes": i, "uniformBytes": u}
Two backend rules keep the estimate trustworthy. First, report aligned sizes, not logical array lengths, so the client’s tileFootprintBytes matches the server’s packing exactly. Second, cap each tile under maxBufferSize server-side and split any that would exceed it, so the client never has to handle a tile it cannot allocate. The streaming path that ships these packed buffers is covered in spatial aggregation in GPU memory, whose resolution-scaled grid budgeting is the sibling of the tile budgeting here.
Related
- VRAM Budget Management Across Tile Zoom Levels — the budgeting policy that consumes this footprint estimate as its ceiling.
- Building an LRU VRAM Cache for Tile Buffers — the cache whose
capacityBytesthis page computes. - Frame Profiling with Timestamp Queries — pair the byte estimate with measured per-tile GPU time so the working set fits the frame, not only the heap.
- Memory Alignment for Spatial Data Buffers — the stride and alignment rules that make a per-tile byte estimate accurate.