Skip to main content

Debug Drawing

Some things are hard to debug by reading numbers: whether a bounding box is where you think it is, which way a spline is heading, what a second camera can actually see. Debug drawing lets you display shapes and texture previews on screen so you can inspect them.

WireRenderer and TextureRenderer draw for a single frame. To keep a shape or preview visible, submit it again every frame from an update handler.

WireRenderer​

WireRenderer draws wireframe shapes. Create one and keep it around:

import { Color, Vec3, WireRenderer } from 'playcanvas';

const wire = new WireRenderer(app);

app.on('update', (dt) => {
wire.color = Color.RED;
wire.sphere(new Vec3(0, 1, 0), 2);
});

State lives on the renderer​

Rather than passing options to every call, the renderer holds the state its shapes use:

PropertyMeaning
colorThe color shapes are drawn in. Alpha is respected.
layerThe layer to draw into, or null for the immediate layer.
depthTestWhether shapes are hidden by geometry in front of them.
segmentsHow many line segments approximate a full circle.
transformA matrix applied to every point, or null.

Set it once and draw as much as you like — drawing many shapes with the same state allocates nothing:

wire.color = Color.GREEN;

for (const item of items) {
wire.sphere(item.getPosition(), item.radius);
}

A second set of state is simply a second renderer. Instances hold no GPU resources, and instances sharing a layer and depth test mode are drawn together, so using several costs nothing extra:

const solid = new WireRenderer(app);

const xray = new WireRenderer(app);
xray.depthTest = false; // drawn on top of everything

transform is useful for drawing a group of shapes in some other space. Set it, draw, and every point is transformed on the way out:

wire.transform = entity.getWorldTransform();
wire.boxMinMax(localMin, localMax); // drawn in the entity's space
wire.transform = null;

Shapes​

wire.line(start, end);
wire.lines(positions, colors); // Vec3[] pairs, optional Color[] per point
wire.linesPacked(positions, colors); // packed xyz / rgba numbers, the fastest form
wire.polyline(positions, colors); // an open strip
wire.loop(positions, colors); // a closed strip

wire.box(box); // BoundingBox or OrientedBox
wire.boxMinMax(min, max);
wire.sphere(center, radius);
wire.circle(center, normal, radius);
wire.cylinder(start, end, radius);
wire.capsule(start, end, radius);
wire.cone(apex, direction, angle, length);
wire.plane(center, normal, size);
wire.point(position, size);
wire.arrow(from, to);
wire.axes(matrix, size); // red, green and blue for x, y and z
wire.frustum(source); // a camera, or a view-projection matrix
wire.light(lightComponent); // the light's shape and extent, in its own color

For the line functions, colors is optional. Omit it and the renderer's color is used for everything; supply one color per point and each segment fades between its ends.

linesPacked takes plain arrays or Float32Arrays of numbers rather than Vec3 and Color instances, which makes it the cheapest way to submit a lot of geometry. Note that leaving colors off is a larger saving than the choice of array type — a uniform color avoids writing a color per vertex entirely.

Visualizing a camera​

wire.frustum() draws the volume a camera can see. It works on a camera that is not currently rendering, which is the interesting case — you can fly one camera around while looking through another:

wire.color = Color.YELLOW;
wire.frustum(observerEntity.camera);

Combined with Frustum#containsAabb this makes culling visible:

const viewProjection = new Mat4().mul2(observer.camera.projectionMatrix, viewMatrix);
frustum.setFromMat4(viewProjection);

for (const meshInstance of meshInstances) {
const inside = frustum.containsAabb(meshInstance.aabb);
(inside ? greenWire : redWire).box(meshInstance.aabb);
}

A note on plane​

The rotation of the square within its plane is derived from the normal you pass, and no such derivation is continuous over every direction. An animated normal will therefore make the square appear to jump as it passes the direction where that derivation switches. To rotate a square smoothly, pass a fixed normal and drive transform instead.

This does not affect circle, cylinder, capsule or cone, whose rings are symmetric about their axis.

TextureRenderer​

TextureRenderer displays textures on screen for debugging. Create one and submit the previews you want to see each frame:

import { TextureRenderer } from 'playcanvas';

const textures = new TextureRenderer(app);

app.on('update', () => {
textures.draw(texture, 0.7, 0.7, 0.25, 0.25);
});

The arguments to draw are the texture, left edge, top edge, width and height. Coordinates are relative to the rendering camera's viewport: (0, 0) is top-left, (1, 1) is bottom-right, and a width or height of 1 spans the viewport. Negative sizes flip the preview about its starting edge.

Preview settings​

layer and channels apply to subsequent draw calls. camera selects the camera for all previews when they are rendered:

PropertyMeaning
layerDestination layer, or null for the application's default debug drawing layer, normally Immediate.
channelsThree characters from r, g, b and a. Defaults to 'rgb', which displays decoded color, or grayscale for single-channel formats.
cameraThe CameraComponent that draws the previews, or null (the default) to allow every camera rendering the destination layer.

Previews are opaque and do not test or write depth. Use a layer rendered after the scene and skybox so later drawing does not cover them. The displaying camera must include that layer. Set textures.camera = camera.camera to restrict previews to one camera, such as the main camera, and keep them out of reflections. Set it back to null to allow all cameras rendering the destination layer.

When previewing a render target, the renderer automatically skips any preview whose texture is a color, resolve or depth attachment of the current render target. This prevents reading and writing the same texture at once, including with CameraFrame and custom render passes.

For render-target textures, use RENDERTARGET_ORIGIN_TOP on the render target for consistent orientation across WebGL2 and WebGPU.

Inspecting channels​

With 'rgb', the renderer automatically decodes the texture's color encoding. Single-channel formats (R8, R16F, R32F) display their stored channel as grayscale. Other selections show stored channel values without color decoding, with values from 0 to 1 mapping directly from black to white. For example, 'rrr' shows red as grayscale, 'aaa' shows alpha, and 'bgr' swaps red and blue. The preview itself remains opaque, including when inspecting alpha.

You can display the same texture in multiple ways during one update:

textures.channels = 'rgb';
textures.draw(texture, 0.05, 0.05, 0.25, 0.25);
textures.channels = 'aaa';
textures.draw(texture, 0.35, 0.05, 0.25, 0.25);

Channel selection is captured by each draw call and remains set until you change it. Set it back to 'rgb' when you want decoded color again. Depth textures and sceneDepth ignore channel selection and always display grayscale.

Scene depth​

sceneDepth displays the rendering camera's depth, linearized and normalized by its far clip distance. Enable scene depth capture on the camera and render the preview layer after the capture:

import { TextureRenderer } from 'playcanvas';

// camera is an entity with a camera component.
camera.camera.requestSceneDepthMap(true);
const textures = new TextureRenderer(app);
textures.camera = camera.camera;

app.on('update', () => {
textures.sceneDepth(0.7, 0.7, 0.25, 0.25);
});

Passing a depth texture to draw instead shows its raw depth values without camera-dependent linearization.

Supported textures​

  • 2D color: normalized, floating-point and device-supported compressed formats. The default 'rgb' selection handles linear, sRGB, RGBM, RGBE and RGBP color automatically.
  • 2D depth: PIXELFORMAT_DEPTH, PIXELFORMAT_DEPTH16 and PIXELFORMAT_DEPTHSTENCIL.

Textures must belong to the same graphics device as the application. Cube, volume, array, integer and multisampled textures are not supported.

On WebGL2, raw depth textures must have comparison sampling disabled. Raw depth and non-filterable float textures also require nearest minification and magnification filters. WebGPU can display these textures regardless of their filtering and comparison sampler settings.

Cleanup​

Stop submitting a preview to hide it on the next frame. Call textures.destroy() when you no longer need the renderer; it also releases its resources automatically when the application is destroyed. The textures you pass remain yours to manage and are never destroyed by the renderer.

Thick lines​

WireRenderer draws single pixel lines, which is what you want for a debugging overlay. For lines that are part of the rendered scene — a highlighted path, a road network, an annotation — use WideLineRenderer instead. It supports width in screen pixels or world units, caps, joins, dashes and gradients, and it is retained rather than immediate: you add a line once and it stays until you remove it.

Performance counters​

Debug drawing shows you where things are. To see how long they take, use MiniStats.

Examples​

Texture Renderer
  • Wire Shapes — every shape, animated
  • Frustum Culling — a camera's view volume, and bounds colored by whether it contains them
  • Lines — all of the line functions at once