Visitar URL original
refactor: Render a RenderPassForward with a single camera by mvaligursky · Pull Request #9783 · playcanvas/engine · GitHub
Skip to content

refactor: Render a RenderPassForward with a single camera - #9783

Merged
mvaligursky merged 2 commits into
mainfrom
mv-single-camera-forward
Oct 16, 2026
Merged

mvaligursky merged 2 commits into
mainfrom
mv-single-camera-forward

Conversation

@mvaligursky

@mvaligursky mvaligursky commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

RenderPassForward now renders the layers of a single camera, which it takes in its constructor. This is how the engine has always used it, and it keeps the directional shadow maps, which cameras share, correct.

Changes:

  • The camera moves from addLayer to the constructor, as used by the render-pass-camera example:
    // before
    const pass = new RenderPassForward(device, composition, scene, renderer);
    pass.addLayer(cameraComponent, worldLayer, false);
    
    // after
    const pass = new RenderPassForward(device, composition, scene, renderer, cameraComponent);
    pass.addLayer(worldLayer, false);
  • A RenderPassForward cannot be nested under another one: among its before-passes or after-passes, or among the before-passes of its camera. It claims the shared shadow map of a directional light for its camera before its nested passes run, so a nested pass rendering another camera overwrote it before the parent rendered. This is asserted in debug builds. To render another camera first, render the passes one after the other, for example as the before-passes of a FramePass, or use a camera of a lower priority.
  • The first and the last pass rendering each camera in the frame, which fire prerender and postrender and collect the camera's before-passes, are now marked on the pass as it is scheduled. This replaces marking the layer render steps after the frame is built.
  • A frame pass cannot be disabled while it is added to the frame, by its frameUpdate or by the frameUpdate of its before-passes, as it has already set itself up for the frame by then. This is asserted in debug builds. Disable a pass before the frame is built, or skip its execution with executeEnabled.

A RenderPassForward took a camera for each layer it rendered, although the
engine only ever rendered one camera with it. Two cameras sharing a directional
light also share its shadow map, which a pass rendering both of them, or a pass
nested under another one rendering a different camera, overwrote before the
other camera rendered.

The pass now takes its camera in the constructor, and addLayer takes only the
layer. The first and the last pass rendering each camera in the frame are
marked on the pass when it is scheduled, which replaces marking them on the
layer render steps after the frame is built. A RenderPassForward cannot be
nested under another one, which is asserted in debug builds.
@github-actions

Copy link
Copy Markdown

Build size report

This PR changes the size of the minified bundles.

Bundle Minified Gzip Brotli
playcanvas.min.js 2664.1 KB (−418 B, −0.02%) 690.8 KB (−16 B, −0.00%) 535.7 KB (−50 B, −0.01%)
playcanvas.min.mjs 2660.9 KB (−419 B, −0.02%) 689.5 KB (−54 B, −0.01%) 535.3 KB (+11 B, +0.00%)

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review by Codex (OpenAI AI agent), posted through the account holder's authorized automated review process.

Reviewed 814276b against 7389dfc. One P2 regression found, detailed inline: camera first/last-use flags now include a forward pass that disables itself during frameUpdate and is subsequently omitted from the frame graph, dropping the remaining pass's prerender or postrender event. Reproduced against base and head on both WebGL2 and WebGPU.

The single-camera refactor and migrated in-repository callers otherwise look consistent. I checked the documented sequential-container replacement for nested camera rendering: directional shadows execute in the correct order (shadow B → draw B → shadow A → draw A), and camera events correctly surround each camera's passes. The nested-forward-pass restriction is explicit in the documentation and debug assertions.

Validation:

  • Full unit suite: 4,372 passing, 43 pending.
  • Full WebGPU suite with Metal/Dawn: 4,371 passing, 39 pending.
  • Declaration build, consumer type tests, and lint of all changed files passed.
  • Runtime-inspection/pixel-verification checks covered ordinary rendering, CameraFrame with a depth prepass, and sequential cameras sharing directional shadows, each at two camera poses. Base/head images were byte-identical: 0 differing pixels out of 24,576 per backend, with identical repeated baseline captures.
  • Explicit event checks covered split passes, initially disabled passes, re-enabling, and self-disabling passes. The latter exposes the inline finding despite the existing suites passing. Actual XR headset rendering was not exercised.

// render. They go to the internal before-passes, so the before-passes set up by the user stay
this.internalBeforePasses.length = 0;

this.updateCameraUse();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Finalize camera-use flags only for passes that are scheduled

FrameGraph.addRenderPass explicitly supports a pass disabling itself in frameUpdate and then omits that pass from the graph (also covered by frame-graph.test.mjs). Calling updateCameraUse() here commits its flags before that decision. For example, with camera.framePasses = [opaquePass, transparentPass], let the transparent pass's override call super.frameUpdate() and then set this.enabled = false: it clears the opaque pass's lastCameraUse, never renders itself, and postrender is never emitted. Disabling the first pass this way instead loses prerender for the surviving pass. I reproduced both on WebGL2 and WebGPU; the base implementation emits both events because it marks the final scheduled list. This can skip per-camera setup or leave temporary render state unrestored. Please finalize these flags from the passes actually admitted to the graph, or roll back skipped registrations, and add coverage for self-disabling first/last forward passes.

Automated review by Codex (OpenAI AI agent).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in d75a635 by not supporting it: a frame pass disabled while it is added to the frame, by its frameUpdate or by the frameUpdate of its before-passes, is now asserted in FrameGraph.addRenderPass, for every pass type. Such a pass has already set itself up for the frame, as the first or the last pass rendering a camera here, and nothing in the engine disables a pass this way. A pass is disabled before the frame is built, or skips its execution with executeEnabled; the enabled docs say so. The frame graph tests cover the assert, and the local shadow tests, which disabled the shadow pass from its stubbed frameUpdate, now disable it as it is added.

A pass disabled by its frameUpdate, or by the frameUpdate of its
before-passes, was left out of the frame after it had already set itself up
for it. A RenderPassForward had then taken the first or the last use of its
camera, and the camera's prerender or postrender event did not fire.

This is now asserted. A pass is disabled before the frame is built, or skips
its execution with executeEnabled.

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review by Codex (OpenAI AI agent), posted through the account holder's authorized automated review process.

Re-reviewed d75a6357f, including the reply to my previous P2. No new actionable findings introduced by this PR.

The previous P2 is addressed by narrowing the internal scheduling contract, not by making self-disabling passes safe: disabling a pass during its own scheduling is now explicitly unsupported, documented, and diagnosed in debug builds. I verified the assertions for the original first/last-pass reproductions, checked engine enable-state writers against the new contract, and confirmed that disabling before scheduling and suppressing execution on independent passes preserve the camera events. I am not repeating the earlier finding under this explicit contract.

Validation:

  • Full unit suite: 4,374 passing, 43 pending.
  • Full WebGPU/Metal suite: 4,373 passing, 39 pending.
  • Declaration build, consumer type tests, and lint of the follow-up's changed files passed.
  • Runtime/pixel checks on WebGL2 and WebGPU: ordinary rendering, CameraFrame with a depth prepass, and sequential cameras sharing directional shadows remain byte-identical to the PR base at two poses: 0 / 24,576 differing pixels per backend. Repeated baseline captures also matched.

Separate pre-existing caveat: while testing the recommended executeEnabled alternative, I found that suppressing the first of two merged forward passes can crash WebGPU in endRenderPass (Cannot read properties of null (reading 'end')). This reproduces identically on base 7389dfc and this head, so it is not a new PR finding. FrameGraph merges the pair without considering executeEnabled; skipping the first pass skips opening the GPU pass, while the second still skips its start and tries to end it. Keeping the passes unmerged makes the execution-suppression checks pass. This warrants a separate follow-up.

@mvaligursky
mvaligursky merged commit 80c92e5 into main Oct 16, 2026
11 checks passed
@mvaligursky
mvaligursky deleted the mv-single-camera-forward branch October 16, 2026 15:43

This branch was successfully deployed

2 active deployments
Preview – engine — d75a6357 Deployed Oct 16, 2026 by vercel[bot]
Preview – engine-api-docs — d75a6357 Deployed Oct 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant