Guide

GuideDebugging & PerformanceBackend comparison

Backend comparison

Compare backend behavior and decide what to ship.

Advanced~7 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.

What has been measured

That claim is easy to make and hard to keep, so a conformance suite renders the same scenes through both backends in each browser and compares the frames pixel by pixel. The table below reports what it found — not what we believe.

Verified rendering behaviour by browser — 14 of 16 features measured, last on 2026-09-22.Guaranteed as of v0.18.0.
FeatureChromiumFirefoxSafari / WebKit
SpriteTextured quads, the workhorse primitive.GL✔GPU✔GL✔GPU✔GL✕GPU✕
NineSliceNine-quad scaling that keeps corners intact.GL✔GPU✔GL✔GPU✔GL✔GPU✕
RepeatingSpriteTiled fills via UV wrapping.GL✔GPU✔GL✔GPU✔GL✕GPU✕
MeshArbitrary triangle geometry with per-vertex UVs.GL✔GPU✔GL✔GPU✔GL✔GPU✔
TransformPosition, rotation, scale, and parent composition.GL✔GPU✔GL✔GPU✔GL✔GPU✕
TextSDF and bitmap glyph rendering.GL✔GPU✔GL✔GPU✔GL✔GPU✔
GraphicsFilled and stroked vector primitives.GL✔GPU✔GL✔GPU✔GL✔GPU✕
TilemapChunked tile layers from the tilemap package.GL✔GPU✔GL✔GPU✔GL✕GPU✕
ParticlesCPU and GPU particle systems.GL✔GPU✔GL✔GPU✔GL✔GPU✔
LightingLights, lit materials and normal mapping from the lighting package.GL✔GPU✔GL✔GPU✔GL✔GPU✔
RenderTextureRendering into a texture and sampling it back.GL?GPU?GL?GPU?GL?GPU?
MaskStencil and alpha clipping.GL✔GPU✔GL✔GPU✔GL✔GPU✔
FilterPost-processing passes over a rendered region.GL✔GPU✔GL✔GPU✔GL✕GPU✕
BlendModeSeparable and backdrop-aware blending.GL✔GPU✔GL✔GPU✔GL✔GPU✔
TintPer-node colour modulation.GL✔GPU✔GL✔GPU✔GL✔GPU✔
VideoVideo frames uploaded as textures.GL?GPU?GL?GPU?GL?GPU?
✔ verified✕ backends differ– backend absent in this browser? not measured

Read it as evidence, not as a support promise. A ? means nobody has measured that combination yet; it is not a statement that the feature is broken, and it is deliberately visible rather than hidden. The scenes use a texture whose every texel encodes its own coordinates, so a matching frame proves the right texel landed in the right pixel — not merely that two images happened to look alike.

The table names the release it is guaranteed as of. release:cut refuses to cut a version until the evidence for Chromium and Firefox was measured on the commit being released, then stamps that version onto the rows — so the guarantee is tied to a version rather than to whenever someone last ran the suite. Rows measured after a release carry no version until the next one claims them.

Chromium is measured on every CI run. Firefox and Safari need a machine with a display and are measured by hand, which is why their rows carry an older date.

Feature parity is a different question from cost, and the matrix above says nothing about the latter. One stress workload has been measured end to end on both backends. In the scrolling-world case of the engine’s benchmark harness — one million static sprites spread over four times the viewport’s area, with a moving camera — both backends draw the visible world in a single draw call: a large static world can keep its renderable state persistent while the camera moves, so moving the camera does not require rebuilding the visible scene from scratch. On the documented reference run (headless Chromium, NVIDIA GeForce RTX 5070 Ti, ExoJS 0.15.2, 2026-08-15) CPU-p95 measured 10.08 ms on WebGL2 and 10.45 ms on WebGPU.

Read that as one dated result for one workload on one machine, not as a backend-wide guarantee and not as a frame-rate claim. CPU-p95 is the time the engine spent in the render path on the CPU in its 95th-percentile frame — not the frame’s GPU time, and not a whole game frame with update, input, and physics in it. The two backends’ full-frame columns come from different instruments (a hardware timer query on WebGL2, a queue-completion wall clock on WebGPU) and are not comparable one-to-one. Methodology, metric definitions, the environment metadata recorded per run, and the command that reproduces this cell live in the harness’s README: @codexo/exojs-bench.

Known differences

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

Area WebGL2 WebGPU
Sprite batching Multi-texture batched (up to 8) Multi-texture batched (up to 8)
Particle simulation CPU CPU (default); optional GPU compute update path when all update modules implement wgsl()
MeshMaterial GLSL ES 3.00 WGSL
ShaderFilter glsl source (GLSL ES 3.00) wgsl source (WGSL)
GPU compute Not available Raw backend.device access for compute + custom pipelines
Engine-emitted debug pass labels Not currently emitted Emitted on key passes (e.g. ShaderFilter 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 Shader 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. Shader.detectUniformDrift() compares declared uniforms across languages for CI-style verification.

For screen-space effects, use ShaderFilter and give it both a glsl and a wgsl source. The uniform-value types are the same either way — only the shader language differs, and the filter picks the one the active backend needs. A filter carrying only one language throws ShaderFilterBackendError when it attaches to the other backend.

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 ComparisonKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Capabilities, Color, FixedResolutionCanvasSizing, Keyboard, type RenderingContext, Scene, type Seconds, Sprite } from '@codexo/exojs';
import { DebugOverlay } from '@codexo/exojs/debug';

const options = {
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
};

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

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

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

    this.sprites = Array.from({ length: 2200 }, () => {
      const sprite = new Sprite(this.loader.get('image/ship-a.png'));
      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, () => {
      // Without an adapter the WebGPU backend produces no context and the
      // canvas stays blank, so the comparison only toggles where both
      // backends can actually run.
      if (!webGpuAvailable) return;
      backendType = backendType === 'webgpu' ? 'webgl2' : 'webgpu';
      boot(backendType);
    });
  }

  override update(delta: Seconds): void {
    const app = this.app;
    const { width, height } = app;
    for (const item of this.sprites) {
      item.sprite.move(item.vx * delta, item.vy * delta);
      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: RenderingContext): void {
    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) {
    void app.destroy();
    app.element?.remove();
    app = null;
  }
  app = new Application({ ...options, scenes: { DemoScene }, backend: { type } });
  overlay = new DebugOverlay(app);
  overlay.layers.performance.visible = true;
  void app.start(DemoScene);
};

// The adapter query is async, so the first boot waits for it: starting on
// WebGPU where no adapter exists leaves the canvas blank with no error.
// `webgpu` only reports the API surface - a browser can expose `navigator.gpu`
// and still hand out no adapter, so the adapter itself is the deciding fact.
void Capabilities.ready.then(capabilities => {
  webGpuAvailable = capabilities.webgpuAdapter !== null;
  backendType = webGpuAvailable ? 'webgpu' : 'webgl2';
  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.