Guide

Guide Debugging & Performance Backend comparison

Backend comparison

Compare backend behavior and decide what to ship.

Advanced ~4 min read

Backend comparison

ExoJS renders through one of two backends: WebGL2 or WebGPU. The choice is automatic by default — the engine picks WebGPU when navigator.gpu is available, WebGL2 otherwise. You can override this with backend: { type: 'webgpu' } or backend: { type: 'webgl2' } in ApplicationOptions.

Selection

import { Application } from '@codexo/exojs';

// Auto-select (prefers WebGPU, falls back to WebGL2)
const app = new Application();

// Pin to WebGPU — throws if unavailable
const gpuApp = new Application({ backend: { type: 'webgpu' } });

// Pin to WebGL2
const glApp = new Application({ backend: { type: 'webgl2' } });

At any point, check app.backend.backendType (a RenderBackendType enum with values WebGl2 and WebGpu) to branch backend-specific code.

Feature parity

The core rendering pipeline — sprites, meshes, graphics, text, containers, masks, filters, render-targets, views, culling — works identically on both backends. The API is the same. You write one scene and it renders on both.

Where differences exist, they are performance characteristics or rendering-path specifics, not API gaps:

AreaWebGL2WebGPU
Sprite batchingMulti-texture batched (up to 8)Multi-texture batched (up to 8)
Particle simulationCPUCPU (default); optional GPU compute update path when all update modules implement wgsl()
MeshMaterialGLSL ES 3.00WGSL
ShaderFilterWebGl2ShaderFilter (GLSL)WebGpuShaderFilter (WGSL)
GPU computeNot availableRaw backend.device access for compute + custom pipelines
Engine-emitted debug pass labelsNot currently emittedEmitted on key passes (e.g. WebGpuShaderFilter pass)

Both backends batch sprites from up to 8 different textures into a single draw call. For most projects with a single texture atlas, the batching difference is irrelevant — the practical distinction is only visible in multi-atlas scenes.

Geometric clip parity is also aligned: RenderNode.clip with a Geometry clipShape works on both backends for Sprite (default/custom material), Mesh (default/custom material), Graphics, Text / BitmapText, and ParticleSystem. Rectangle / bounds clips remain on the scissor path.

Particles and GPU

Particle simulation (spawn, integration, module updates) runs on the CPU by default on both backends. On WebGPU, when every registered update module is GPU-eligible (implements wgsl()), the system compiles a composite WGSL compute shader and runs the full update pipeline on the GPU in one dispatch. This is the GPU path: simulation moves to the GPU, and the renderer reads instance data from a shared buffer — no CPU readback.

On WebGL2, or when any update module lacks wgsl(), the system runs on CPU. The particle rendering path (instanced draw calls) is the same on both backends. Check system.gpuMode to know which path is active.

Custom shaders

A ShaderSource accepts both glsl: { vertex, fragment } and wgsl source; wrap it in a MeshMaterial to bind uniforms and attach it to a Mesh. The renderer picks the appropriate language for the active backend. Provide both for cross-backend portability. ShaderSource.detectUniformDrift() compares declared uniforms across languages for CI-style verification.

For screen-space effects, use WebGl2ShaderFilter on WebGL2 and WebGpuShaderFilter on WebGPU. Both accept the same uniform-value types — only the shader language differs. For cross-backend filters, construct the appropriate variant based on app.backend.backendType at init time.

Direct GPU access

The WebGPU backend exposes backend.device (GPUDevice), backend.context (GPUCanvasContext), and backend.format (GPUTextureFormat). You can create custom pipelines, vertex buffers, and command encoders directly — the custom-triangle-renderer example demonstrates this. On WebGL2, backend.context is also publicly available as a WebGL2RenderingContext, but the direct compute-style escape hatch exists only on WebGPU.

Device loss

Both backends handle loss events. On WebGL2, the engine attempts context restore automatically. On WebGPU, ExoJS also attempts automatic device recovery and emits onBackendLost / onBackendRestored on the Application.

Choosing for production

  • Ship with auto (the default). Most users get WebGPU, older browsers get WebGL2. You write one codebase, both paths work.
  • Pin to webgpu if you depend on features only available through direct backend access (compute shaders, raw GPU pipelines) and can accept the browser-support trade-off.
  • Pin to webgl2 if you are targeting a specific environment where WebGPU is unreliable or unavailable. This is uncommon — auto-selection covers this case.

The backend-comparison example lets you toggle backends at runtime (press B) to compare performance and visual output with the same scene.

Examples

Backend Comparison Keyboard Open in Playground View source
import { Application, Color, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs';
import { DebugOverlay } from '@codexo/exojs/debug';

const options = {
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit' as const,
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
};

let app: Application | null = null;
let overlay: DebugOverlay | null = null;
let backendType: 'webgl2' | 'webgpu' = 'webgpu';

class DemoScene extends Scene {
    private sprites!: { sprite: Sprite; vx: number; vy: number }[];

    override async load(loader): Promise<void> {
        await loader.load(Texture, { bunny: 'image/ship-a.png' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.sprites = Array.from({ length: 2200 }, () => {
            const sprite = new Sprite(loader.get(Texture, 'bunny'));
            sprite.setAnchor(0.5);
            sprite.setScale(0.35);
            sprite.setPosition(Math.random() * width, Math.random() * height);
            return {
                sprite,
                vx: (Math.random() - 0.5) * 180,
                vy: (Math.random() - 0.5) * 180,
            };
        });
        this.inputs.onTrigger(Keyboard.B, () => {
            backendType = backendType === 'webgpu' ? 'webgl2' : 'webgpu';
            boot(backendType);
        });
    }

    override update(delta): void {
        const { width, height } = this.app.canvas;
        for (const item of this.sprites) {
            item.sprite.move(item.vx * delta.seconds, item.vy * delta.seconds);
            if (item.sprite.position.x < 0 || item.sprite.position.x > width) item.vx *= -1;
            if (item.sprite.position.y < 0 || item.sprite.position.y > height) item.vy *= -1;
        }
    }

    override draw(context): void {
        context.backend.clear();
        for (const { sprite } of this.sprites) context.render(sprite);
    }
}

const boot = (type: 'webgl2' | 'webgpu'): void => {
    if (overlay !== null) {
        overlay.destroy();
        overlay = null;
    }
    if (app !== null) {
        app.destroy();
        app.canvas.remove();
        app = null;
    }
    app = new Application({ ...options, backend: { type } });
    overlay = new DebugOverlay(app);
    overlay.layers.performance.visible = true;
    void app.start(new DemoScene());
};

boot(backendType);

2200 bouncing sprites with a performance overlay. Press B to toggle between WebGL2 and WebGPU backends and compare frame rates.

Where to go next

The next chapter, Custom renderers, covers extending the render pipeline with your own passes — no-op passes, full-screen triangles, and bridging a custom renderer into the engine’s frame.