Virtual texturing
Shade streams texture pages on demand - opt a material into a VirtualTextureStack and GPU texture memory is bounded by what is on screen instead of by what is loaded.
Virtual texturing lets a material sample a texture set far larger than GPU memory - an 8192², 16384² or 65536² albedo/normal/ORM triple - by keeping only the pages the camera can currently see resident. It is a feature of Shade, meep’s renderer, it is opt-in per material, and it allocates nothing at all until the first material asks for it. Hundreds of virtual textures share one page table, one physical cache and one feedback pass.
There is no virtual-texture system, material or page object to construct: the opt-in is one field on the material, and the renderer drives the whole loop. Pre-tiled page files in the coarsest-first ${mip}-${x}-${y}.png layout are readable too - see Reading legacy tiles.
Opting a material in
Assign a VirtualTextureStack to StandardShadeMaterial.vt_stack. That is the whole opt-in - registration happens automatically the first time the material is built, and one stack may be shared by any number of materials.
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { VirtualTextureStack } from "@woosh/meep-engine/src/shade/renderer/texture/virtual/VirtualTextureStack.js";
import { VTSourceTiled } from "@woosh/meep-engine/src/shade/renderer/texture/virtual/source/VTSourceTiled.js";
const material = new StandardShadeMaterial();
material.vt_stack = VirtualTextureStack.from({
label: "rock",
size: 8192,
source: new VTSourceTiled({
layers: [
"tiles/rock/albedo/{mip}/{x}_{y}.webp",
"tiles/rock/normal/{mip}/{x}_{y}.webp",
"tiles/rock/orm/{mip}/{x}_{y}.webp"
]
}),
wrap: true,
uv_set: 0,
average_color: [0.4, 0.35, 0.3, 1]
});
When vt_stack is set, the deferred material pass takes albedo, normal and ORM from the streamed pages and ignores texture_albedo, texture_normal and texture_orm. texture_emissive stays a regular texture and is sampled normally.
What a stack is
A stack is the unit of virtualization: a set of texture layers that share one UV set and one resolution. The whole set streams, resides and is addressed together - one feedback value, one page-table entry, one residency decision - so the per-pixel addressing work is paid once no matter how many layers the material reads. The standard cache group is three layers, in this order:
| Layer | Format | Content |
|---|---|---|
| 0 | rgba8unorm-srgb | albedo (premultiplied on upload) |
| 1 | rgba8unorm | tangent-space normal |
| 2 | rgba8unorm | ORM: R = occlusion, G = roughness, B = metalness |
A source may provide a subset. Layers it omits fall back per layer in the shader: albedo to the stack’s average_color, normal to flat +Z, ORM to neutral.
VirtualTextureStack.from()
| Option | Default | Meaning |
|---|---|---|
size | required | Side length in texels. Square, power of two, a multiple of page_size, at most 512 pages per axis (65536 texels at the default page size). |
source | required | Tile provider - see below. |
label | "" | Name used in error messages. |
uv_set | 0 | Which vertex UV set the stack samples: 0 or 1. A uv_set: 1 stack re-interpolates uv1 for every sample of that material, emissive included. |
wrap | true | true repeats, false clamps to edge. Applied in-shader, not by a sampler. |
average_color | [0.5, 0.5, 0.5, 1] | Linear-space premultiplied RGBA shown while no page is resident - in practice only for the first frames after registration, before the pinned root page arrives. |
from() checks that size is a non-negative integer and that a source was given; the power-of-two and page-multiple rules are asserted when the stack registers, on first material build.
Tile sources
| Source | Constructor | Use for |
|---|---|---|
VTSourceTiled | new VTSourceTiled({ layers, legacy_meep_mips }) | Pre-tiled page files fetched over HTTP and decoded with createImageBitmap (WebP, AVIF, PNG, JPEG - browser-native, no WASM). One file per page per layer, addressed by a URL template with {layer}, {mip}, {x} and {y} placeholders. |
VTSourceImage | new VTSourceImage({ images, wrap }) | Runtime tiling of ordinary decoded images: builds a canvas mip pyramid once, then blits padded tiles on demand. The zero-pipeline adoption path - it wins GPU memory and per-frame bandwidth, not host memory, since the full pyramid stays in canvas memory. |
VTSourceProcedural | new VTSourceProcedural({ latency_ms }) | Labelled debug tiles - colour is the mip actually resident, the text is the page address. latency_ms fakes network delay. Also the reference implementation of the source contract. |
Import paths follow the module name, for example @woosh/meep-engine/src/shade/renderer/texture/virtual/source/VTSourceImage.js. All three are named exports.
Serve normal and ORM tiles in a lossless or 4:4:4 profile. Chroma subsampling mangles them.
The custom source contract
A source is any object with these members - there is no base class to extend:
| Member | Required | Contract |
|---|---|---|
layer_mask | yes | Bitmask of layers the source provides, bit 0 = albedo. Defaults to 0b111 if absent. |
load_page(mip, x, y, signal) | yes | Resolves to an array with one payload per provided layer (ImageBitmap, OffscreenCanvas, or { data: Uint8Array }); omitted layers are undefined. Each payload is slot_size square - that is page_size + 2 * border, with the border texels already baked in. Reject with an AbortError when signal aborts. |
prepare(geometry) | no | Called on registration with { page_size, border, slot_size, layer_count, size, mip_count }. This is where a source learns the slot size it must produce. |
destroy() | no | Called when the last material referencing the stack unregisters it. |
Payloads that do not match slot_size are rejected by an assertion, as is a source that omits a layer it declared in layer_mask.
Constraints in v1
- Opaque materials only, and it is asserted.
material.transparency_modemust beTransparencyMode.Opaque(the default). The alpha-tested visibility pass and the OIT path sample the regular albedo texture, where VT residency is not guaranteed, so settingvt_stackon an alpha-tested or transparent material fails an assertion rather than rendering wrong. - Emissive is never virtualized. It stays a regular texture on the material.
- Feedback covers the primary view only. The CPU path tracer and the lightmap baker do not sample virtual textures at all - they see the fallback colours.
- Filtering is bilinear with per-pixel nearest mip. No anisotropy, no trilinear blend between mips. The 4-texel default border already budgets for anisotropy later.
- A stack is at most 512 pages per axis - 65536 texels at the default 128-texel page - and at most 16 mip levels, both limits set by the bit layout of the feedback request key in
VT_CONSTANTS.js. - At most 1022 stacks may be registered at once - the stack id is 10 bits with
0and1023reserved - and the shared page table is a finite address space besides. Registration throws a plainError(vt: page table is fullorvt: stack budget exhausted) when either runs out.
The residency loop, and what it costs
Feedback. After the visibility buffer is resolved, one compute pass walks the primary view at one sample per feedback_stride² pixels, jittered over stride² frames so every pixel is eventually covered. Each sample recovers the mesh, its material and its stack, computes the UV and analytic gradients with the same WGSL chunk the material sampler uses, selects a mip, and inserts a packed stack:10 | mip:4 | x:9 | y:9 key into a GPU hash set. At 1080p with the default stride of 4 that is about 130,000 samples deduplicated into a 16,384-entry, 64 KB buffer, regardless of how many stacks exist.
Read-back. The hash buffer is copied to a pooled staging buffer and mapped asynchronously - at most three read-backs in flight, results typically folded in on the next frame. Nothing stalls the frame, and the price is that a page is requested one to two frames after the camera first needs it. The eviction_protect_window of 4 frames exists precisely to keep a page that is in flight through this loop from being evicted before its request lands.
Streaming. VirtualTextureManager.update() runs once per frame inside the renderer’s frame setup and does the CPU-side work: apply arrived pages within the upload budget, evict by LRU (protected pages excluded, finest mip shed first on ties), flush the page table’s dirty rectangles, then issue queued loads coarse-first with exponential backoff on failures. Requests untouched for queue_stale_window frames are dropped rather than fetched late.
That gives concrete numbers to budget against, at the defaults:
| Cost | Default | Note |
|---|---|---|
| Physical cache | ~64 MB per layer, ~192 MB for the standard three | One 4096² atlas per layer, 30×30 = 900 page slots |
| Page table | ~5 MB | One mip-mapped 1024² r32uint texture, shared by every stack |
| Feedback buffer | 64 KB | Plus one compute dispatch over the primary view per frame |
| Upload ceiling | 8 pages per frame | A page is 136² texels per layer, so ~217 KB across the three layers, ~1.7 MB per frame at full budget |
| Network concurrency | 32 in-flight loads | Queue capped at 1/3 of cache capacity; stale entries dropped |
| Latency to sharp | 1-2 frames of feedback, plus the fetch | The root page of every stack is pinned at registration, so something is always resident once it arrives |
Degradation, not thrash. Every page-table texel holds the nearest resident ancestor rather than a “not resident” marker, so sampling is one textureLoad followed by the layer fetches and never a mip walk, and a missing page shows as its parent rather than as a hole. When residency crosses the high watermark the manager raises a global mip bias, so oversubscription costs sharpness instead of collapsing into upload thrash; the bias drops again at the low watermark.
Configuration
Geometry freezes at the first stack registration, so configure() must be called before any material carrying a vt_stack is built. It asserts if called later. The manager lives on Shade’s GraphicsContext, reachable once engine.start() has resolved (engine.graphics.renderer is null before that):
const renderer = engine.graphics.renderer;
renderer.graphics.virtual_textures.configure({
page_size: 128,
border: 4,
atlas_size: 4096,
table_size: 1024
});
| Key | Default | Meaning |
|---|---|---|
page_size | 128 | Payload texels per page side |
border | 4 | Border texels per side, baked into tiles - slot size is 136² at the defaults |
atlas_size | 4096 | Physical cache side per layer; capacity is floor(atlas_size / slot_size)² |
table_size | 1024 | Shared page-table side, in pages. At the default page size this addresses 256 stacks of 8192² or 4 of the maximum 65536² |
layers | albedo / normal / orm | Cache group: name, format, premultiply flag |
hash_capacity | 16384 | Unique requests the feedback hash set can hold per frame |
feedback_stride | 4 | Sample one pixel per stride² block, jittered. Runtime-adjustable |
max_uploads_per_frame | 8 | Page-upload budget |
max_concurrent_loads | 32 | In-flight source loads |
request_cap_fraction | 1/3 | Pending requests capped at this fraction of cache capacity |
eviction_protect_window | 4 | Frames a touched page is unevictable, covering read-back latency |
queue_stale_window | 30 | Untouched queued requests dropped after this many frames |
pressure_high_watermark | 0.9 | Residency fraction at which the mip bias engages |
pressure_low_watermark | 0.75 | Residency fraction at which it disengages |
Switching it off, and looking inside
renderer.feature_virtual_textures (default true) is the global switch, one of the frame settings. Clearing it stops the feedback pass and all streaming; pages already resident keep rendering, so the picture freezes at its current sharpness rather than reverting to fallback colours. While no stack is registered the feature costs nothing either way - the manager allocates its textures lazily.
renderer.graphics.virtual_textures.stats is refreshed every update() and is the first place to look when streaming misbehaves:
const { stacks, resident_pages, cache_capacity, pending_loads, queued_loads,
uploads_last_frame, evictions_last_frame, dropped_requests,
residency_bias } = renderer.graphics.virtual_textures.stats;
A residency_bias that never returns to zero, or evictions_last_frame tracking uploads_last_frame, means the working set does not fit: raise atlas_size, lower texture resolution, or accept the blur.
Reading legacy tiles
VTSourceTiled reads the coarsest-first tiled layout with legacy_meep_mips: true - ${mip}-${x}-${y}.png naming, where mip 0 is the coarsest level rather than the finest:
const source = new VTSourceTiled({
layers: [
"data/textures/terrain/mega/albedo/{mip}-{x}-{y}.png",
undefined,
undefined
],
legacy_meep_mips: true
});
That is the only accommodation. There is no override material and no makeMaterial(); you set vt_stack on the material you already have. There is no per-system initialize()/setTexture()/update() to call - the renderer drives the whole loop. Stacks are addressed by a 10-bit id in a shared page table, so many can be resident at once.
Virtual textures are not virtual geometry
The two share a word and an idea - stream fixed-size pages, keep resident only what is visible. They are separate subsystems with separate file formats and do not depend on each other. Both have renderer integration; virtual geometry requires explicit runtime and feedback setup.
| Virtual texturing | Virtual geometry | |
|---|---|---|
| Streams | texels: albedo, normal, ORM pages | clusters of triangles with their LOD hierarchy |
| Opt-in | material.vt_stack | VirtualGeometry plus scene runtime and feedback setup |
| Format | ordinary image tiles, or none for VTSourceImage | .vgeo container, one per geometry |
| Status in 3.57.0 | shipping | GPU cut selection and meshlet expansion, RAM/VRAM residency, and streaming feedback |
Virtual geometry (.vgeo)
.vgeo is Shade’s container for a meshlet DAG: clusters of at most 128 triangles, grouped and simplified level by level, with error bounds used to pick a cut through the hierarchy per view. The package ships the writer, validator and VGeoContainerReader under shade/renderer/geometry/virtual/format/read/. The reader opens the header and root page first, fetches requested pages by byte range, and supports eviction. VGeoResidency (.../virtual/runtime/) owns both residency tiers: a byte budget each for RAM and VRAM, CLOCK eviction under a protect window, and admission against the RAM budget before a page is fetched at all.
The .../virtual/runtime/ directory supplies VirtualGeometry, a MeshletGeometry subclass backed by an open reader, and the GPU runtime. GPUSceneContext.enable_virtual_geometry(options) creates a pool and runtime sharing the renderer’s ordinary meshlet storage. The rasterization paths select a view-dependent cut on the GPU and expand it into the same meshlet draw records used by static geometry. The runtime’s tau controls permitted error in pixels (default 1).
Streaming still needs application setup: VGeoFeedbackLoop connects GPU page wants and touches to the residency and the GPU pool. See shade/playground/vgeo_runtime/ for the complete setup, including begin_frame / end_frame and feedback readback. The renderer opens one virtual-geometry frame across the main view and shadow graphs. Shadow views select their own cuts, share residency and contribute streaming feedback; pending caster detail can trigger a shadow-map refresh. Loading an ordinary model does not automatically convert it to virtual geometry.
For multiple assets, share one VGeoResidency and GPU pool across readers; instances of one container share its resident pages. Registration throws if the container’s root page cannot fit in RAM and VRAM, so budget for the roots as well as streamed detail. The package’s shade/playground/vgeo_scene/ shows this setup across a whole converted scene. Its asset requests need a server that supports byte ranges with 206 responses; cross-origin hosting must expose Content-Range.
The cut runtime starts with max_iterations: 8. If feedback reports VGEO_CUT_STAT.QUEUE_REMAINING, VGeoFeedbackLoop calls runtime.note_incomplete_descent(...), increasing the traversal budget for subsequent frames up to max_iteration_ceiling (default 64). Remaining work means incomplete geometry, rather than simply a coarser LOD. runtime.iteration_escalations counts these increases.
import { vgeo_build } from "@woosh/meep-engine/src/shade/renderer/geometry/virtual/build/vgeo_build.js";
import { VGeoBuildOptions } from "@woosh/meep-engine/src/shade/renderer/geometry/virtual/build/VGeoBuildOptions.js";
// geometry: a Shade `Geometry` in object space
const result = vgeo_build(geometry, new VGeoBuildOptions());
result.buffer; // ArrayBuffer - the .vgeo bytes
result.page_count;
result.levels; // per-level summaries
vgeo_build takes over the geometry’s arrays - welding vertices and re-deriving normals and tangents - so pass a clone if you still need the original. Defaults worth knowing: codec is LZ4 at codec_level 9 (LZ4_LEVEL.HC_DEFAULT - the level is how long the encoder spends looking for matches, not a change to the file: every level writes the same LZ4 block format), nominal_page_size is 64 KiB, level_options.simplification_ratio is 0.5 (targets half as many clusters in the parent), groups start with 4 clusters, and weld_ratio is 1e-5 (the source’s own advice is to leave it alone). The page size is a hard decoded-byte cap, advertised by the bounded-pages feature bit. A group cannot be split across pages, so a group larger than the cap makes the build throw; increase the page size and configure the runtime to match.
The builder supplies per-vertex normals and tangents even when the input geometry lacks them. Builder support types are grouped under build/level/, build/mesh/, build/page/, build/partition/, build/sphere/ and build/submesh/; for example, VGeoLevelOptions lives at shade/renderer/geometry/virtual/build/level/VGeoLevelOptions.js.
Ordinary meshes still use the plain meshlet path - meshlet_geometry_build_from_geometry(), a one-time precompute, GPU cluster culling, static residency, no continuous LOD. See Meshes & materials for that, and Rendering overview for where the visibility buffer that feeds VT feedback comes from.