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 { Application } from '@codexo/exojs';
import { DebugOverlay } from '@codexo/exojs/debug';
const app = new Application();
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.
Change one thing, then measure again
Isolate a single change per measurement — stacking several optimizations at once hides which one actually helped, or masks one that made things worse. Reach for the overlay before you edit, not after a hunch.
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 { Application } from '@codexo/exojs';
import { RenderPassInspectorLayer } from '@codexo/exojs/debug';
const app = new Application();
const inspector = new RenderPassInspectorLayer(app);
inspector.visible = true;
The two highest-impact reductions: remove filters you don’t need, and set cacheAsTexture = 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
Pool and toggle instead of rebuilding
For scenes that spawn and retire objects constantly — bullets, hit sparks, floating damage numbers — keep a fixed pool sized to your peak concurrent count and recycle nodes by flipping visible, rather than constructing and destroying them each frame.
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.
Per-frame new Color() / new Vector() feeds the GC
Allocating a fresh Color, Vector, or Matrix inside update or draw runs every frame and hands the garbage collector a steady stream of short-lived objects, which surfaces as periodic frame-time spikes. Allocate once and mutate in place (color.set(...), vec.set(...)) on hot paths.
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.
Overriding the cull check with cullArea
The automatic check calls getBounds(), which for a Container walks every visible child and unions their bounds — for a complex Graphics node or a container with hundreds of children, that walk runs every frame even when the node’s on-screen footprint is trivial to reason about. Set cullArea to a Rectangle and the cull check uses it directly instead of computing bounds:
// A debris field built from hundreds of small Graphics children - walking
// all of them for getBounds() every frame is wasted work once we already
// know the field never exceeds a 200x200 footprint at its spawn position.
const debris = new Container();
for (let i = 0; i < 300; i++) {
const shard = new Graphics();
shard.fillColor = new Color(0x708090);
shard.drawCircle(0, 0, 2);
shard.x = Math.random() * 200;
shard.y = Math.random() * 200;
debris.addChild(shard);
}
debris.x = 400;
debris.y = 300;
// cullArea is compared directly against the view's bounds, in the same
// world-space coordinates getBounds() would otherwise produce - it is not
// re-transformed by the node's own position/scale/rotation. Since `debris`
// is positioned once and never moves, a static rectangle here stays correct.
debris.cullArea = new Rectangle(debris.x, debris.y, 200, 200);Two things to keep in mind:
cullableandcullArealive onRenderNode, so every node that draws something has them; a bareSceneNodeis structural only, never reaches the renderer, and carries neither.cullAreareplacesgetBounds()only in theinView()check — it has no effect on hit-testing, collision, or rendering, and it’s ignored entirely whencullable = false.- Because the rectangle is used as-is (not transformed), a
cullAreaon a node that moves, rotates, or scales after it’s set will go stale. Either recompute it when the node’s position changes, or reserve it for nodes whose world placement is fixed once configured — e.g. static level decoration, or particle-heavy effects anchored to a known spawn point.
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.
The engine’s own reproducible stress benchmarks live with @codexo/exojs-bench, whose README documents the methodology behind them — median vs. p95, warmup and timed frames, what each metric does and does not cover. Useful as a reference for measuring honestly; the numbers that decide your game still come from the PerformanceLayer on your own scene.
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
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.


