ENGINE 07 · RENDERING PERFORMANCE

Spend work only where the world changed

Chunk.js keeps canonical world answers separate from presentation, then carries those answers as compact column profiles and sparse deltas. Dirty mesh revisions select local rebuilds, Workers prepare packed opaque and visual geometry, and the WebGL2 loop admits only a measured number of upload jobs per frame. This is inspectable scheduling and data movement, not a promised frame rate across every browser, GPU, view distance, or thermal state.

12 min read
The canonical villager boy repairs only a short damaged stretch of a baked stone road while the villager girl surveys the intact coastal route ahead through a brass spyglass; a wooden cart groups reusable grass, stone, and wood blocks beside detailed Chunk.js terrain, cotton, flowers, and reflective water.

Key points

Production play loads a narrow engine surface

NGINX serves the play shell and Chunk.js modules directly. The game imports an explicit play entry instead of the general barrel, so the terrain runtime does not evaluate the forge path merely to start the world.

One changed coordinate does not restart the world

Base state, chain deltas, pending deltas, dirty versions, completed meshes, and GPU uploads are tracked separately. A changed Chunk and only the boundary neighbors that can expose a face become rebuild candidates.

Budgets limit work, not outcomes

Compact typed arrays, Worker scheduling, visibility filters, DPR caps, and adaptive uploads reduce avoidable work. They do not create a universal memory ceiling or prove a particular FPS on hardware that has not been measured.

NATIVE MODULE ENTRY AND COLUMN STATE

Production loads a narrow engine surface, then carries a compact world

NGINX serves /play/ and /chunk.js/ from the production project as native ES modules. The play shell imports one explicit /chunk.js/play.js surface whose export list is intentionally narrower than the general Chunk.js barrel. That keeps the game-engine startup path from evaluating forge modules just because the player entered the world. Chain adapters may still load their own built asset, so this boundary describes the play renderer and world engine rather than every script on the page.

The manager's normal base state is a 16 by 16 column profile across a 353-voxel vertical envelope. Three two-byte arrays retain surface height, water height, and surface block. Sparse chain and pending deltas remain separate maps, and tree instances remain separate generated objects. The deterministic-world guide explains what those layers mean; this section measures how the renderer carries them without storing a default dense Uint16 copy of every base voxel.

Production engine entry /chunk.js/play.js

An explicit native-ESM export surface imported directly by play/main.js.

Default world envelope 16 x 16 x 353

Ninety thousand three hundred sixty-eight possible voxel positions per base Chunk.

Core profile arrays 1,536 bytes

Three two-byte arrays across 256 columns, before trees, deltas, maps, and caches.

VERSIONED INVALIDATION

A changed coordinate invalidates local work; it does not restart the world

ChunkState advances its state version when a visible block answer changes. The last committed opaque and visual mesh versions advance only when a complete replacement arrives. Rebuild selection filters for dirty Chunks that are neither already queued nor building, then orders them nearest to the player's center. A boundary edit also dirties only the loaded neighbors that could expose a face or tree proxy across that edge.

Worker results carry the task version and material version that produced them. If state moves again before a result arrives, the manager can keep any useful base profile, reject the stale mesh replacement, and leave the Chunk dirty for another build. The prior complete GPU mesh can remain drawable until its replacement upload succeeds, avoiding a blank region while the world catches up.

Rebuild candidate set Dirty only

Queued and in-flight Chunks are excluded from a second simultaneous build.

Dispatch order Nearest first

Remesh jobs receive an additional priority before distance and movement direction are considered.

Stale result guard Task and material versions

A completed job cannot overwrite newer world or material state merely because it finished later.

WORKER-PREFERRED MESHING

Workers prepare geometry while the frame keeps its own lane

Production computes the world-build pool as max(1,min(3,cores-1)) on a coarse-pointer device and max(1,min(6,cores-2)) otherwise. Before the first nearby terrain is visible, only one lane runs. After that, a large streaming backlog can use the full reserved pool; smaller workloads adapt to sampled FPS, motion, and sprinting. The initial queue is capped at the greater of 768 jobs or the full preload square.

Each Worker deterministically builds the base profile, tree instances, opaque mesh, and visual mesh. Final and neighbor deltas are packed as Int32 coordinate-and-block quadruples and transferred in; typed mesh and profile buffers are transferred out. Failed build jobs back off from 180 milliseconds up to five seconds. If Worker construction fails or all Workers die, the manager changes to synchronous generation and meshing on later rebuild calls.

The default mesh-loop budget is five milliseconds, with production query overrides clamped from two through fourteen. In Worker mode that timer bounds main-thread result draining and dispatch work, not CPU time already running inside Workers. In synchronous fallback the elapsed check happens after a complete Chunk job, so one expensive job can cross the requested budget.

Reserved Worker ceiling 3 coarse · 6 other

Actual count also depends on reported hardware concurrency and never falls below one when Workers are available.

Default mesh-loop budget 5 ms

A scheduling stop condition rather than a preemptive deadline for Worker or synchronous CPU work.

Retry delay 180 ms to 5 s

Per-Chunk exponential backoff after a Worker build failure.

VISIBLE SURFACES AND SHARED MATERIALS

Exposed faces become packed quads and one material array

The opaque fast path starts from the column profile, finds exposed tops and cliffs, accounts for sparse deltas, and groups face cells by orientation, plane, texture layer, block identity, and quantized shade. Greedy meshing expands each run into the largest available rectangle, so a uniform surface becomes one quad instead of one quad per block face. Water, cutout vegetation, projected shadows, and other visual geometry remain in a separate pass because they need different blending and depth behavior.

Every final vertex is packed into twenty bytes containing fixed-point position and scale, normalized signed-byte normal, shade, unsigned-short UVs, texture layer, and flags. Indices use two bytes until the packed mesh crosses the 65,535-vertex boundary. Those arrays go straight into STATIC_DRAW WebGL buffers with the same layout; collision queries do not retain a duplicate world collision mesh and instead resolve canonical terrain, deltas, tree trunks, and supplemental building collision separately.

Material tiles are generated deterministically from the material registry and texture seed. The current registry contains eighty-three materials with a highest layer index of 101, so the one-level 32 by 32 RGBA8 array allocates 102 layers, including nineteen gaps. Its raw texel payload is 417,792 bytes, or 408 KiB, before driver overhead. The shared array reduces texture switching, but opaque, visual, sky, avatar, shadow, particle, overlay, and building passes still create more than one draw call.

Packed vertex stride 20 bytes

The position, normal, shade, UV, layer, scale, and flags share one fixed layout.

Current texture array 102 x 32 x 32 RGBA8

One mip level with nearest filtering and repeat wrapping.

Raw array texels 417,792 bytes

Exact uploaded pixel payload for the current layer count, excluding WebGL driver metadata.

CAMERA AND GPU ADMISSION

The camera limits candidates before the GPU receives them

The production default view distance is seven Chunks with a two-Chunk preload margin. Player movement updates the loading center on a seventy-millisecond cadence when needed. ChunkManager first limits terrain by horizontal distance and a conservative camera cone, then preserves recently visible terrain for twelve frames to avoid edge flicker. The renderer applies its render-radius and cached frustum filter to the current candidate list before scanning for stale GPU versions.

A mesh is uploaded only when no GPU entry exists, its committed version changed, or its uploaded flag was cleared. Production then applies an adaptive job allowance: sampled FPS below 36 admits one upload, motion admits two through five depending on FPS and sprinting, and idle frames can admit more. Moving or an active build backlog postpones region rebatching, while a 650-millisecond pruning cadence deletes buffers for CPU Chunks that are no longer live.

The WebGL2 context disables multisample antialiasing and preserved drawing buffers and supplies the browser a high-performance preference. That preference is a hint, not a hardware guarantee. By default, canvas DPR is bounded from 0.75 through a 1.25 coarse-pointer cap or 1.75 other-device cap, reducing dense-display fragment work without changing world coordinates, chain inputs, or deterministic generation.

Production view setup 7 visible · 2 preload

The player can select a view distance from two through twenty.

Low-FPS upload allowance 1 job per frame

Applied when the sampled FPS falls below 36; a job may represent an individual Chunk or region.

Default DPR caps 1.25 coarse · 1.75 other

Upper bounds on the normal devicePixelRatio path, not fixed render scales for every device.

MEASUREMENT AND AUTHORITY BOUNDARY

A performance result needs a device, a window, and a trace

Chunk.js exposes sampled FPS, visible Chunk counts, draw calls, triangles, upload queues, build timings, and buffer bytes for the handles drawn in a frame. Its frame probe can identify long main-thread segments, and render logging records CPU-side time around WebGL calls. Neither mechanism is a GPU timer query, and the per-frame buffer statistic is not guaranteed to equal all resident driver memory.

A reproducible benchmark must state the production URL, browser and version, operating system, CPU and GPU, viewport, effective DPR, view distance, movement pattern, world position, warm-up, sample duration, and thermal state. Headless browser results can catch regressions in scheduling and page behavior, but they cannot prove target mobile GPU throughput, battery cost, or a universal frame rate.

The performance architecture intentionally ends at rendering inputs. Chunk.js can reconstruct deterministic base terrain and present pending or accepted deltas, but resource ownership, mining legality, backpack capacity, recipe matching, and chain finality are verified by their programs and adapters. Faster meshing cannot turn a locally invented block into authoritative state.

Frame-rate sample window 500 ms default

A rolling application signal used for adaptive scheduling, not a lab benchmark by itself.

Render call timing CPU-side

Time around JavaScript and WebGL calls, without a GPU timestamp query.

Performance promise None without a target device

The implementation exposes controls and evidence; measured outcomes still depend on the environment.

STATE, STREAMING, AND GPU MATH

Count bytes and jobs before making a performance claim

These equations measure the core column profile, the production streaming window, packed mesh buffers, and the frame's upload and pixel workload. JavaScript objects, tree instances, driver metadata, and unrelated render passes are deliberately excluded where the implementation cannot count them exactly.

Dense voxel storage versus the column profile

B_dense = 2*C^2*H; B_profile = C^2*(2+2+2) = 6*C^2; C=16, H=353 => B_dense=180736 B and B_profile=1536 B

A dense Uint16 base copy would retain every voxel in the full vertical envelope. The normal profile retains one Int16 surface height, one Int16 water height, and one Uint16 surface block per horizontal column, then regenerates subsurface answers as needed. The approximately 118-to-1 comparison covers only those core typed arrays; trees, sparse deltas, maps, caches, and JavaScript overhead remain additional state.

C
Chunk width and depth in voxels; the current default is sixteen.
H
Vertical envelope in voxels; the current default is 353 from minimum Y minus 32.
B_profile
Byte length of the three transferable column arrays, excluding every non-array object.

Production streaming squares

N_load = (2*(r+p)+1)^2; N_visible <= (2*r+1)^2; N_keep <= (2*(r+p+2)+1)^2; r=7, p=2 => 361, 225, 529

The production game initially ensures a square through view distance r plus preload margin p. The visible square is an upper bound before the horizontal camera-cone filter. Distance eviction allows a two-Chunk linger band, so the retained bound can exceed the newly ensured square while the player moves. These are geometric bounds, not a promise that every position already has a complete mesh or GPU buffer.

r
Production play view distance in Chunks; seven by default and selectable from two through twenty.
p
Production preload margin; currently two Chunks.
N_keep
Maximum square implied by distance retention, not a fixed allocation target.

Packed mesh buffer bytes

B_mesh = 20*V + s_i*I; s_i=2 when V<=65535, otherwise 4; B_chunk = B_opaque + B_visual

Each packed vertex occupies twenty bytes. Index buffers stay Uint16 until a mesh exceeds 65,535 vertices, then become Uint32. Opaque and visual passes own separate buffers, so a Chunk's exact transferable mesh payload is their sum. This excludes temporary meshing arrays, region-combine staging, VAO metadata, and driver allocation overhead.

V, I
Vertex and index counts for one packed opaque or visual mesh.
s_i
Index element width in bytes selected from the final vertex count.
B_chunk
Combined typed-array mesh bytes for both terrain passes of one Chunk.

Upload admission and display density

U_frame = min(b_frame, |J_changed intersect J_candidate|); P_canvas = W_css*H_css*d^2; d = min(devicePixelRatio, d_cap) by default

Only changed upload jobs inside the current filtered render-candidate set can consume the frame allowance. A job may be one individual Chunk or one combined region. Canvas pixels grow with the square of effective DPR, which is why the default path caps coarse-pointer devices at 1.25 and other devices at 1.75. An explicit renderer DPR override follows a separate path and is not covered by those default caps.

b_frame
Adaptive upload allowance for the current frame.
J_changed, J_candidate
Version-stale upload jobs and jobs admitted by the current render candidate filters.
d
Effective canvas device-pixel ratio on the default renderer path.

The normal base payload is three arrays per horizontal column

JavaScript chunk.js/world/world-generator.js
export function generateBaseChunkProfileFromConfig(config, chunkX, chunkZ, { cacheTreeCandidates = false } = {}) {
  const size = config.chunkSize;
  const cells = size * size;
  const surfaceY = new Int16Array(cells);
  const waterY = new Int16Array(cells);
  const surfaceBlock = new Uint16Array(cells);
  const treeCandidates = cacheTreeCandidates ? new Array(cells) : null;
  const noWater = Math.max(-32768, Math.min(32767, config.minY - 1));
  waterY.fill(noWater);

  for (let localZ = 0; localZ < size; localZ += 1) {
    for (let localX = 0; localX < size; localX += 1) {
      const { worldX, worldZ } = chunkToWorld(chunkX, chunkZ, localX, 0, localZ, size);
      const column = localX + localZ * size;
      const surface = terrainSurfaceHeight(config, worldX, worldZ);
      const water = waterLevelAt(config, worldX, worldZ, surface);
      surfaceY[column] = surface;
      waterY[column] = water ?? noWater;
      surfaceBlock[column] = surfaceBlockAt(config, worldX, worldZ, surface);
      const tree = treeCandidates
        ? (canGrowTree(config, worldX, worldZ, surface) ? treeAt(config, worldX, worldZ, surface, surfaceBlock[column]) : null)
        : undefined;
      if (treeCandidates) treeCandidates[column] = tree;
    }
  }

  const profile = {
    chunkX: Math.trunc(chunkX),
    chunkZ: Math.trunc(chunkZ),
    chunkSize: size,
    minY: config.minY,
    height: config.height,
    generationVersion: config.generationVersion,
    surfaceY,
    waterY,
    surfaceBlock,
    noWater,
  };
  if (treeCandidates) Object.defineProperty(profile, "treeCandidates", { value: treeCandidates, configurable: true });
  return profile;

The profile measures every XZ column once and retains the surface, water, and top material in compact typed arrays. The deterministic block resolver reconstructs deeper answers instead of making this profile a second dense copy of the world.

Only eligible dirty Chunks enter the rebuild loop

JavaScript chunk.js/chunk/chunk-manager.js
  rebuildDirtyChunks(budgetMs = DEFAULT_MESH_BUDGET_MS) {
    const start = performance.now();
    const rebuilt = this.drainCompletedBuilds();
    const dirty = Array.from(this.chunks.values())
      .filter((chunk) => chunk.dirty && chunk.buildState !== "queued" && chunk.buildState !== "building")
      .sort((a, b) => Math.max(Math.abs(a.chunkX - this.centerChunkX), Math.abs(a.chunkZ - this.centerChunkZ)) - Math.max(Math.abs(b.chunkX - this.centerChunkX), Math.abs(b.chunkZ - this.centerChunkZ)));
    for (const chunk of dirty) {
      if (this.useWorkers) {
        if (chunk.buildState === "error" && !this.prepareWorkerBuildRetry(chunk, start)) continue;
        if (chunk.baseBlocksReady) this.enqueueRemesh(chunk);
        else this.enqueueBuild(chunk);
        if (performance.now() - start >= budgetMs) break;
        continue;
      }
      if (!chunk.baseBlocksReady) this.ensureChunkBaseSync(chunk);
      const access = {
        getBlockAtWorld: (x, y, z) => this.getBlockAtWorld(x, y, z),
        getDeltaAtWorld: (x, y, z) => this.getDeltaAtWorld(x, y, z),
        getColumnTopAtWorld: (x, z) => this.getOpaqueColumnTopAtWorld(x, z),
        getWaterLevelAtWorld: (x, z, surface = terrainSurfaceHeight(this.config, x, z)) => waterLevelAt(this.config, x, z, surface),
        treeDeltaCandidateCount: this.treeDeltaCandidateCountForChunk(chunk.chunkX, chunk.chunkZ),
      };
      const meshStartedAt = performance.now();
      const mesh = meshChunkOpaqueFast(chunk, access);
      const opaqueMeshMs = performance.now() - meshStartedAt;
      const visualStartedAt = performance.now();
      const visualMesh = meshChunkVisual(chunk, access);
      const visualMeshMs = performance.now() - visualStartedAt;
      chunk.setMeshes(mesh, visualMesh);
      this.logRenderEvent("chunk-build-sync", {
        chunkId: chunk.id,
        chunkX: chunk.chunkX,
        chunkZ: chunk.chunkZ,
        elapsedMs: opaqueMeshMs + visualMeshMs,
        opaqueMeshMs,
        visualMeshMs,
        triangles: (mesh?.triangleCount || 0) + (visualMesh?.triangleCount || 0),
        bytes: (mesh?.vertices?.byteLength || 0) + (mesh?.indices?.byteLength || 0) + (visualMesh?.vertices?.byteLength || 0) + (visualMesh?.indices?.byteLength || 0),
      });
      rebuilt.push(chunk);
      if (performance.now() - start >= budgetMs) break;
    }
    this.lastRebuildMs = performance.now() - start;
    this.dispatchBuilds();
    return rebuilt;
  }

Worker mode spends the loop budget draining results and scheduling dirty work; Worker CPU time itself is outside that main-thread timer. In fallback mode one synchronous opaque-plus-visual job runs before the elapsed check, so the five-millisecond default is a stopping policy, not a hard preemption deadline.

Completed buffers transfer back without cloning their payloads

JavaScript chunk.js/chunk/chunk-build-worker.js
function transferablesForBuild(message) {
  const transfer = [message.mesh.vertices.buffer, message.mesh.indices.buffer];
  if (message.visualMesh) transfer.push(message.visualMesh.vertices.buffer, message.visualMesh.indices.buffer);
  const profile = message.baseProfile;
  if (profile) {
    for (const field of ["surfaceY", "waterY", "surfaceBlock"]) {
      if (profile[field]?.buffer) transfer.push(profile[field].buffer);
    }
  }
  return transfer;
}

Opaque, visual, and base-profile ArrayBuffers move through the Worker transfer list. Sparse final and neighbor deltas travel the other direction as transferred Int32 quadruples, keeping large typed payloads out of structured-clone copies.

One deterministic texture array serves every terrain layer

JavaScript chunk.js/renderer/texture-array-manager.js
  createTextureArray(materialTextures = null) {
    const gl = this.gl;
    this.texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.texture);
    gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, this.tileSize, this.tileSize, this.layerCount);
    const blank = new Uint8Array(this.tileSize * this.tileSize * 4);
    for (let layer = 0; layer < this.layerCount; layer += 1) {
      const material = this.materials.find((entry) => entry.textureLayer === layer);
      const pixels = materialTextures?.[layer] ?? (material ? this.generateMaterialTile(material) : blank);
      gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, layer, this.tileSize, this.tileSize, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
    }
    gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT);
    gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT);
    gl.bindTexture(gl.TEXTURE_2D_ARRAY, null);
    return this.texture;

Renderer initialization allocates one one-level RGBA8 array and uploads every deterministic material tile into its layer. This reduces runtime material rebinding; it does not mean zero texture uploads or one draw call for the entire scene.

IMPLEMENTATION EVIDENCE

Where these claims come from

Each claim is intentionally scoped to a concrete implementation path. These references are for verification, not decoration.

nginx.site.conf

Serves production /play/ and /chunk.js/ directly from the project with explicit native-module routes.

play/index.html

Loads the production game through the native ./main.js module entry.

chunk.js/play.js

Defines the explicit play-only Chunk.js export surface and avoids evaluating the general barrel's forge path during startup.

play/main.js

Wires the production view distance, preload margin, Worker pool, mesh budget, WebGL2 renderer, visibility pass, upload budget, and periodic GPU pruning.

play/play-render-budget.js

Selects adaptive Worker concurrency, queue size, per-frame upload jobs, loading cadence, region deferral, and GPU pruning cadence.

chunk.js/world/world-generator.js

Builds compact surface, water, and surface-material column profiles and deterministic base answers.

chunk.js/chunk/chunk-state.js

Separates base profiles, sparse chain and pending deltas, state versions, committed mesh versions, build state, and upload flags.

chunk.js/chunk/chunk-manager.js

Owns streaming squares, distance eviction, camera-cone visibility, dirty selection, boundary invalidation, queue priority, Worker retries, fallback, and packed delta transfer.

chunk.js/chunk/chunk-build-worker.js

Generates base profiles, tree instances, opaque and visual meshes in a Worker and returns typed arrays through transfer lists.

chunk.js/chunk/chunk-mesher.js

Implements profile-aware opaque meshing, visual geometry, greedy rectangles, twenty-byte packed vertices, and adaptive index width.

chunk.js/renderer/texture-array-manager.js

Bakes deterministic material tiles into one RGBA8 Texture2DArray with a bounded generated-tile cache.

chunk.js/renderer/buffer-manager.js

Uploads the packed vertex layout and selected index type into WebGL2 buffers and disposes replaced handles.

chunk.js/renderer/webgl2-renderer.js

Configures WebGL2, DPR caps, texture-array initialization, version-aware uploads, region batching, frustum filtering, draw passes, and GPU buffer disposal.

chunk.js/renderer/frustum.js

Defines the conservative camera-frustum filter used before render candidate uploads and draws.

chunk.js/debug/stats.js

Samples frame rate for runtime status and adaptive scheduling.

play/play-frame-probe.js

Measures long main-thread frame segments and throttles diagnostic reports.