Performance
Measure scene limits with focused stress examples.
Performance
Performance in ExoJS is about three things: how many drawables you push per frame, how many render passes they cost, and how much state change happens between draws. The engine does a lot automatically — batching sprites that share a texture, reusing render-target memory across filter passes, culling nodes outside the view — but the decisions that matter most are yours: how many sprites, how many textures, how many filters, how many particle systems.
Measuring with the performance layer
Before making changes, measure. The PerformanceLayer from @codexo/exojs/debug gives you FPS, frame time, draw-call count, and node count in real time:
import { DebugOverlay } from '@codexo/exojs/debug';
const debug = new DebugOverlay(app);
debug.layers.performance.visible = true;
Watch the FPS number while you add sprites, enable filters, or spawn particles. The sparkline shows frame-time stability — a flat line is good, spikes indicate intermittent work. The draw-call count tells you how many GPU-level draw calls the renderer issued this frame.
Sprite batching
Sprites that share the same texture are batched into a single draw call on WebGPU backends. On WebGL2, single-texture batching also applies. The practical consequence: 600 sprites all using one texture atlas cost far less than 600 sprites each using a different texture.
The multi-texture-stress example demonstrates this — a grid of sprites spread across 4 textures vs. a grid using 1 texture. The draw-call count difference is visible in the performance layer.
For texture-atlas workflows, use a single Spritesheet-sliced Texture and select frames via sprite.setTextureFrame() rather than loading separate images per sprite.
Render passes
Every filter on a drawable adds one render pass for that drawable. A container with three filters costs three passes beyond the main batch draw. The cost scales with the drawable’s bounding-box area (in pixels), not the canvas size, so occluding a filtered sprite behind a solid overlay doesn’t prevent the filter passes from running.
The RenderPassInspectorLayer shows exactly who is adding passes. Enable it during development when frame time rises and you suspect filter overhead:
import { RenderPassInspectorLayer } from '@codexo/exojs/debug';
// See 7.6 Render pipeline debugging for setup
The two highest-impact reductions: remove filters you don’t need, and set cacheAsBitmap = true on filtered containers that don’t change every frame. For the full set of strategies with examples, see Render pipeline debugging.
Particle system throughput
The ParticleSystem (from @codexo/exojs-particles) is designed for throughput — SoA storage, no per-particle allocations, instanced rendering. The practical limits depend on whether you’re on CPU or GPU path:
- CPU path (WebGL2, or any non-GPU-eligible update module): Suitable for moderate particle counts — performance drops roughly linearly with particle count. The per-frame update cost is proportional to
aliveCount× number of update modules. - GPU path (WebGPU, all modules GPU-eligible): The composite WGSL compute shader runs update logic on the GPU in one dispatch, writing directly into the renderer’s instance buffer — no CPU readback. This path becomes attractive for large systems with many particles and GPU-eligible modules.
Check system.gpuMode at runtime to know which path is active. Measure with the PerformanceLayer to find the right capacity target for your scene — the optimal number depends on your module count, particle lifetime, and target frame rate.
Scene-graph churn
Adding and removing many children from a container each frame triggers transform-dirty propagation and bounds recomputation. For dynamic scenes where objects appear and disappear frequently, prefer:
sprite.visible = falseovercontainer.removeChild(sprite)— keeps the node in the tree, skips rendering.- Pre-allocate a pool of sprites and recycle them by toggling visibility rather than constructing/destroying.
- Use
zIndexonly where layering is required. MixedzIndexvalues add per-group sorting work during render-plan optimization.
Texture updates
Updating a texture source (e.g. a canvas, video frame, or DataTexture buffer) incurs GPU upload work each frame, and may also trigger GPU re-allocation when dimensions/format change. For per-frame updates:
- Use
DataTexture.commitRect()for partial uploads (cheaper than fullcommit()). - Avoid creating new
Textureinstances per frame — reuse one texture and update its source. - Keep
RenderTexturedimensions stable —resize()triggers framebuffer recreation.
View culling
Nodes that fall entirely outside the current View’s viewport are skipped by the renderer. This is automatic and per-node. The culling check is AABB-based rather than exact shape, so nodes near the viewport edge may still be rendered even if partially outside. For large scrolling worlds, this is a significant performance multiplier — only the visible subset of your scene graph incurs draw cost.
Not a checklist
The most useful performance practice is measurement. Turn on the performance layer, watch the numbers, change one thing, measure again. The engine’s behavior under your specific scene — your sprite counts, your texture layout, your filter stacks — matters more than any general guideline.
Examples
A 34×20 grid of sprites (680 total) from a single texture atlas, each with animated position, scale, and rotation. Demonstrates sprite batching throughput.
Three particle systems emitting 240–320 particles per second each, with custom tint-cycling, scale-over-lifetime, alpha-fade, and force-based velocity. Demonstrates particle system throughput with a non-GPU-eligible custom update module.
Try it
Playground
API
Where to go next
The next chapter, Backend comparison, covers the WebGL2 vs. WebGPU decision — how backends are selected, where feature parity exists, and where backend-specific differences remain.