Skip to content
playcanvasPublic

About

A debug panel for PlayCanvas apps: hierarchy, assets, frame graph, textures, meshes, materials, scripts, cameras, memory, shaders and physics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Repository files navigation

PlayCanvas Inspector

NPM Version NPM Downloads License Discord Reddit X

| User Manual | Blog | Forum |

A debug panel for running PlayCanvas apps. It shows the entity hierarchy and every component's properties, and tabs for the cameras, assets, frame graph, performance, render targets, textures, meshes, materials, scripts, memory, shaders and physics of the app, with live previews of textures and render targets, picking and flying in the view, and a debug frame mode that steps through the draw calls of a render pass.

The inspector is read-only, apart from toggling entities on and off, pausing and stepping the app, and view-only overrides (render modes, wireframe, a flown or orbited camera) it removes when it closes.

The inspector docked beside a running app, with an entity selected in the hierarchy

Install

npm install @playcanvas/inspector

It requires PlayCanvas Engine 2.23 or newer. playcanvas is a peer dependency: the inspector works on your app's own engine, so it must be the same playcanvas package your app imports.

TypeScript declarations are included, generated from the JSDoc of the sources.

Use

import { Inspector } from '@playcanvas/inspector';

const inspector = new Inspector(app, { dock: 'left', width: 480 });

Press ` to show or hide the panel, F9 to pause and F10 to step a frame while paused. The keys, dock side, width and more are options of the constructor.

Or, from the Editor or a scripted scene, as a script on an entity:

import { EntityInspector } from '@playcanvas/inspector';

const entity = new Entity('inspector');
entity.addComponent('script');
entity.script.create(EntityInspector, { properties: { dock: 'left' } });
app.root.addChild(entity);

Step through a frame

In the Frame graph tab, select a forward pass or one of its layer steps and turn on Debug frame. The app pauses and the pass draws only up to the selected draw; the arrow keys step through the draws one at a time, with each one outlined in the view and listed with its mesh and material. It needs the debug build of the engine.

Stepping through the draws of a layer one at a time

Record performance

On the Performance tab, press the record button (a ring around a dot) to capture every frame, up to the last 1000; it shows red while recording, and pressing it again stops. The button beside it clears the capture. Charts cover the whole capture along its time, each frame as wide as it lasted, with a pause longer than a second folded to a break mark: the frame interval, the CPU time on the main thread by phase, the GPU time and the draw calls by kind. The Loading section, on the same time, marks the moments of the page and the app, such as the first paint, the preload and the first frame, the assets loading, from asking for them to their resource being ready, with their request solid and what came after it, as parsing, faint, and how many requests were in flight; hover it for what was loading, and click an asset to show it on the Assets tab. Below them, the Memory chart draws the JavaScript heap, the video memory and each kind of it as lines sharing a scale, and the Resources chart the count of textures, render targets, shaders, buffers and pipelines; untick a line in the list beside a chart to hide it, and the chart rescales to the others. The Events section has a row, which can be unticked too, for each kind of event, marking the frames that had one: hitches, main thread blocked, lightmap bakes (for an app with a lightmapper), shaders compiled, garbage collections, resources created or destroyed and the app's own measures; the assets loading are in the Loading section. A hitch is a frame taking over twice the median, whatever held it back, so it is what the user sees stutter. Main thread blocked is what the browser reports, in Chromium browsers, as a long animation frame: its main thread busy for 50 ms or more, with the script that ran longest, during a frame or between them. It tells why a frame was slow: a hitch without it was held back by the GPU, the browser or the display, which its hover says. Click the name of a chart or a section to collapse it. Hover a mark for what happened, or a chart for the figures of the frame under the pointer. A dashed line marks the display interval on the Frame, CPU and GPU charts, which are scaled to show it.

Click a frame, or a mark, to show it on a timeline of its own, and everything recorded for it below: what held it back, the CPU time by phase, the GPU time of each pass, what loaded in it, the work it submitted, memory and resources, and its events. The timeline is a flame chart: the update, post-update, inspector and render of the CPU, and any lightmap bake where it ran, with the physics step, the animation and the phases of the render under them, then the GPU passes, the events, and the moments, assets and requests of the frame. With frames selected together, it shows their average frame. Scroll over the timeline to zoom into part of the frame, drag the scrollbar under it, or drag across it holding Shift, to pan, and double-click it to see the whole frame again; click its title to collapse it. Drag the divider between the names and the charts, or the timeline, to make the names wider. Drag across the charts to select frames together, with their averages, 95th percentiles and largest figures; scroll to zoom, then drag the scrollbar under the charts, or drag across them holding Shift, to pan, and double-click to see the whole capture again. ‹ and › step a frame, « shows the first and » the newest, following it as frames are recorded. While the panel has focus, the arrow keys step a frame (ten with Shift), Home shows the first and End follows the newest.

Turn on GPU timings to record the GPU time. On WebGPU each pass is timed, and the timeline draws the GPU as a flame chart: the effects, such as bloom or depth of field, on the GPU line, with the passes each runs under it, and the passes of their own on the GPU line too. The effect of a pass comes from the frame graph, the pass it is a before or after pass of; where the frame graph no longer matches the passes timed, every pass goes on the GPU line. Passes of one name in a row share a label, as Downsample ×5, the frame details list the time of each, and clicking a pass or an effect shows it on the Frame graph tab. An engine that sums the passes sharing a name, before 2.24, gives one time for each name, and WebGL times the frame as a whole. An engine whose GPU profiler does not say which frame its timings belong to, such as 2.23, gets them a few frames late, which the tab notes. The inspector runs inside the app's update, so its own time is shown apart, as Inspector; turn off Inspector time to leave it out of the CPU chart, which then scales to the app's own time. The release engine times the update and the render as a whole; the profiler and debug engines add the phases of the render, the batcher's updating and creating of batches among them, and the breakdown of the draw calls. Spans the app measures with performance.measure show on the timeline of their frame.

Turn on Script timing to time each script type while recording: the time of its update, postUpdate and initialize calls, without the calls nested in them, such as the scripts of an entity a script creates, and how many times each was called. The CPU chart then stacks the scripts apart from the other systems, and the timeline draws the scripts of the update and of the post-update where they ran, with a line below breaking each down by script type: as the calls of the types interleave, entity by entity, each type is its time in all, back to back in the order the types first ran. Click a type there to show it on the Scripts tab. The Scripts chart stacks the ten script types that took the longest, over the frames selected together or else the whole capture, so selecting a spike brings forward the scripts behind it; the others share one band, whose hover lists the longest of them. ▸ 10 more under the list gives ten more types a band of their own. To follow a script whatever its time, pin it: Pin beside it in the Scripts details, or Shift+click it in the list. Alt+click a line of a list to show it alone, and again to show them all. Timing a call costs the app about half a microsecond, estimated as recording starts and counted as the inspector's time. Time spent in event handlers, timers and promises is not counted. On a page that is not cross-origin isolated, the browser's clock steps by a tenth of a millisecond, so the times of small scripts read best over many frames selected together.

The Counters section charts the counters the app keeps in app.stats.user, as each frame ended: a line each, with a scale of its own, as their units are the app's. The frame details list their values, and the frames selected together their averages, 95th percentiles and largest:

// counted afresh each frame
app.on('frameupdate', () => app.stats.user.set('enemies', 0));

// wherever the app counts them
app.stats.user.set('enemies', app.stats.user.get('enemies') + 1);

To record from the first frame the app runs, pass record: true to the constructor, or set the record attribute of the EntityInspector script. Create the inspector before the app runs its first frame, as right after app.init(), to record the start of the app too: the capture then begins with a Startup entry, from the page opening to the first frame, with the requests of the page, its moments, the assets loaded and the times its main thread was blocked; its details list the slowest assets, with their request and processing apart. It is one of the 1000 entries, so it makes way for frames like any other. From code, set inspector.recording to start or stop a capture around a part of the app:

const inspector = new Inspector(app, { record: true });

// once the part to look at has run
inspector.recording = false;

Edit transforms

Click a position, rotation or the local scale in the Transform section of an entity to edit it as three numbers separated by commas (one number for a uniform scale); Enter applies it and Escape gives up. Or turn on Move, Rotate or Scale in the toolbar and drag the handles of the gizmo drawn on the selected entity. A drag on a handle is kept from the app, while away from the handles the app has the view as usual. Pick, Fly and Orbit are off while a gizmo is on; Esc or its button turns it off. An entity with a physics body takes its body along.

Copy as JSON

Right-click a row, a section title or an item of a list to copy it to the clipboard as JSON, with numbers at full precision. Right-click the header of a group, such as Textures on the Memory tab, to copy the group, or the name of a tab to copy everything it lists: the Hierarchy tab copies the hierarchy from its root. Copy as JSON copies it on its own; Copy N levels deep also copies what it links to, such as the children of an entity, its mesh instances, their materials and their textures, up to N links away; Copy all levels follows every link, so copying the root copies the whole hierarchy. A link that is not followed is named by $type and its name or id, and marked $stub; an object already copied elsewhere in the copy is marked $ref. A node's parent and the lists of what uses an object are left out, so a copy never climbs out of what was copied.

On the Performance tab, right-click the name of the tab to copy the whole capture, every frame with all it recorded, or the name of a chart or section, or a line of its list, to copy that series a row per frame, as JSON or as CSV for a spreadsheet; both cover the frames selected together when there are any. Right-click a frame in the charts to copy it, inside the frames selected together to copy their figures, and the timeline to copy its bars.

AI agents (MCP)

Click MCP at the right end of the status bar to let AI agents read the app and profile it on the Performance tab. It turns orange while on, is remembered with the other settings, and keeps working while the panel is hidden. Or turn it on from code:

const inspector = new Inspector(app, { mcp: true });

No tool changes the app. These only read it:

Tool What it returns
playcanvas_app_summary The engine version, graphics device and video memory, entity and asset counts, frame stats
playcanvas_index Everything in one call: the summary, every entity with its path and components, and every camera, material, texture, shader, mesh, render target, script, asset and pass with its figures
playcanvas_find_entities Entities by name, component type, script name and tag
playcanvas_list What a tab lists: textures, shaders, materials, meshes, scripts, cameras, assets, renderTargets, passes (the frame graph) or memory
playcanvas_get Any object by id, as its tab shows it

Every object returned carries an $id, such as material:12, which playcanvas_get takes, so an agent follows links from one object to the next: an entity to its mesh instances, their material, its shader, and back through the materials using that shader. depth copies linked objects in place, up to 4 links away. Shader sources are left out unless sources is true. Ids stay the same for the life of the inspector.

These record frames on the Performance tab, as its Record button does, and read what was recorded. Only playcanvas_perf_record and playcanvas_perf_stop change anything, and that is the inspector's capture and its timing switches:

Tool What it does
playcanvas_perf_record Starts recording, optionally with GPU and script timing. With frames, stops after that many and returns their summary, or gives up after timeout seconds, as frames stop while the page is hidden
playcanvas_perf_stop Stops recording and returns the summary
playcanvas_perf_status Whether it is recording, the frames kept, the timings on and whether the page is visible
playcanvas_perf_summary A few KB: the min, average, 95th percentile and max of frame time, CPU update and render, scripts, GPU, draw calls and memory, the slowest GPU passes and script types, the hitches, what loaded and the shader compiles
playcanvas_perf_frame One frame in full, as right-clicking it copies it
playcanvas_perf_series One series a row per frame, as JSON or CSV, 300 rows at a time
const { summary } = await inspector.executeTool('playcanvas_perf_record', { frames: 120, gpuTimings: true, scriptTiming: true });
const slowest = await inspector.executeTool('playcanvas_perf_frame', { frame: summary.hitches.longest[0]?.frame ?? 1 });

The summary's CPU figures leave out the inspector's own time, and say how much it was; includeInspector counts it. A capture the user recorded is not discarded unless clear is true; clear: false adds to it.

These show the app and what it logs, and hold it still:

Tool What it does
playcanvas_screenshot A PNG image: what the canvas shows, or with the id of a texture or a render target, that texture or an attachment of the target, as its preview shows it: sRGB, HDR and encoded textures decoded, depth and single channels as grayscale. channels shows stored channels instead, such as aaa for alpha
playcanvas_logs What the page logged to the console since MCP was turned on, and the errors nothing caught: the engine's warnings in a debug build, shader errors, the app's own messages. Repeats in a row are counted, and since returns only what is new
playcanvas_pause Pauses or resumes the app, as the Pause button does
playcanvas_step Runs a number of frames with time moving, then holds the app paused

A texture is captured by drawing it with the preview's shaders into an 8-bit target the size of the image, then reading that back, so it comes out the way the panel previews it. WebGL2 cannot capture a comparison depth texture, such as a shadow map, or a depth renderbuffer; WebGPU captures shadow maps. HDR values above 1 clip to white, as in the preview. Through WebMCP a screenshot is an image the agent sees; through executeTool it is base64 PNG data under $image.

Messages are kept while MCP is on, the last 1000 of them. The console methods are wrapped meanwhile, so DevTools names the wrapper as where a message came from until MCP is turned off.

Each tool describes itself with a description and a JSON schema of its arguments, which is all an agent needs to use it. While MCP is on, the tools are offered two ways:

  • WebMCP, where the browser has it (Chrome 150+ with --enable-features=WebMCP): the tools are registered with document.modelContext, where agents running in the browser find them. To use them from Claude Code or another MCP client, add chrome-devtools-mcp with its WebMCP tools on, which launches Chrome with the flag:

    claude mcp add chrome-devtools -- npx -y chrome-devtools-mcp@latest --categoryExperimentalWebmcp --chromeArg=--enable-features=WebMCP

    Then ask the agent to open the page, click MCP, and use list_webmcp_tools and execute_webmcp_tool.

  • From the page, in any browser, for agents driving it, such as through Puppeteer:

    inspector.mcp = true;
    const tools = inspector.getTools();
    const { entities } = await inspector.executeTool('playcanvas_find_entities', { component: 'render' });
    const entity = await inspector.executeTool('playcanvas_get', { id: entities[0].$id });

Engine versions

Each inspector release names the engine versions it supports in its playcanvas peer dependency. The inspector reads engine internals to show what it shows, so a new engine release can need a new inspector release. The range takes later 2.x releases but not prereleases of them, such as 2.24.0-beta.1, as npm does not match prereleases of other versions than the range names.

The devtools hook

From engine 2.23, every PlayCanvas app announces itself to a hook defined on the global object, which is how the upcoming browser extension finds apps on any page:

globalThis[Symbol.for('playcanvas.inspector')] = {
    register(app, { version, revision, protocol }) {},
    unregister(app) {}
};

The hook must be defined before the engine creates the app. register is called once each app is initialized, and unregister when it is destroyed. protocol is 1.

Development

Node 22.19 or newer.

npm install
npm run lint
npm test
npm run build:types # writes the TypeScript declarations to types/
npm run test:types  # compiles a TypeScript usage of the package against them
npm run publint     # checks the package is publishable

npm run test:webmcp calls the MCP tools through Chrome's own WebMCP: it launches system Chrome with WebMCP on, opens the inspector example of the engine's examples browser on port 5620, clicks MCP and runs a chain of calls. node e2e/webmcp.mjs --url <page> --calls '<json>' runs other calls on another page; see the top of e2e/webmcp.mjs.

node e2e/webmcp.mjs --dump <dir> writes everything the app holds to files, for an agent to search rather than read: index.json, then a file per kind (entities.json, materials.json, shaders.json with their sources, meshInstances.json and so on) holding every object as playcanvas_get returns it, keyed by its $id, so the links between objects resolve across the files. A README.md in the directory describes them.

The driver writes every screenshot it gets to a PNG file in --images <dir> (default screenshots), so --calls can capture textures and render targets to look at.

node e2e/webmcp.mjs --perf <frames> [dir] records that many frames through the perf tools, with GPU and script timing where the app has them, prints the summary and writes capture.json (every frame, as copying the capture gives it), summary.json and a CSV per series (series-cpu.csv, series-gpu.csv and so on) into the directory, or into the --dump directory.

--perf-dump [dir] writes the same files from the capture the Performance tab already holds, without recording, such as one recorded with its Record button. As the script otherwise opens a page of its own, record in one of these:

  • --wait opens a window and waits for Enter in the terminal, so you can record there first.
  • --connect <url> uses a Chrome you already run, started with --remote-debugging-port=9222 --user-data-dir=<dir> --enable-features=WebMCP, given as http://127.0.0.1:9222. It picks the tab with the inspector (or the one whose URL contains --url's value) and leaves Chrome running.
node e2e/webmcp.mjs --connect http://127.0.0.1:9222 --perf-dump ./capture

The tests run against the playcanvas version in devDependencies, on the engine's null graphics device under jsdom.

A few engine helpers the inspector draws with are copied into src/renderers, src/picker and src/input, so the inspector does not depend on them being in the app's engine build. See COPIED_FROM.md.

License

MIT

About

A debug panel for PlayCanvas apps: hierarchy, assets, frame graph, textures, meshes, materials, scripts, cameras, memory, shaders and physics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages