Skip to content

Performance Systems

Awaken keeps big scenes fast with a stack of complementary systems - spatial culling, cached shadows, static batching, distance culling, optional GPU-driven culling, texture compression, MSAA, and render scale - most of them controlled from Render Settings.

The cull → draw path

Every frame the renderer turns the whole scene into a small set of draw calls. The persistent scene cache holds one GPU instance record per object; culling picks which are visible, splits them into instanced groups and merged static chunks, and packs a visible→slot buffer the vertex shader indexes.

Octree frustum culling - always on

Drawables are indexed in a bounded octree (packages/render/src/octree.ts) built once per cache rebuild from world bounding spheres. Each frame the octree is queried against the camera frustum (and any dirty shadow cascades and the preview camera) in one traversal: a node fully inside the frustum contributes its whole subtree with no per-item test, a node fully outside is skipped entirely, and only straddling nodes test their own items. This keeps culling sublinear as object counts grow.

This is not optional and needs no setting - it is how the renderer decides what to draw at all. If only transforms moved (nothing added/removed), the octree is cheaply refit instead of the cache being fully rebuilt.

Cached static shadows - opt-in, on by default

Re-drawing every shadow caster into every cascade each frame is usually the dominant cost. Awaken caches the static-caster depth per cascade and re-bakes only when a cascade is dirty (sun moved, geometry changed, or the texel-snapped region shifted). Covered in full on Shadows. Setting: Shadow Cache (default on).

Static batching (index-merge) - opt-in, on by default

Static scenery whose mesh is used by few objects is merged per spatial cell + texture into single draw calls. Geometry stays local; each merged vertex carries an objIndex into a stable per-object transform buffer, so moving an object is a one-slot write, not a re-merge (only adding, removing, or crossing a cell boundary rebuilds a chunk). Opaque and transparent objects never share a chunk, which preserves alpha draw order. Heavily-repeated meshes stay on the instanced path (instancing already collapses them). This is the biggest draw-call win on scenes assembled from many distinct props. Full detail on Static Batching.

Settings: Static Batching (on), Merge Max Uses (merge a mesh used by ≤ N static objects, default 8), and Merge Budget (millions of merged verts before falling back to per-object draws, default 8M).

Size-aware distance culling - opt-in, off by default

An object's on-screen size is roughly radius / distance, so Awaken can cull an object once it is too small to matter: it drops any object whose distance from the eye exceeds radius × drawDistanceScale (packages/render/src/distanceCull.ts). Because the threshold scales with each object's own bounding-sphere radius, big things stay visible far away while small clutter culls close - automatically, with no per-object tuning.

It is pure float math with no GPU features, so it runs on the weakest phone (mobile just uses a smaller scale). It runs before packing and applies to the main camera pass only; shadows keep their own shadowDistance. It removes the cost of distant clutter rather than swapping in cheaper geometry, so it is what stands in for content that has no authored LOD levels.

Setting: Draw Distance Scale - 0 = off (keep everything), lower = more aggressive.

NOTE

At aggressive scales, a modular building assembled from many small parts can pop apart as its parts cross the threshold at slightly different distances. That is inherent to per-object culling of small parts; HLOD would be the proper fix.

LOD groups - per object, authored or imported

A LodGroup component swaps an object's mesh for a cheaper one as the camera pulls away. meshes is a comma-separated list of mesh asset ids, highest detail first; distances is the ascending list of distances past which each next level takes over, so it has one fewer entry than meshes.

The object keeps one MeshRenderer throughout - only the mesh id changes - so materials, colliders and everything else stay put. A switch only fires once the camera has moved a further 10% past a boundary, so an object sitting exactly on one does not flicker between two meshes.

This is how a source engine's LOD group imports. Such a group lists one mesh per level and shows exactly one at a time, so importing every level as visible geometry stacked two or three near-identical meshes in the same place: z-fighting, doubled darkening, and two to three times the triangles. The import now keeps the highest-detail level on the renderer and hands the rest to this component.

Awaken does not generate levels: there is no runtime decimation and no HLOD. See Limitations.

Depth pre-pass - opt-in, off by default

An opaque depth pre-pass runs before shading, so early-Z rejects occluded fragments and the lit shader runs roughly once per visible pixel. That is a win on dense, high-overdraw scenes and a small net cost on trivial ones, which is why it is off by default. Setting: Depth Prepass.

Clustered lighting (Forward+) - opt-in, off by default

Binning lights into a screen-tile x depth-slice grid on the GPU removes the fixed per-fragment light budget: each fragment shades only the lights in its own cluster, up to 32. Without it the shader loops the most important 16 lights. Setting: Clustered Lighting; see PBR & Lighting.

GPU-driven culling - opt-in, capability-gated

This is an advanced path. When enabled and the device supports the indirect-first-instance feature, a compute kernel (CULL_WGSL) frustum-culls every object for the opaque main pass and writes compacted drawIndexedIndirect args, so the CPU does zero per-object culling for that pass. Shadows, the transparent pass, and picking stay on the verified CPU path (transparent stays CPU because indirect instance order is non-deterministic, which would flash order-dependent alpha blending).

It is off by default; the CPU path is the fallback and is what ships. Note that some developer GPUs (and browsers) don't expose indirect-first-instance at all - on those, the flag is a silent no-op and the scene renders through the CPU path unchanged. Setting: GPU Culling.

BC texture compression - opt-in, on by default, capability-gated

When the device supports texture-compression-bc, textures are block-compressed at upload per role (packages/render/src/bc.ts), cutting VRAM 4–8×:

RoleFormatRatioUse
Albedo (opaque)BC1Colour, no alpha
Albedo (alpha)BC3Colour + alpha
Mask (1 channel)BC4Single-channel masks (roughness/metallic/AO/height)
Normal (2 channels)BC5Tangent-space normals (X,Y; Z reconstructed in-shader)

Encoding uses bounding-box endpoints - a solid first-pass quality; edge blocks replicate the last texel, so non-multiple-of-4 dimensions are fine. Textures fall back to uncompressed RGBA8 when the feature is absent, so nothing breaks - you just pay full VRAM. Compression is chosen at load per-device rather than baked at import, which keeps the shipped file device-agnostic. The stats overlay reports real post-compression VRAM.

MSAA - anti-aliasing vs. fill cost

The main scene pass renders into a multisample colour target and resolves into the scene texture. MSAA Samples is 4 (best edges, the default), 2, or 1 (off). Turning MSAA off is the single biggest GPU-fill saving on heavy scenes, at the cost of jagged edges. Changing it rebuilds only the MSAA-dependent pipelines.

Render scale - internal resolution

Render Scale (0.251) multiplies the internal render resolution; the browser upscales the result to the canvas. Below 1 it is a large GPU-fill win for a softer image - the go-to knob for hitting frame rate on low-end GPUs or high-DPI displays. It is applied in the GPU context resize (backing size = client px × device pixel ratio × render scale).

Choosing settings

SituationReach for
Many distinct static props, high draw callsStatic Batching (raise Merge Max Uses / Budget)
Open world, distant clutterDraw Distance Scale > 0
Fill-bound on a weak GPU / 4KRender Scale < 1, MSAA off
Lots of shadow draw callsKeep Shadow Cache on; lower Shadow Distance or Cascade Count
Huge object counts, capable GPUTry GPU Culling
Texture VRAM pressureKeep BC compression on
Heavy overdraw (dense foliage, interiors)Depth Prepass
More than 16 visible lightsClustered Lighting
Imported content with authored LOD levelsNothing - the LodGroup component switches them

Read the per-pass GPU ms and draw-call counts in the stats overlay first - optimize the pass that is actually slow.

📸 Screenshot - save as img/render-performance-stats.png

The stats overlay open on a heavy scene, showing draw calls, instances, static batches, shadow draw calls, distance-culled count, and per-pass GPU ms.

See also

Awaken — browser-native WebGPU game engine.