Skip to content

Material: Add per-face stencil properties#33313

Open
jpt wants to merge 3 commits intomrdoob:devfrom
countertype:stencil-back-face
Open

Material: Add per-face stencil properties#33313
jpt wants to merge 3 commits intomrdoob:devfrom
countertype:stencil-back-face

Conversation

@jpt
Copy link
Copy Markdown
Contributor

@jpt jpt commented Apr 2, 2026

Description

Add stencilBackFunc, stencilBackRef, stencilBackFail, stencilBackZFail, and stencilBackZPass properties to Material, enabling separate front/back stencil operations on a single DoubleSide mesh.

When any back-face property is non-null, the renderer uses per-face stencil state (gl.stencilFuncSeparate / gl.stencilOpSeparate on WebGL; depthStencil.stencilBack on WebGPU). When all are null (default), behavior is unchanged - back faces mirror front, as established in #33002

Masks (stencilFuncMask, stencilWriteMask) remain shared across both faces, matching the WebGPU spec which does not support per-face masks

Motivation

#33002 fixed stencilBack to match stencilFront in the WebGPU pipeline. This follow-up exposes the back-face state as a user-facing API so front and back can intentionally differ. The primary use case is nonzero-winding stencil fills (e.g. for resolution-independent text rendering via Loop-Blinn, which I just added to three-text. Loop-Blinn achieves better AA than Slug, which is why I didn't go that route - interesting that Eric Lengyel open sourced Slug exactly one week before the Loop-Blinn patent expired, probably a coincidence 😉)

Without per-face stencil ops, this requires two separate single-sided draw calls per stencil pass: one for front-face increment, one for back-face decrement. With this change, a single DoubleSide draw call handles both

Changes

  • src/materials/Material.js - 5 new properties (default null), copy(), toJSON()
  • src/renderers/webgl/WebGLState.js - cached separate-face stencil helpers
  • src/renderers/webgl-fallback/utils/WebGLState.js - same
  • src/renderers/webgpu/utils/WebGPUPipelineUtils.js - populate stencilBack from back properties; refactored _getStencilCompare() to accept raw value
  • src/renderers/webgpu/WebGPUBackend.js - back properties in pipeline cache key and needsUpdate
  • test/unit/src/materials/Material.tests.js - defaults, copy, serialization tests

@jpt jpt force-pushed the stencil-back-face branch from 7e9349d to b04a597 Compare April 2, 2026 04:42
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 2, 2026

📦 Bundle size

Full ESM build, minified and gzipped.

Before After Diff
WebGL 360.57
85.58
361.88
85.87
+1.31 kB
+292 B
WebGPU 635.35
176.35
638.94
176.9
+3.59 kB
+551 B
WebGPU Nodes 633.47
176.05
637.06
176.61
+3.59 kB
+557 B

🌳 Bundle size after tree-shaking

Minimal build including a renderer, camera, empty scene, and dependencies.

Before After Diff
WebGL 492.95
120.2
494.95
120.61
+2 kB
+410 B
WebGPU 707.53
191.25
711.81
191.88
+4.28 kB
+630 B
WebGPU Nodes 656.75
178.55
661.02
179.16
+4.28 kB
+610 B

@Mugen87 Mugen87 added this to the r184 milestone Apr 2, 2026
if ( this.stencilBackFail !== null ) data.stencilBackFail = this.stencilBackFail;
if ( this.stencilBackZFail !== null ) data.stencilBackZFail = this.stencilBackZFail;
if ( this.stencilBackZPass !== null ) data.stencilBackZPass = this.stencilBackZPass;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

MaterialLoader also needs an update so the deserialization works:

if ( json.stencilFunc !== undefined ) material.stencilFunc = json.stencilFunc;
if ( json.stencilRef !== undefined ) material.stencilRef = json.stencilRef;
if ( json.stencilFuncMask !== undefined ) material.stencilFuncMask = json.stencilFuncMask;
if ( json.stencilFail !== undefined ) material.stencilFail = json.stencilFail;
if ( json.stencilZFail !== undefined ) material.stencilZFail = json.stencilZFail;
if ( json.stencilZPass !== undefined ) material.stencilZPass = json.stencilZPass;
if ( json.stencilWrite !== undefined ) material.stencilWrite = json.stencilWrite;

setStencilMask( stencilMask ) {

if ( this.currentStencilMask !== stencilMask ) {
if ( this.currentStencilSeparate === true || this.currentStencilMask !== stencilMask ) {
Copy link
Copy Markdown
Collaborator

@Mugen87 Mugen87 Apr 2, 2026

Choose a reason for hiding this comment

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

I'm not sure these checks are robust enough:

If you have used separate mask and then switch to a uniform mask, gl.stencilMask() might not be executed if this.currentStencilMask is equal to stencilMask. Meaning it would leave the back faces with a wrong value (the one from the separate call).

I think you must implement a check that reconginizes when currentStencilSeparate changes. If it does, it must invalidate the cached properties so the WebGL state is forced to update.

Func and Op are also affected by this issue.


/**
* The stencil reference value for back faces, or `null` to use
* {@link Material#stencilRef} for both faces.
Copy link
Copy Markdown
Collaborator

@Mugen87 Mugen87 Apr 2, 2026

Choose a reason for hiding this comment

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

stencilBackRef is ignored in WebGPU. WebGPU only offers GPURenderPassEncoder.setStencilReference() that means you can only set a single stencil reference for front and back faces.

We could a) remove stencilBackRef or b) just document that it is WebGL 2 only. That might cause rendering issue though. A feature that works in WebGL 2 won't work in WebGPU.

I vote doing the same with masks and adhere to the WebGPU spec. What do you think?

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.

2 participants