Guide

GuideRenderingRender targets

Render targets

Render into intermediate textures and reuse those outputs in scene composition.

Advanced~8 min read

What you'll learn

  • render a scene into an intermediate texture
  • reuse render-target output in composition
  • fill several colour attachments from one pass

Before you start

Render targets

The canvas is the default render target — the engine’s per-frame clear and every context.render(sprite) go there. A RenderTexture is a second target: an off-screen surface you can draw into and then sample as a texture on a sprite, apply filters to, or composite back into the main scene.

This is the foundation of multi-pass rendering in ExoJS: render-to-texture for caching, minimaps, picture-in-picture views, reflection maps, and as a staging area before post-processing.

Creating a RenderTexture

A RenderTexture has pixel dimensions and optional sampler parameters:

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

const rt = new RenderTexture(256, 256);

The constructor accepts an optional SamplerOptions object to control how the texture is sampled when used on a sprite:

import { RenderTexture, ScaleModes, WrapModes } from '@codexo/exojs';

const rt = new RenderTexture(512, 512, {
    scaleMode: ScaleModes.Nearest,  // default Linear
    wrapMode: WrapModes.Repeat,     // default ClampToEdge
});

Default sampler settings — linear filtering, clamp-to-edge wrapping, premultiplied alpha, no mipmap generation — match the typical “render-to-texture then display” use case.

Drawing into a RenderTexture

Set the render target on the backend, draw normally, then restore the canvas:

examples/guides/render-targets/offscreen.ts
override init(): void {
  const backend = this.app.backend;

  this.offscreen = new RenderTexture(256, 256);

  // Redirect rendering to the off-screen target
  backend.setRenderTarget(this.offscreen);

  backend.clear();
  this.someSprite.render(backend);
  this.someContainer.render(backend);

  // Restore the canvas
  backend.setRenderTarget(null);
}

Everything between setRenderTarget(rt) and setRenderTarget(null) draws into the RenderTexture instead of the canvas. The clear() call clears the render texture, not the canvas. After restoring the canvas, the RenderTexture holds the result as a sampled texture.

Using the result

Once drawn, a RenderTexture can be assigned to any Sprite:

examples/guides/render-targets/offscreen.ts
this.display = new Sprite(this.offscreen);
this.display.setPosition(400, 300);
this.display.setAnchor(0.5);
this.addChild(this.display);

The sprite displays the off-screen render as its texture. You can position, scale, rotate, tint, and filter it like any other sprite.

Updating each frame

The render-to-texture step typically happens once during init for static content, or inside draw for content that changes per frame:

examples/guides/render-targets/offscreen.ts
override draw(context: RenderingContext): void {
  // 1. Draw game world into the off-screen target
  context.backend.setRenderTarget(this.offscreen);
  context.backend.clear();
  this.worldLayer.render(context.backend);
  context.backend.setRenderTarget(null);

  // 2. Draw the main scene - the off-screen result is now a texture.
  //    The canvas itself was already cleared before `draw` ran.
  context.render(this.display);
  context.render(this.hud);
}

When a RenderTexture is used as the active render target, draw calls write into it directly. You do not need to call updateSource() after setRenderTarget(null).

RenderTexture as a RenderTarget

RenderTexture extends RenderTarget, which carries a View for camera control, size management, and viewport configuration. When drawing into a RenderTexture, the render target’s view determines the coordinate system. By default it uses a pixel-aligned view matching the texture dimensions:

import { RenderTexture, View } from '@codexo/exojs';

const rt = new RenderTexture(400, 300);

// Optionally set a custom view (camera)
const customView = new View(200, 150, 400, 300);
rt.setView(customView);

The setSize() method changes the texture dimensions. powerOfTwo reports whether both dimensions are powers of two — relevant for some mipmap and tiling scenarios.

Use cases

Cached layers. Render a complex, static container once into a RenderTexture, then display the result as a sprite. Subsequent frames skip the container’s render tree entirely — one texture draw instead of N child draws:

examples/guides/render-targets/offscreen.ts
override init(): void {
  this.buildComplexScene(); // builds this.staticLayer

  this.cache = new RenderTexture(Math.ceil(this.staticLayer.width), Math.ceil(this.staticLayer.height));

  const backend = this.app.backend;
  backend.setRenderTarget(this.cache);
  backend.clear();
  this.staticLayer.render(backend);
  backend.setRenderTarget(null);

  this.cachedSprite = new Sprite(this.cache);
  this.staticLayer.visible = false;
}

override draw(context: RenderingContext): void {
  context.render(this.cachedSprite);
  // ... dynamic content on top ...
}

Mini-maps. Render the full world from a zoomed-out view into a small RenderTexture, then display it scaled down in a corner sprite.

Compositing. Render two independent scene layers into separate RenderTexture instances, then composite them with different blend modes, tints, or filters applied to the result sprites.

Staging for post-processing. Render a scene into a RenderTexture, apply a filter to the sprite that displays it, and render the result to the canvas. The Post-processing chapter covers this pattern in detail.

Several attachments in one pass: MultiRenderTarget

Some passes have to produce more than one image from the same geometry — colour plus a selection id, a normal buffer, a velocity buffer. Rendering the scene twice pays for the same transforms and rasterisation twice. A MultiRenderTarget carries several colour attachments and fills them in one pass:

import { MultiRenderTarget, TextureFormat, type RenderingContext, type RenderNode } from '@codexo/exojs';

declare const context: RenderingContext;
declare const scene: RenderNode;

const gbuffer = new MultiRenderTarget(512, 512, {
    formats: [TextureFormat.Rgba8, TextureFormat.Rgba8],
});

context.renderTo(scene, { target: gbuffer });

const albedo = gbuffer.attachment(0);
const ids = gbuffer.attachment(1);

Each attachment is an ordinary RenderTexture and is sampled like any other texture afterwards. The target owns them: it creates them, resizes them with itself, and destroys them with itself. Read app.backend.maxColorAttachments for the ceiling on the current device.

Attachments can also be blended on different terms. One blend mode for the whole draw is rarely what a G-buffer wants: the albedo slot is composited with alpha, while a blended normal vector or a blended selection id is not a value at all. A MeshMaterial constructed with blendModes gives one mode per attachment, in the order the fragment shader declares its outputs; an attachment past the end of the list keeps the blend mode the draw would have used anyway, and entries past the target’s attachment count are ignored. Only the fixed-function modes (Normal, Additive, Subtract, Multiply, Screen) can differ per attachment, since a backdrop-aware mode composites through a pass of its own.

import { BlendModes, MeshMaterial, type Shader } from '@codexo/exojs';

declare const gbufferShader: Shader;

const material = new MeshMaterial({
    shader: gbufferShader,
    blendModes: [BlendModes.Normal, BlendModes.Additive],
});

If one pass only ever produces one image, a plain RenderTexture is the right tool — this exists for the case where it genuinely produces two.

Depth as a data source

A render target can also keep the depth its draws produce, so a later pass can read it: fog thickness, depth of field, a screen-space occlusion term. Pass depth: true to a RenderTexture or a MultiRenderTarget and it owns a depth attachment, resized and destroyed with it and exposed as target.depthTexture. It binds as a named texture of a custom MeshMaterial or SpriteMaterial only: a drawable’s own texture, a filter input and the built-in renderers expect a colour format, and a depth texture in any of those places is a validation error on WebGPU. Filling it is opt-in per material: a MeshMaterial constructed with writesDepth: true writes the clip-space z its vertex stage produces, and nothing else in the scene writes depth at all. There is no depth test — the comparison is fixed to “always pass”, so what is in front of what still follows from draw order, exactly as before. A depth-writing material drawn into a target without the opt-in draws normally and writes depth nowhere.

import { MeshMaterial, RenderTexture, Shader, type RenderNode, type RenderingContext } from '@codexo/exojs';

declare const context: RenderingContext;
declare const scene: RenderNode;
declare const depthShader: Shader;

const target = new RenderTexture(512, 512, { depth: true });
const material = new MeshMaterial({ shader: depthShader, writesDepth: true });

context.renderTo(scene, { target });

const depth = target.depthTexture;

RenderTexture vs. cacheAsTexture

cacheAsTexture on a RenderNode bakes the node’s subtree into an internal texture automatically. Use cacheAsTexture when the cached content doesn’t need to be repositioned, scaled, filtered, or displayed in multiple places — it’s a simple on/off toggle. Use a RenderTexture when you need explicit control over when the cache updates, what view it uses, or how the result is displayed (multiple sprites, custom sampler settings, composited with blend modes).

Cache resolution

The cache texture inherits the resolution of the surface it is composited into, so turning caching on does not soften the picture on a HiDPI display. On a pixelRatio: 3 phone a 200 × 200 logical subtree bakes into a 600 × 600 texture.

That is nine times the memory of a logical-size bake, and nine times the fill on every re-bake. cacheResolution is the knob when the trade is worth making the other way:

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

const backdrop = new Container();

backdrop.cacheAsTexture = true;
backdrop.cacheResolution = 1; // logical size regardless of the display

Changing cacheResolution invalidates the cache, as does anything that moves the node’s world bounds — including its own transform. A node that animates re-bakes every frame and is strictly slower cached than uncached, whatever its resolution.

Lifecycle

RenderTexture owns GPU resources. Call destroy() when the texture is no longer needed to release the backing framebuffer and texture. The engine does not garbage-collect GPU objects automatically. Destroying a MultiRenderTarget destroys its attachments too, so do not destroy() one of those separately.

Examples

Render to TextureOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, Container, FixedResolutionCanvasSizing, type RenderingContext, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs';

class RenderToTextureScene extends Scene {
  private container!: Container;
  private renderTexture!: RenderTexture;
  private renderSprite!: Sprite;

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

    this.container = this.createBunnyContainer(this.loader.get('image/ship-a.png'));

    this.renderTexture = this.createRenderTexture(app.backend, this.container);

    this.renderSprite = new Sprite(this.renderTexture);
    this.renderSprite.setPosition(width, height);
    this.renderSprite.setAnchor(1, 1);
  }

  private createBunnyContainer(texture: Texture): Container {
    const container = new Container();

    for (let i = 0; i < 25; i++) {
      const bunny = new Sprite(texture);

      bunny.setAnchor(0.5, 0.5);
      bunny.setPosition(25 + (i % 5) * 30, 25 + Math.floor(i / 5) * 30);
      bunny.setRotation(Math.random() * 360);

      container.addChild(bunny);
    }

    return container;
  }

  private createRenderTexture(backend: Application['backend'], container: Container): RenderTexture {
    const renderTexture = new RenderTexture(Math.ceil(container.width), Math.ceil(container.height));

    backend.setRenderTarget(renderTexture);

    backend.clear();
    container.render(backend);

    backend.setRenderTarget(null);

    return renderTexture;
  }

  override draw(context: RenderingContext): void {
    context.render(this.container);
    context.render(this.renderSprite);
  }
}

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

await app.start(RenderToTextureScene);

A container of 25 sprites rendered once into a RenderTexture, then displayed as a single sprite alongside the original container for comparison.

Mini MapOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, Container, FixedResolutionCanvasSizing, Graphics, type RenderingContext, RenderNodePass, RenderPipeline, RenderTexture, Scene, type Seconds, Sprite, View } from '@codexo/exojs';

class MiniMapScene extends Scene {
  private worldContainer!: Container;
  private world!: Graphics;
  private player!: Graphics;
  private miniRt!: RenderTexture;
  private miniSprite!: Sprite;
  private miniFrame!: Graphics;
  private overlay!: Container;
  private miniView!: View;
  private pipeline!: RenderPipeline;
  private time = 0;

  override init(): void {
    const app = this.app;
    const { width, height } = app;
    const miniX = width - 220 - 20;
    const miniY = 20;

    // Grid + player live in one container so the same subtree can be drawn at
    // full size to the canvas and shrunk into the minimap texture.
    this.worldContainer = new Container();
    this.world = new Graphics();
    this.player = new Graphics();
    this.worldContainer.addChild(this.world);
    this.worldContainer.addChild(this.player);

    this.miniRt = new RenderTexture(220, 160);
    this.miniSprite = new Sprite(this.miniRt).setPosition(miniX, miniY);
    this.miniFrame = new Graphics();
    this.miniFrame.lineWidth = 2;
    this.miniFrame.lineColor = Color.white;
    this.miniFrame.drawRectangle(miniX, miniY, 220, 160);

    // Sprite + frame composited in one pass; draw order is now independent (RT sampling is order-safe).
    this.overlay = new Container();
    this.overlay.addChild(this.miniSprite);
    this.overlay.addChild(this.miniFrame);

    // A dedicated view that frames the whole world, scaled down into the
    // 220×160 minimap texture so the entire grid stays visible.
    this.miniView = new View(width / 2, height / 2, width, height);

    // Every stage is a RenderNodePass so the off-screen target redirect and
    // its clear stay inside the pass machinery - mixing in a manual
    // `context.backend.clear()` (immediate-mode) here leaks the off-screen
    // pass's clear onto the canvas and leaves the texture empty.
    this.pipeline = new RenderPipeline()
      .addPass(new RenderNodePass(this.worldContainer, { target: this.miniRt, view: this.miniView, clear: Color.black }))
      .addPass(new RenderNodePass(this.worldContainer, { clear: Color.black }))
      .addPass(new RenderNodePass(this.overlay));
  }

  override update(delta: Seconds): void {
    const app = this.app;
    const { width, height } = app;
    const marginX = 80;
    const marginY = 60;

    this.time += delta;

    this.world.clear();
    // Filled play-area: gives the minimap a recognizable region. Sub-pixel grid
    // lines alone vanish when the world is shrunk into the 220×160 texture.
    this.world.fillColor = new Color(50, 90, 160);
    this.world.drawRectangle(marginX, marginY, width - 2 * marginX, height - 2 * marginY);
    this.world.lineWidth = 2;
    this.world.lineColor = new Color(60, 70, 90);
    for (let x = marginX; x <= width - marginX; x += 80) this.world.drawLine(x, marginY, x, height - marginY);
    for (let y = marginY; y <= height - marginY; y += 80) this.world.drawLine(marginX, y, width - marginX, y);

    const px = width / 2 + Math.cos(this.time) * (width * 0.4);
    const py = height / 2 + Math.sin(this.time * 1.3) * (height * 0.4);
    this.player.clear();
    this.player.fillColor = new Color(255, 180, 100);
    this.player.drawCircle(px, py, 18);
  }

  override draw(context: RenderingContext): void {
    this.pipeline.execute(context);
  }

  override destroy(): void {
    // Pipeline cascades destroy() to its passes; the caller-owned target/view it created are freed here.
    this.pipeline.destroy();
    this.miniRt.destroy();
    this.miniView.destroy();
    super.destroy();
  }
}

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

await app.start(MiniMapScene);

A zoomed-out view of a tile map rendered into a small RenderTexture and displayed as a corner mini-map.

Where to go next

The next chapter, Pixel snapping, shows how to keep sprites, panels, and tilemaps crisp on the device-pixel grid without touching logical state. After that, the Effects section builds directly on render targets — in particular, Post-processing extends the render-to-texture pattern into multi-pass filtering and screen-space effects.