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 = false over container.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 zIndex only where layering is required. Mixed zIndex values 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 full commit()).
Avoid creating new Texture instances per frame — reuse one texture and update its source.
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:
import { Color, Container, Graphics, Rectangle } from '@codexo/exojs';// 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 = Color.slateGray; 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:
cullArea replaces getBounds() only in the inView() check — it has no effect on hit-testing, collision, or rendering, and it’s ignored entirely when cullable = false.
Because the rectangle is used as-is (not transformed), a cullArea on 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.
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.
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.