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. This is Awaken's substitute for the missing runtime LOD/HLOD system - it removes the cost of distant clutter rather than swapping in cheaper geometry.

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.

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

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.