How to render caps with clipping planes and stencil in Threejs with outer and inner mesh objects

Viewed 336

I’m new to three.js and stackoverflow. I’m trying to clip and cap three.js objects that have been rendered so I can move the helperPlane back and forth through the object to see inside it. There's an object inside it. What I’m looking to do is similar to this description of advanced clipping techniques in OpenGL here: More OpenGL Game Programming - Bonus - Advanced Clip Planes. So, if this can be done in OpenGL, there must be some way to do it in WebGL too?

I adapted the clipping_stencil example from threejs ( webgl - clipping stencil ), and everything looks right as long as I don’t move the helperPlanes. When the helperPlanes are moved, some of the cap faces of the larger mesh disappear, there’s some rendering artifacts-I think this is z-fighting-and the caps might not be rendered in the desired position.

Setting the renderingOrder property for the meshes was the big trick to getting the inner mesh to be rendered in the scene, but I don't know what to do about the z-fighting? when I move the clipping planes on the sliders.

I also posted this on discourse.threejs. Everything is on a JSFiddle. Any help would be greatly appreciated.

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

        let camera, scene, renderer, object, object2, stats;
        let planes, planeObjects, planeObjects2, planeHelpers;
        let clock;
        
        const params = {

            animate: false,
            planeX: {

                constant: 1,
                negated: false,
                displayHelper: false

            },
            planeY: {

                constant: 1,
                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;

            /* Subtract the mask created from the front-facing image 
            from the mask created from the back-facing image, we get 
            a new mask that represents the area where the clip edge 
            would be. Set the stencil buffer operation to increment 
            when rederering back-facing polygons and decrement on 
            front-facing polygons. This results in the desired mask 
            stored in the stencil buffer : http://glbook.gamedev.net/GLBOOK/glbook.gamedev.net/moglgp/advclip.html */

            // 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;
            
            //mat0.depthFunc = THREE.LessDepth;  // See reference above

            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;
            
            //mat1.depthFunc = THREE.LessDepth;

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

            group.add( mesh1 );

            return group;

        }
        
        function init(){
            //clock
            clock = new THREE.Clock();
        
            // scene
            scene = new THREE.Scene();
        
            // camera
            camera = new THREE.PerspectiveCamera(36, window.innerWidth/window.innerHeight, 1,100);
            camera.position.set(2,2,2);
            
            // Lights
            
            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);
            
            //Clipping planes
            planes = [
                new THREE.Plane( new THREE.Vector3( - 1, 0, 0 ), 1 ),
                new THREE.Plane( new THREE.Vector3( 0, - 1, 0 ), 1 ),
                new THREE.Plane( new THREE.Vector3( 0, 0, - 1 ), 0 )
            ];
            
            planeHelpers = planes.map( p => new THREE.PlaneHelper( p, 2, 0xffffff ) );
            planeHelpers.forEach( ph => {

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

            } );

            //Inner Cube        
            const geometry = new THREE.BoxGeometry( 0.5,0.5,0.5 );

            
            //Outer Cube    
            const geometry2 = new THREE.BoxGeometry( 1,1,1 );
            
            object = new THREE.Group();
            scene.add(object);
            
            //Set up clip plane rendering
            
            /*
            See https://discourse.threejs.org/t/capping-two-clipped-geometries-using-two-planes-which-are-negated-to-each-other/32643
            
            Object 1
            Render order 1: Draw front face / back face clipped and front face 
                            / back face not clipped (4 meshes)
            Render order 2: Draw planar clip cap
            
            Object 2
            Render order 3: Draw front face / back face clipped and front face 
                            / back face not clipped (4 meshes)
            Render order 4: Draw planar clip cap
            */
        
            planeObjects = [];
            planeObjects2 = [];
            const planeGeom = new THREE.PlaneGeometry( 4, 4 );

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

                const poGroup = new THREE.Group();
                const poGroup2 = new THREE.Group()
                
                const plane = planes[ i ];
                
                // Object 1
                const stencilGroup = createPlaneStencilGroup( geometry, 
                plane, i + 4 ); // Render after first group
                
                // Object 2
                const stencilGroup2 = createPlaneStencilGroup( geometry2, 
                plane, i + 1 ); // Render this first
                
                // PLANAR CLIP CAP
                // plane is clipped by the other clipping planes
                const planeMat =
                    new THREE.MeshStandardMaterial( {

                        color: 0xfff000, // inner torus colour
                        metalness: 0.1,
                        roughness: 0.75,
                        clippingPlanes: planes.filter( p => p !== plane ),
                        
                        //depthFunc: THREE.LessDepth,
                        
                        stencilWrite: true,
                        stencilRef: 0,
                        stencilFunc: THREE.NotEqualStencilFunc,
                        stencilFail: THREE.ReplaceStencilOp,
                        stencilZFail: THREE.ReplaceStencilOp,
                        stencilZPass: THREE.ReplaceStencilOp,

                    } );
                
                const planeMat2 =
                    new THREE.MeshStandardMaterial( {

                        color: 0xff0000, // inner torus colour
                        metalness: 0.1,
                        roughness: 0.75,
                        clippingPlanes: planes.filter( p => p !== plane ),

                        //depthFunc: THREE.LessDepth,

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

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

                    renderer.clearStencil();

                };
                
                po2.onAfterRender = function ( renderer ) {

                    renderer.clearStencil();

                };

                // Draw Planar Clip Cap
                po.renderOrder = i + 4.1; // Render last (slightly)
                po2.renderOrder = i + 1.1; // Render slightly after first group

                object.add( stencilGroup );
                object.add( stencilGroup2 );
                
                
                poGroup.add( po );
                poGroup2.add( po2 );
                
                
                planeObjects.push( po );
                planeObjects2.push( po2 );
                
                
                scene.add( poGroup );
                scene.add( poGroup2 );

            }
            
            // Object 1
            const material = new THREE.MeshStandardMaterial( {

                color: 0xfff000,  // outer torus colour
                metalness: 0.1,
                roughness: 0.75,
                clippingPlanes: planes,
                clipShadows: true,
                shadowSide: THREE.DoubleSide,

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

            // Object 2
            const material2 = new THREE.MeshStandardMaterial( {

                color: 0xff0000,  // outer colour
                metalness: 0.1,
                roughness: 0.75,
                side: THREE.DoubleSide,
                clippingPlanes: planes,
                clipShadows: true,
                shadowSide: THREE.DoubleSide,

            } );
            
            // add the color
            const clippedColorFront2 = new THREE.Mesh( geometry2, material2 );
            clippedColorFront2.castShadow = true;
            clippedColorFront2.renderOrder = 3;
            object.add( clippedColorFront2 );
            
            //Ground
            const ground = new THREE.Mesh(
                new THREE.PlaneGeometry(9,9,1,1),
                new THREE.MeshPhongMaterial({color:0x999999, 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.localClippingEnabled = true;
            
            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;

            }

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

                const plane = planes[ i ];
                
                // Planar clip cap for object 1
                const po = planeObjects[ i ];
                plane.coplanarPoint( po.position );
                
                // planar clip cap for object 2
                const po2 = planeObjects[ i ];
                plane.coplanarPoint( po2.position );
                
                // planar clip cap for object 1
                po.lookAt(
                    po.position.x - plane.normal.x,
                    po.position.y - plane.normal.y,
                    po.position.z - plane.normal.z,
                );
                
                // planar clip cap for object 2
                po2.lookAt(
                    po2.position.x - plane.normal.x,
                    po2.position.y - plane.normal.y,
                    po2.position.z - plane.normal.z,
                );

            }
            
            stats.begin();
            renderer.render( scene, camera );
            stats.end();

        }
        
        
1 Answers

I had some success with what I set out to do. This is an updated JSFiddle. I was able to implement capping an object inside another object with clipping and stencils. I included drag and orbit controls and gui to select the plane (x,y,z) to section along. I noticed some strange behaviour in rendering the caps depending on the object position and the rotation of the camera.

  • I needed to move the object further away from the camera to see the caps rendered when sectioning in the x and y planes, but not z
  • The caps seemed to disappear like a sliding door if I rotated the camera from positive x to negative x

So I think the caps are rendering in the same place as the clipping plane and depth testing can’t discriminate between the two at some camera points. I think moving the caps away from the clipping plane by some tolerance along a vector normal to the plane will get the caps to render at more angles when I move the camera. I tried this in my animate function:

    innerCap.translateOnAxis(clipPlane.normal, -1.5);

This gets the caps to render for a little more of an angle in the direction of negative x. I think this tolerance is some function of the distance from the object to the camera, but I’m not sure how to implement this. Thanks for your help.

Related