Guide

Guide Rendering Render targets

Render targets

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

Advanced ~3 min read

What you'll learn

  • render a scene into an intermediate texture
  • reuse render-target output in composition

Before you start

Render targets

The canvas is the default render target — every context.backend.clear() and context.render(sprite) goes 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:

init(loader) {
    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:

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:

draw(context) {
    // 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
    context.backend.clear();
    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:

init(loader) {
    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;
}

draw(context) {
    context.backend.clear();
    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.

RenderTexture vs. cacheAsBitmap

cacheAsBitmap on a RenderNode bakes the node’s subtree into an internal texture automatically. Use cacheAsBitmap 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).

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.

Examples

Render to Texture Open in Playground View source
import { Application, Color, Container, RenderTexture, Scene, Sprite, Texture } from '@codexo/exojs';

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

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

    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.container = this.createBunnyContainer(loader.get(Texture, 'bunny'));

        this.renderTexture = this.createRenderTexture(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(container: Container): RenderTexture {
        const backend = this.app.backend;
        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): void {
        context.backend.clear();
        context.render(this.container);
        context.render(this.renderSprite);
    }
}

app.start(new RenderToTextureScene());

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

Mini Map Open in Playground View source
import { Application, CallbackRenderPass, Color, Graphics, RenderNodePass, RenderPipeline, RenderTexture, Scene, Sprite } from '@codexo/exojs';

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

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

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

        this.world = new Graphics();
        this.player = new Graphics();
        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);

        // The "world" is immediate-mode (grid + player) — a context-aware callback, drawn once
        // into the minimap texture and once to the screen. The sprite + frame are scene nodes.
        this.pipeline = new RenderPipeline()
            .addPass(
                new CallbackRenderPass(
                    (context) => {
                        context.backend.clear();
                        this.renderWorld(context.backend);
                    },
                    { target: this.miniRt },
                ),
            )
            .addPass(
                new CallbackRenderPass((context) => {
                    context.backend.clear();
                    this.renderWorld(context.backend);
                }),
            )
            .addPass(new RenderNodePass(this.miniSprite))
            .addPass(new RenderNodePass(this.miniFrame));
    }

    override update(delta): void {
        this.time += delta.seconds;
    }

    private renderWorld(backend): void {
        const { width, height } = this.app.canvas;
        const marginX = 80;
        const marginY = 60;

        this.world.clear();
        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);
        this.world.render(backend);

        const x = width / 2 + Math.cos(this.time) * (width * 0.4);
        const y = 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(x, y, 18);
        this.player.render(backend);
    }

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

    override destroy(): void {
        // Pipeline cascades destroy() to its passes; the caller-owned minimap target is freed here.
        this.pipeline.destroy();
        this.miniRt.destroy();
        super.destroy();
    }
}

app.start(new 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.