Render targets
Render into intermediate textures and reuse those outputs in scene composition.
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:
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:
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:
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:
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.
Only a custom material can write one
A fragment shader has to declare one output per attachment. Text, nine-slice and repeating sprites, video, and both default materials declare exactly one, so drawing any of them into a multi-attachment target throws — as does alpha-mask or backdrop-blend compositing. Give the drawable a MeshMaterial or a SpriteMaterial whose shader writes every slot:
struct FragmentOut {
@location(0) color: vec4<f32>,
@location(1) id: vec4<f32>,
};
@fragment
fn fragmentMain(input: VertexOutput) -> FragmentOut {
var out: FragmentOut;
out.color = vec4<f32>(1.0, 0.0, 0.0, 1.0);
out.id = vec4<f32>(0.25, 0.0, 0.0, 1.0);
return out;
}The GLSL counterpart declares layout(location = 0) out vec4 outColor; and layout(location = 1) out vec4 outId;.
A SpriteMaterial takes the same fragment shader, which is what a 2D G-buffer usually wants: the scene is already authored as sprites, so it needs no mesh geometry to write a normal or id buffer. Per-attachment blend modes are the one thing it does not carry — blendModes below is a MeshMaterial option, so a G-buffer that needs its slots blended differently draws through a MeshMaterial.
A custom material is held to the same rule. The engine reads the declared output count off the shader source, and a material that declares fewer than the target has attachments is refused on both backends rather than writing slot 0 only on WebGL2 while WebGPU rejects the pipeline. Where the source uses a shape the reader cannot resolve — an array output, a return struct declared elsewhere — the draw proceeds and a development build logs a warning instead.
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],
});
WebGL2 needs an extension for this one
Modes that differ from one another need OES_draw_buffers_indexed on WebGL2, which desktop drivers generally have and older mobile GPUs may not; WebGPU carries the state in the pipeline and always supports it. Read app.backend.supportsPerAttachmentBlend before relying on it. Where it is missing, such a draw throws rather than picking one mode for every attachment — splitting the pass per blend group would cost exactly the single rasterisation a multi-attachment target exists for, and a fragment shader cannot read its own attachment to blend there itself. Entries that all agree, or a single-attachment target, need no extension.
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;
Compare depths, do not hard-code them
The stored value is each backend’s own window-space mapping of the clip-space z: WebGL2 maps NDC [-1, 1] onto [0, 1], while WebGPU’s NDC z already is [0, 1]. Nearer is the smaller value on both, and the ordering is the portable part; the absolute numbers are not. Sampling is nearest-only — a depth format is not filterable — and single-channel: GLSL reads texture(u_depth, uv).r, while WGSL declares the binding as texture_depth_2d and reads textureSample(u_depth, u_depthSampler, uv). Sample it in a pass after the one that wrote it, never in the same pass, and expect a depth-writing draw to sit in its own batch.
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
A container of 25 sprites rendered once into a RenderTexture, then displayed as a single sprite alongside the original container for comparison.
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.
