Rendering

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:

LayerFormatContent
0rgba8unorm-srgbalbedo (premultiplied on upload)
1rgba8unormtangent-space normal
2rgba8unormORM: 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()

OptionDefaultMeaning
sizerequiredSide 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).
sourcerequiredTile provider - see below.
label""Name used in error messages.
uv_set0Which 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.
wraptruetrue 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

SourceConstructorUse for
VTSourceTilednew 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.
VTSourceImagenew 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.
VTSourceProceduralnew 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:

MemberRequiredContract
layer_maskyesBitmask of layers the source provides, bit 0 = albedo. Defaults to 0b111 if absent.
load_page(mip, x, y, signal)yesResolves 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)noCalled 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()noCalled 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_mode must be TransparencyMode.Opaque (the default). The alpha-tested visibility pass and the OIT path sample the regular albedo texture, where VT residency is not guaranteed, so setting vt_stack on 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 0 and 1023 reserved - and the shared page table is a finite address space besides. Registration throws a plain Error (vt: page table is full or vt: stack budget exhausted) when either runs out.

The residency loop, and what it costs

visibility buffer gives mesh and triangle per pixelvt_feedback compute samples one pixel per 4x4 blockGPU hash set collects unique page requestsasync readback lands one or two frames laterprocess_feedback touches resident pages and queues missesupdate uploads arrivals and evicts by LRUpage table and atlases feed the next material pass

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:

CostDefaultNote
Physical cache~64 MB per layer, ~192 MB for the standard threeOne 4096² atlas per layer, 30×30 = 900 page slots
Page table~5 MBOne mip-mapped 1024² r32uint texture, shared by every stack
Feedback buffer64 KBPlus one compute dispatch over the primary view per frame
Upload ceiling8 pages per frameA page is 136² texels per layer, so ~217 KB across the three layers, ~1.7 MB per frame at full budget
Network concurrency32 in-flight loadsQueue capped at 1/3 of cache capacity; stale entries dropped
Latency to sharp1-2 frames of feedback, plus the fetchThe 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
});
KeyDefaultMeaning
page_size128Payload texels per page side
border4Border texels per side, baked into tiles - slot size is 136² at the defaults
atlas_size4096Physical cache side per layer; capacity is floor(atlas_size / slot_size)²
table_size1024Shared page-table side, in pages. At the default page size this addresses 256 stacks of 8192² or 4 of the maximum 65536²
layersalbedo / normal / ormCache group: name, format, premultiply flag
hash_capacity16384Unique requests the feedback hash set can hold per frame
feedback_stride4Sample one pixel per stride² block, jittered. Runtime-adjustable
max_uploads_per_frame8Page-upload budget
max_concurrent_loads32In-flight source loads
request_cap_fraction1/3Pending requests capped at this fraction of cache capacity
eviction_protect_window4Frames a touched page is unevictable, covering read-back latency
queue_stale_window30Untouched queued requests dropped after this many frames
pressure_high_watermark0.9Residency fraction at which the mip bias engages
pressure_low_watermark0.75Residency 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 - and nothing else. They are separate subsystems with separate file formats, they do not depend on each other, and only virtual texturing is integrated into the main renderer’s automatic streaming path.

Virtual texturingVirtual geometry
Streamstexels: albedo, normal, ORM pagesclusters of triangles with their LOD hierarchy
Opt-inmaterial.vt_stackexplicit container reader; prototype viewer
Formatordinary image tiles, or none for VTSourceImage.vgeo container, one per geometry
Status in 3.21.0shippingbuild, validation, incremental reading and a prototype viewer

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. The prototype in shade/playground/vgeo_viewer/ selects a cut for the camera, requests missing pages and draws it through Shade’s direct geometry path. Automatic virtual-geometry streaming is not integrated into the main meshlet renderer.

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, nominal_page_size is 256 KB, each level halves the triangle count (simplification_ratio 0.5) in groups of 4 clusters, and weld_ratio is 1e-5 (the source’s own advice is to leave it alone).

The main mesh renderer uses 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.