Stencil Clipping GLTF - Weird Backfaces

Hello!

I’m trying to use the stencil buffer to clip a GLTF on the Y axis. I’m building off of this ThreeJS example: https://threejs.org/examples/webgl_clipping_stencil.html

When I clip the torus primitive model it looks great. But when I load the Damaged Helmet GLTF file I get some weird rendering on the backfaces, and it doesn’t seem to be rendering in the right order in areas above the clipping plane. I’m not sure what I’m doing wrong. Here are some screenshots of what it looks like:


And here is the code I’m using to clip the model, it’s basically the same as the ThreeJS example linked above. Any suggestions on what I could do to fix it would be amazing! Thanks!!

import * as THREE from 'three';
import { OrbitControls } from './jsm/controls/OrbitControls.js';
import { GUI } from './jsm/libs/lil-gui.module.min.js';
import Stats from './jsm/libs/stats.module.js';

import { GLTFLoader } from './jsm/loaders/GLTFLoader.js';
import { RGBELoader } from './jsm/loaders/RGBELoader.js';

let camera, scene, renderer, object, stats;
let planes, planeObjects, planeHelpers;
let clock;

let geometry2, globalPlane;

const params = {

    animate: true,
    planeX: {

        constant: 0,
        negated: false,
        displayHelper: false

    },
    planeY: {

        constant: 0,
        negated: false,
        displayHelper: false

    },
    planeZ: {

        constant: 0,
        negated: false,
        displayHelper: false

    }


};

init();
animate();

function createPlaneStencilGroup( geometry, plane, renderOrder ) {

    const group = new THREE.Group();
    const baseMat = new THREE.MeshBasicMaterial();
    baseMat.depthWrite = false;
    baseMat.depthTest = false;
    baseMat.colorWrite = false;
    baseMat.stencilWrite = true;
    baseMat.stencilFunc = THREE.AlwaysStencilFunc;

    // back faces
    const mat0 = baseMat.clone();
    mat0.side = THREE.BackSide;
    mat0.clippingPlanes = [ plane ];
    mat0.stencilFail = THREE.IncrementWrapStencilOp;
    mat0.stencilZFail = THREE.IncrementWrapStencilOp;
    mat0.stencilZPass = THREE.IncrementWrapStencilOp;

    const mesh0 = new THREE.Mesh( geometry, mat0 );
    mesh0.renderOrder = renderOrder;
    group.add( mesh0 );

    // front faces
    const mat1 = baseMat.clone();
    mat1.side = THREE.FrontSide;
    mat1.clippingPlanes = [ plane ];
    mat1.stencilFail = THREE.DecrementWrapStencilOp;
    mat1.stencilZFail = THREE.DecrementWrapStencilOp;
    mat1.stencilZPass = THREE.DecrementWrapStencilOp;

    const mesh1 = new THREE.Mesh( geometry, mat1 );
    mesh1.renderOrder = renderOrder;

    group.add( mesh1 );

    return group;

}

function init() {

    clock = new THREE.Clock();

    scene = new THREE.Scene();

    camera = new THREE.PerspectiveCamera( 36, window.innerWidth / window.innerHeight, 1, 100 );
    camera.position.set( 2, 2, 2 );

    scene.add( new THREE.AmbientLight( 0xffffff, 0.5 ) );

    const dirLight = new THREE.DirectionalLight( 0xffffff, 1 );
    dirLight.position.set( 5, 10, 7.5 );
    dirLight.castShadow = true;
    dirLight.shadow.camera.right = 2;
    dirLight.shadow.camera.left = - 2;
    dirLight.shadow.camera.top	= 2;
    dirLight.shadow.camera.bottom = - 2;

    dirLight.shadow.mapSize.width = 1024;
    dirLight.shadow.mapSize.height = 1024;
    scene.add( dirLight );

    planes = [
        new THREE.Plane( new THREE.Vector3( - 1, 0, 0 ), 1000 ),
        new THREE.Plane( new THREE.Vector3( 0, - 1, 0 ), 0 ),
        new THREE.Plane( new THREE.Vector3( 0, 0, - 1 ), 1000 )
    ];

    planeHelpers = planes.map( p => new THREE.PlaneHelper( p, 2, 0xffffff ) );
    planeHelpers.forEach( ph => {

        ph.visible = false;
        scene.add( ph );

    } );

    //default model from example
    const geometry = new THREE.TorusKnotGeometry( 0.4, 0.15, 220, 60 );
    object = new THREE.Group();
    scene.add( object );

    //load custom GLTF model
    new RGBELoader()
		.setPath('textures/equirectangular/')
		.load('table_mountain_1_1k.hdr', function (texture) {

			texture.mapping = THREE.EquirectangularReflectionMapping;

			// scene.background = texture;
			scene.background = new THREE.Color( 0x777777 );;
			scene.environment = texture;

			// model
			const loader = new GLTFLoader().setPath('models/gltf/DamagedHelmet/glTF/');
            loader.load('DamagedHelmet.gltf', function (gltf) {
                geometry2 = gltf.scene;
                geometry2.traverse(function(child) {
                    if (child.isMesh){
                        console.log(child.name);
                        // SetUpModel(geometry);
                        SetUpModel(child.geometry);
                    }
                  });

                  Finished();

			});
			

		});
    }

function SetUpModel(gltfObj){

    // Set up clip plane rendering
    planeObjects = [];
    const planeGeom = new THREE.PlaneGeometry( 4, 4 );

    for ( let i = 0; i < 3; i ++ ) {

        const poGroup = new THREE.Group();
        const plane = planes[ i ];
        const stencilGroup = createPlaneStencilGroup( gltfObj, plane, i + 1 );

        // plane is clipped by the other clipping planes
        const planeMat =
            new THREE.MeshStandardMaterial( {

                color: 0xE91E63,
                metalness: 0.1,
                roughness: 0.75,
                clippingPlanes: planes.filter( p => p !== plane ),

                stencilWrite: true,
                stencilRef: 0,
                stencilFunc: THREE.NotEqualStencilFunc,
                stencilFail: THREE.ReplaceStencilOp,
                stencilZFail: THREE.ReplaceStencilOp,
                stencilZPass: THREE.ReplaceStencilOp,

            } );

        const po = new THREE.Mesh( planeGeom, planeMat );
        po.onAfterRender = function ( renderer ) {
            renderer.clearStencil();
        };

        po.renderOrder = i + 1.1;

        object.add( stencilGroup );
        poGroup.add( po );
        planeObjects.push( po );
        scene.add( poGroup );

    }

    //main color
    const material = new THREE.MeshStandardMaterial( {

        color: 0xFFC107,
        metalness: 0.1,
        roughness: 0.75,
        clippingPlanes: planes,
        clipShadows: true,
        shadowSide: THREE.DoubleSide,

    } );

    // add the color
    const clippedColorFront = new THREE.Mesh( gltfObj, material );
    clippedColorFront.castShadow = true;
    clippedColorFront.renderOrder = 6;
    object.add( clippedColorFront );
}

function Finished(){

    console.log("finished");

    const ground = new THREE.Mesh(
        new THREE.PlaneGeometry( 9, 9, 1, 1 ),
        new THREE.ShadowMaterial( { color: 0x000000, opacity: 0.25, side: THREE.DoubleSide } )
    );

    ground.rotation.x = - Math.PI / 2; // rotates X/Y to X/Z
    ground.position.y = - 1;
    ground.receiveShadow = true;
    scene.add( ground );

    // Stats
    stats = new Stats();
    document.body.appendChild( stats.dom );

    // Renderer
    renderer = new THREE.WebGLRenderer( { antialias: true } );
    renderer.shadowMap.enabled = true;
    renderer.setPixelRatio( window.devicePixelRatio );
    renderer.setSize( window.innerWidth, window.innerHeight );
    renderer.setClearColor( 0x263238 );
    window.addEventListener( 'resize', onWindowResize );
    document.body.appendChild( renderer.domElement );

    // renderer.clippingPlanes = [ globalPlane ];
    renderer.localClippingEnabled = true;

    // Controls
    const controls = new OrbitControls( camera, renderer.domElement );
    controls.minDistance = 2;
    controls.maxDistance = 20;
    controls.update();

    // GUI
    const gui = new GUI();
    gui.add( params, 'animate' );

    const planeX = gui.addFolder( 'planeX' );
    planeX.add( params.planeX, 'displayHelper' ).onChange( v => planeHelpers[ 0 ].visible = v );
    planeX.add( params.planeX, 'constant' ).min( - 1 ).max( 1 ).onChange( d => planes[ 0 ].constant = d );
    planeX.add( params.planeX, 'negated' ).onChange( () => {

        planes[ 0 ].negate();
        params.planeX.constant = planes[ 0 ].constant;

    } );
    planeX.open();

    const planeY = gui.addFolder( 'planeY' );
    planeY.add( params.planeY, 'displayHelper' ).onChange( v => planeHelpers[ 1 ].visible = v );
    planeY.add( params.planeY, 'constant' ).min( - 1 ).max( 1 ).onChange( d => planes[ 1 ].constant = d );
    planeY.add( params.planeY, 'negated' ).onChange( () => {

        planes[ 1 ].negate();
        params.planeY.constant = planes[ 1 ].constant;

    } );
    planeY.open();

    const planeZ = gui.addFolder( 'planeZ' );
    planeZ.add( params.planeZ, 'displayHelper' ).onChange( v => planeHelpers[ 2 ].visible = v );
    planeZ.add( params.planeZ, 'constant' ).min( - 1 ).max( 1 ).onChange( d => planes[ 2 ].constant = d );
    planeZ.add( params.planeZ, 'negated' ).onChange( () => {

        planes[ 2 ].negate();
        params.planeZ.constant = planes[ 2 ].constant;

    } );
    planeZ.open();

}

function onWindowResize() {

    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();

    renderer.setSize( window.innerWidth, window.innerHeight );

}

function animate() {

    const delta = clock.getDelta();

    requestAnimationFrame( animate );

    if ( params.animate ) {

        object.rotation.x += delta * 0.5;
        object.rotation.y += delta * 0.2;

    }

    if(planeObjects != undefined){
        for ( let i = 0; i < planeObjects.length; i ++ ) {

            const plane = planes[ i ];
            const po = planeObjects[ i ];
            plane.coplanarPoint( po.position );
            po.lookAt(
                po.position.x - plane.normal.x,
                po.position.y - plane.normal.y,
                po.position.z - plane.normal.z,
            );
    
        }
        stats.begin();
        renderer.render( scene, camera );
        stats.end();
    }
    

    

}

Seems related and happening on all nontrivial models - 3DM clipping flickering

Hmm that’s interesting, but I’m not getting flickering on the GLTF. It looks more like there is an issue with how it is being rendered. Here is a short video of the damaged helmet GLTF

In order for this kind of effect to work the geometry bring clipped needs to be two manifold. Ie every edge must have exactly one connected triangle and be water tight. No triangles must be duplicated, etc. The helmet geometry does not meet the necessary requirements

2 Likes

Ah! Ok so that might be an issue for what I’m trying to do. Do you know of any other ThreeJS examples/libraries that allow you to clip a GLTF model like this? Thanks a bunch!

These conditions are more or less required to perform any kind of clipping like this without artifacts even analytical, geometric clipping.

Well crap, that’s annoying. As an alternative, I was looking at doing a boolean using this plugin, but it looks like it needs to be a solid watertight model for it to work https://github.com/oathihs/ThreeCSG

Is there a way to do booleans on non watertight models in ThreeJS?

Ha boolean operations require water tight models. Otherwise the expected result can be ambiguous or impossible. You’re best off modifying the model to be water tight if you need the effect.

I figured :frowning:

ok thanks for all the help!

there was a lib that cuts a mesh with a plane somewhere,… maybe this? I dont think it cares if the mesh is water-tight or not

Ooo that’s pretty cool. I downloaded that library and tested it out, and it looks like it doesn’t always successfully slice the model.

I adjusted the torus’s position and sometimes it caps the mesh and sometimes it doesn’t.

For my use case I am going to just slice the model in blender manually. I’d prefer to do it in realtime in ThreeJS but it seems that it’s not going to work reliably.

that seems sliced just fine to me? also you could do real-time slicing in the shader with no problems and I thought this is what clipping planes do, apparently not? never thought that they use stencils

Oh whoa! Yes! That would work great! Thanks for linking that example :slight_smile:

The issue I was pointing out with that other screenshot is the lack of the cap on the end, sometimes it doesn’t show up based on where it is clipping the model

Hmm maybe I got too excited too quickly. I’m getting weird rendering of backfaces when I forked that example you linked to and swapped the model for the damaged helmet: https://jsfiddle.net/curlytalegames/c7ykn1uo/1/

I dont see anything weird about it? remember that the example does not “fill” the hole left by the slicing plane, so you can see the hidden inner surfaces through the hole perfectly fine.

ah right, if that was a problem for you with that js lib, then the shader trick will not make you happy either, sorry.

I had a similar issue and it was down to the quality of the incoming meshes. The normals have to be correct and I found it helped to have no transforms on the object, so in my app the transforms get baked into the vertices. I use a worker to ‘freeze transformations’ and recalculate normals when the model is loaded in. It adds a lot of complication, and doesn’t work in the case of animated meshes, but for my purposes, with a known input data source, it works every time.