Stencil fail still writes depth?

I am trying to do something fairly simple, but I cannot figure out why I am getting the result I’m seeing.

I am trying to use stencil masking to render a golf hole on a previously rendered turf.

The ground is rendered in an earlier layer, I render the “lid” of a golf hole as a disc, writing 1 into the stencil buffer without depth test. I then render the “inside of a hole” below the lid. I disable depth test and I compare with stencil value 1. I correctly see the inside of the hole only where the lid applied its mask.

However. When I look at the depth I see that the inside of the hole, even where it was stencil failed, still writes to the depth buffer. This means that post effects using the depth buffer will get strange artifacts and act as if the whole inside of the hole was rendered, depth-wise.

I was under the impression that a failed stencil test would prevent depth being written in addition to preventing the colors from being written.

I am using the pc.CameraFrame class on the camera for post effects, so maybe that could somehow change the behavior of stencil and depth? And to be clear, the stencil operations work fine for the colors, it masks everything correctly. It’s just the depth that is still written even through a stencil fail.

Is this unexpected behavior, or am I barking up the wrong tree because the post effects (SSAO specifically in this case) in the pc.CameraFrame class doesn’t use the depth?

Here’s the AI analysis, and it seems spot on to me:

Root Cause: Depth Prepass Lacks Stencil Buffer

When CameraFrame is used with SSAO (or TAA/DOF), a depth prepass is automatically enabled. This prepass renders depth to a separate texture that SSAO reads from, but its render target has no stencil buffer, so stencil masking is completely ignored.

Here’s the chain of events:

1. SSAO forces prepass on:

frame-pass-camera-frame.jsLines 170-178

sanitizeOptions(options) {

options = Object.assign({}, _defaultOptions, options);

// automatically enable prepass when required internally

if (options.taaEnabled || options.ssaoType !== SSAOTYPE_NONE || options.dofEnabled) {

options.prepassEnabled = true;

}

return options;

}


2. Prepass render target has depth but NO stencil:

render-pass-prepass.jsLines 74-83

const renderTarget = new RenderTarget({

name: ‘PrepassRT’,

colorBuffer: this.linearDepthTexture,

// use depth buffer, but this can be discarded after the prepass as the depth is stored in the linearDepthTexture

depth: true,

// always single sampled

samples: 1

});


No stencil: true is passed, so RenderTarget defaults it to false.

3. Prepass renders ALL mesh instances with depthWrite:

render-pass-prepass.jsLines 120-131

for (let j = 0; j < meshInstances.length; j++) {

const meshInstance = meshInstances[j];

// only collect meshes that update the depth

if (meshInstance.material?.depthWrite) {

tempMeshInstances.push(meshInstance);

}

}

renderer.renderForwardLayer(camera, renderTarget, null, undefined, SHADER_PREPASS, this.viewBindGroups, {

meshInstances: tempMeshInstances

});


The “inside of the hole” mesh gets rendered here because it has depthWrite enabled. Since there’s no stencil buffer on this render target, the stencil test can never reject fragments. The depth is written for all fragments unconditionally (with respect to stencil).

4. SSAO reads from this prepass depth, not the main depth buffer:

render-pass-prepass.jsLines 91-94

after() {

// Assign the linear depth texture to the uniform

this.device.scope.resolve(DEPTH_UNIFORM_NAME).setValue(this.linearDepthTexture);

}


Summary

  • The main forward pass correctly applies stencil masking – colors and depth to the main render target are properly masked. The user’s stencil setup is working as intended there.

  • But the prepass runs before the main pass, into a separate framebuffer without stencil, so the “inside of the hole” writes its depth into the linear depth texture unconditionally.

  • SSAO (and any other effect using uSceneDepthMap) reads from this prepass linear depth texture, so it sees the unmasked depth of the hole interior.

Workaround for the User

The simplest workaround: set depthWrite: false on the “inside of the hole” material. Since it’s rendered only where stencil passes (inside the lid mask), it likely doesn’t need to contribute to the depth buffer. The prepass only includes meshes where material.depthWrite is true (line 124), so this would exclude it entirely.

Potential Engine Fix

To properly fix this, the prepass would need stencil support – either by adding a stencil attachment to PrepassRT and replicating the stencil-writing passes, or by providing a way for users to exclude specific mesh instances from the prepass. Both are non-trivial changes.

1 Like

Yes, this does sound like a relevant answer. Thanks!

My AI answer was not as good. I asked AI specifically about z pre-pass, and it told me that PlayCanvas didn’t have any know implementation of z pre-pass and discarded that as a cause. Guess it didn’t know about the pc.CameraFrame. :slight_smile:

In my case the inside of the hole does need depth because otherwise things (like the flag pole and the ball) will be depth fail once they go below ground level. So I think I’ll have to live with the artifacts sadly.

I do however feel like pre-pass ought to respect stencil. Otherwise post effects just simply won’t work with stencil masking.

Thanks a lot for the answer!

1 Like

This topic was automatically closed 3 days after the last reply. New replies are no longer allowed.