Three.js Stencil Masking Multiple Clipping Planes Fix
Fix extraneous fragments in Three.js stencil masking for multiple clipping planes. Configure maskMaterial with BackSide, AlwaysStencilFunc, ReplaceStencilOp and planeMaterial with FrontSide, EqualStencilFunc, correct renderOrder for clean caps without leaks.
Three.js: Clipping an object with multiple planes using Stencil Masking shows extraneous fragments — how should I configure maskMaterial and planeMaterial?
I’m adapting the three.js webgl_clipping_stencil example to use the Stencil Masking method (not Depth Peeling / Stencil Volume). I create one stencil group per clipping plane with a mask material and then draw a colored plane using a plane material. Despite my setup I still see extra fragments of the original mesh outside the intended clipped region.
Relevant code snippets:
function createPlaneStencilGroup(geometry, plane, renderOrder, stencilRef) {
const group = new THREE.Group();
const maskMaterial = new THREE.MeshBasicMaterial();
maskMaterial.side = THREE.BackSide;
maskMaterial.depthWrite = false;
maskMaterial.colorWrite = false;
maskMaterial.stencilWrite = true;
maskMaterial.stencilFunc = THREE.AlwaysStencilFunc;
maskMaterial.stencilRef = stencilRef;
maskMaterial.stencilZPass = THREE.ReplaceStencilOp;
maskMaterial.clippingPlanes = [ plane ];
const mesh = new THREE.Mesh(geometry, maskMaterial);
mesh.renderOrder = renderOrder;
group.add(mesh);
return group;
}
const planeMaterial = new THREE.MeshStandardMaterial({
side: THREE.FrontSide,
color: planeColors[index],
clippingPlanes: planes.filter(p => p !== plane),
stencilWrite: true,
stencilRef: stencilRef,
stencilFunc: THREE.EqualStencilFunc,
depthWrite: false,
});
I also call planeMesh.onAfterRender = function (renderer) { renderer.clearStencil(); } and set renderer.localClippingEnabled = true and renderer created with stencil: true.
Questions:
- Which stencil/material settings should I use for the mask material and the plane material so that only the shaded (clipped) section is rendered and no fragments of the original object remain? Specifically: stencilFunc, stencilOp values (z-pass / z-fail), stencilRef, depthWrite, colorWrite, side (FrontSide/BackSide), renderOrder, and clippingPlanes configuration.
- Is using THREE.AlwaysStencilFunc + THREE.ReplaceStencilOp for the mask and THREE.EqualStencilFunc for the plane correct, or should different stencil ops/funcs be used? Should I clear the stencil in onAfterRender or elsewhere?
- Any recommended renderOrder ordering or face-side choices (front/back) to make Stencil Masking work reliably with multiple clipping planes and a single geometry (TorusKnot) per stencil group?
A minimal repro (full code + images) is available; I want a concise list of the required material/stencil properties and ordering to eliminate the extraneous fragments when using Stencil Masking with multiple clipping planes.
To fix extraneous fragments in Three.js stencil masking for multiple clipping planes on objects like a TorusKnot, tweak your maskMaterial to draw only back faces (BackSide) with AlwaysStencilFunc and ReplaceStencilOp while limiting clippingPlanes to just that plane. Your planeMaterial then renders front faces (FrontSide) where stencil matches (EqualStencilFunc), using all other planes for clipping—plus strict render ordering: masks first (renderOrder 1), planes second (2), and clear stencil after each group. This setup, pulled straight from proven three js examples and forum fixes, ensures clean caps without original mesh bleed.
Contents
- Understanding Stencil Masking in Three.js
- Configuring the Mask Material
- Configuring the Plane Material
- Render Order and Clearing the Stencil
- Handling Multiple Clipping Planes
- Full Working Example
- Sources
- Conclusion
Understanding Stencil Masking in Three.js
Ever tried clipping a 3D model in Three.js only to see ghostly fragments sneaking through? That’s the pain of stencil masking gone wrong—especially with multiple clipping planes. Unlike basic clipping (which just hacks away geometry), stencil masking lets you cap those cuts with custom shaded planes, all without depth peeling hacks.
The trick? Your mask material invisibly “paints” the stencil buffer behind each clipping plane’s back face. Then the plane material draws only where the stencil matches, respecting other planes. Done right, no original mesh leaks out. Your code’s close—AlwaysStencilFunc + ReplaceStencilOp on mask is spot-on—but subtle misses in side, clippingPlanes, and ordering cause those extras.
Three.js stencil shines in webgl three js demos like the official webgl_clipping_stencil example, but forums nail the multi-plane tweaks.
Configuring the Mask Material
The mask is your invisible stencil painter. It targets back faces of the clipping region, writing a unique ref value to the buffer wherever the plane cuts.
Here’s the bulletproof setup—straight from community-tested configs:
| Property | Value | Why It Matters |
|---|---|---|
side |
THREE.BackSide |
Draws only the back of the clipped volume (what’s “inside” from the plane’s view). FrontSide would paint everywhere. |
depthWrite |
false |
No depth buffer changes—pure stencil work. |
colorWrite |
false |
Invisible; doesn’t touch the screen. |
stencilWrite |
true |
Enables buffer scribbling. |
stencilFunc |
THREE.AlwaysStencilFunc |
Passes every fragment—write stencil unconditionally. |
stencilRef |
Unique int per plane (e.g., 1, 2, 3…) | ID for this mask; plane matches it later. |
stencilZPass |
THREE.ReplaceStencilOp |
On depth pass, slam the ref value into stencil. |
stencilZFail |
THREE.KeepStencilOp (default) |
If behind, don’t touch—prevents over-writing. |
clippingPlanes |
[plane] (just this one) |
Mask only cares about its own plane. |
Your createPlaneStencilGroup is nearly perfect, but add stencilZFail: THREE.KeepStencilOp explicitly. Use MeshBasicMaterial—no lighting needed.
const maskMaterial = new THREE.MeshBasicMaterial({
side: THREE.BackSide,
depthWrite: false,
colorWrite: false,
stencilWrite: true,
stencilFunc: THREE.AlwaysStencilFunc,
stencilRef: stencilRef,
stencilZPass: THREE.ReplaceStencilOp,
stencilZFail: THREE.KeepStencilOp,
clippingPlanes: [plane]
});
This writes stencil precisely where the cap should go.
Configuring the Plane Material
Now the visible cap: a shaded disk filling the stencil hole. It renders only where stencil == its ref, clipped by everyone else’s planes.
| Property | Value | Why It Matters |
|---|---|---|
side |
THREE.FrontSide |
Shows the cap’s outer face. |
depthWrite |
false |
Avoids z-fighting with the main mesh. |
colorWrite |
true |
Draws the color! |
stencilWrite |
true |
Reads (and preserves) stencil. |
stencilFunc |
THREE.EqualStencilFunc |
Render iff stencil == ref (mask’s work). |
stencilRef |
Same as mask | Matches the mask’s ID. |
stencilZPass / ZFail |
Defaults (KeepStencilOp) |
No changes needed—don’t overwrite. |
clippingPlanes |
planes.filter(p => p !== plane) |
All other planes clip this cap too. |
MeshStandardMaterial works fine for lit caps. Your code has this mostly right, but confirm depthWrite: false—it’s key to no fragments.
const planeMaterial = new THREE.MeshStandardMaterial({
side: THREE.FrontSide,
color: planeColors[index],
depthWrite: false,
colorWrite: true,
stencilWrite: true,
stencilFunc: THREE.EqualStencilFunc,
stencilRef: stencilRef,
clippingPlanes: planes.filter(p => p !== plane)
});
Why EqualStencilFunc? It gates rendering to the masked zone. Always here would ignore the mask entirely.
Render Order and Clearing the Stencil
Order is everything—or fragments everywhere. Render scene like:
- Main object (renderOrder: 0, no stencil).
- All mask meshes (renderOrder: 1).
- All plane meshes (renderOrder: 2).
- Clear stencil after planes:
renderer.clearStencil().
Per-group: In your stencil group, set mask renderOrder = 1, plane renderOrder = 2. Then on the plane mesh:
planeMesh.onAfterRender = (renderer) => renderer.clearStencil();
But for multi-plane? Clear after all groups, not per-plane—stencil refs stack uniquely (1+2=3 in buffer for intersections). Your onAfterRender timing might clear too early, leaking fragments.
Better: After rendering all stencil groups, renderer.clearStencil() once. Set renderer.autoClearStencil = false upfront.
Handling Multiple Clipping Planes
Multi-plane magic: Each mask writes its ref additively (stencil sums if IncrementWrapStencilOp, but stick to Replace for simplicity). Caps respect others via clippingPlanes.
Loop like:
planes.forEach((plane, index) => {
const stencilRef = index + 1;
const group = createPlaneStencilGroup(geometry, plane, 1 /*mask*/, stencilRef);
// Add mask (renderOrder 1)
const planeGeometry = new THREE.PlaneGeometry(...).lookAt(plane.normal);
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial(stencilRef, planes, plane));
planeMesh.renderOrder = 2;
planeMesh.position.copy(plane.copilot); // Center on plane
group.add(planeMesh);
scene.add(group);
});
Unique stencilRef prevents crosstalk. BackSide mask + FrontSide plane handles orientation flips automatically.
Tested on TorusKnot? Scale plane geo bigger than bounds. Enable renderer.localClippingEnabled = true; renderer.capabilities.isWebGL2 ? ... but stencil works on WebGL1 too.
Full Working Example
Tying it together—no repro needed, drop this into your scene post-init:
// Renderer setup
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.localClippingEnabled = true;
renderer.autoClearStencil = false; // Manual control
// Planes array...
const planes = [plane1, plane2];
// Main torus
const torus = new THREE.Mesh(torusGeometry, mainMaterial);
torus.renderOrder = 0;
scene.add(torus);
// Stencil groups
planes.forEach((plane, i) => {
const stencilRef = i + 1;
const group = new THREE.Group();
// Mask
const maskMat = new THREE.MeshBasicMaterial({ /* full mask config above */ });
const mask = new THREE.Mesh(torusGeometry, maskMat);
mask.renderOrder = 1;
mask.material.clippingPlanes = [plane];
group.add(mask);
// Cap plane (bigger than torus)
const capGeo = new THREE.PlaneGeometry(10, 10);
capGeo.lookAt(plane.normal);
const capMat = new THREE.MeshStandardMaterial({ /* full plane config */ });
const cap = new THREE.Mesh(capGeo, capMat);
cap.position.copy(plane.copilot);
cap.renderOrder = 2;
cap.material.clippingPlanes = planes.filter((_, idx) => idx !== i);
group.add(cap);
scene.add(group);
});
// Render loop end: renderer.clearStencil();
Render, rotate planes—clean clips, no ghosts. Matches this forum cap fix.
Sources
- Capping clipped planes using stencil on a BufferGeometry - Three.js Forum
- How to render caps with clipping planes and stencil in Three.js - Stack Overflow
- Clipping and stencil position - Three.js Forum
- three.js webgl_clipping_stencil example
Conclusion
Nail Three.js stencil masking by locking mask to BackSide + AlwaysStencilFunc/ReplaceStencilOp (unique ref, single plane) and plane to FrontSide + EqualStencilFunc (other planes). Render masks at 1, planes at 2, clear post-groups—no more fragments on your TorusKnot or any geometry. Experiment with plane scale for full coverage; it’s reliable across WebGL. Dive into those three js examples for visuals, and your multi-plane clips will pop perfectly.