Search code examples
three.jsshader

Render "hard" edges using custom shader


I'd like to reproduce the effect created by using THREE.EdgesHelper (drawing a boundary on "hard" object edges), but using a custom shader rather than adding a separate THREE.Line object. Essentially I'd like to do what's done in this demo, but only for the "hard" boundaries; e.g. boundaries that are not between two coplanar faces

Approach: apply similar routine to EdgesHelper, but mark vertices that are in hard edges with a custom attribute (e.g. isEdge); probably need to use BufferGeometry, since regular Geometry allows re-use of vertices in multiple faces, but BufferGeometry duplicates vertices such that each vertex is part of only one face (at least, this is my understanding; the documentation isn't explicit).

Progress so far:

  1. Reproduced the effect in the wireframe materials example, but using BufferGeometry:\
    http://jsfiddle.net/ogav6o77/\

        function BufferEdgesHelper(geometry) {
            
            var positions = geometry.attributes.position.array;
            var normals = geometry.attributes.normal.array;
            
            // Build new attribute storing barycentric coordinates
            // for each vertex
            var centers = new THREE.BufferAttribute(new Float32Array( 3 * positions.length ), 3);
            for( var f = 0; f < positions.length; f += 9 ) {
                centers.array[ f + 0 ] = 1;
                centers.array[ f + 1 ] = 0;
                centers.array[ f + 2 ] = 0;
    
                centers.array[ f + 3 ] = 0;
                centers.array[ f + 4 ] = 1;
                centers.array[ f + 5 ] = 0;
    
                centers.array[ f + 6 ] = 0;
                centers.array[ f + 7 ] = 0;
                centers.array[ f + 8 ] = 1;
            }
            geometry.addAttribute( 'center', centers );
    
        }
    
        // Build geometry 
        var geometry = new THREE.BoxGeometry(1, 1, 1);
        geometry.computeFaceNormals();
        geometry.computeTangents();
        geometry = new THREE.BufferGeometry().fromGeometry(geometry);
        BufferEdgesHelper(geometry);
    
        // Build shader
        var vertexShader = document.getElementById( 'vertexShader' ).textContent;
        var fragmentShader = document.getElementById( 'fragmentShader' ).textContent;
    
        var material = new THREE.ShaderMaterial( { 
            uniforms: {}, 
            attributes: {
                'center': { type: 'v3', value: null,  boundTo: 'faceVertices' }
            }, 
            vertexShader: vertexShader, 
            fragmentShader: fragmentShader 
        } );
    
        var cube = new THREE.Mesh(geometry, material);
    
        // ------------------------------------------------------------------------
        // (Boilerplate)
    
        var scene = new THREE.Scene();
        var camera = new THREE.PerspectiveCamera(75, 
                                                 window.innerWidth / 
                                                 window.innerHeight, 
                                                 0.1, 1000);
    
        var renderer = new THREE.WebGLRenderer();
        renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(renderer.domElement);
        scene.add(cube);
    
        camera.position.z = 5;
    
        var render = function () {
            requestAnimationFrame(render);
            cube.rotation.x += 0.01;
            cube.rotation.y += 0.01;
            renderer.render(scene, camera);
        };
    
        render();
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r100/three.min.js"></script>
    <script type="x-shader/x-vertex" id="vertexShader">
        attribute vec3 center;
        varying vec3 vCenter;
        
        void main() {
    
            vCenter = center;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    
        }
    </script>
    <script type="x-shader/x-fragment" id="fragmentShader">
        #extension GL_OES_standard_derivatives: enable
    
        varying vec3 vCenter;
    
        float edgeFactorTri() {
    
            vec3 d = fwidth(vCenter.xyz);
            vec3 a3 = smoothstep(vec3(0.0), d * 1.5, vCenter.xyz);
            return min(min(a3.x, a3.y), a3.z);
    
        }
    
        void main() {
            gl_FragColor.rgb = mix(vec3(1.0), vec3(0.2), edgeFactorTri());
            gl_FragColor.a = 1.0;
        }
    </script>

  2. Port the logic of EdgesHelper to a "BufferEdgesHelper" function that works with BufferGeometry (but still use it to create a THREE.Line):
    http://jsfiddle.net/L2aertya/\

        function BufferEdgesHelper(geometry) {
    
            var positions = geometry.attributes.position.array;
            var normals = geometry.attributes.normal.array;
    
            // Build new attribute storing barycentric coordinates
            // for each vertex
            var centers = new THREE.BufferAttribute(new Float32Array( 3 * positions.length ), 3);
            for( var f = 0; f < positions.length; f += 9 ) {
                centers.array[ f + 0 ] = 1;
                centers.array[ f + 1 ] = 0;
                centers.array[ f + 2 ] = 0;
    
                centers.array[ f + 3 ] = 0;
                centers.array[ f + 4 ] = 1;
                centers.array[ f + 5 ] = 0;
    
                centers.array[ f + 6 ] = 0;
                centers.array[ f + 7 ] = 0;
                centers.array[ f + 8 ] = 1;
            }
            geometry.addAttribute( 'center', centers );
    
    
            // Hash all the edges and remember which face they're associated with
            // (Adapted from THREE.EdgesHelper)
            function sortFunction ( a, b ) { 
                // Lexicographic sort
                if (a[0] - b[0] != 0) {
                    return (a[0] - b[0]);
                } else if (a[1] - b[1] != 0) { 
                    return (a[1] - b[1]);
                } else { 
                    return (a[2] - b[2]);
                }
            }
            var edge = [ 0, 0 ];
            var hash = {};
            var face;
            var numEdges = 0;
    
            for (var i = 0; i < positions.length/9; i++) {
                var a = i * 9 
                face = [ [ positions[a+0], positions[a+1], positions[a+2] ] ,
                         [ positions[a+3], positions[a+4], positions[a+5] ] ,
                         [ positions[a+6], positions[a+7], positions[a+8] ] ];
                for (var j = 0; j < 3; j++) {
                    var k = (j + 1) % 3;
                    var b = j * 3
                    var c = k * 3
                    edge[ 0 ] = face[ j ];
                    edge[ 1 ] = face[ k ];
                    edge.sort( sortFunction );
                    key = edge[0] + ' | ' + edge[1];
                    if ( hash[ key ] == undefined ) {
                        hash[ key ] = { vert1: a + b, vert2: a + c, face1: a, face2: undefined };
                        numEdges++;
                    } else { 
                        hash[ key ].face2 = a;
                    }
                }
            }
    
            // Build a new geometry containing only the "hard" edges
            var geometry2 = new THREE.BufferGeometry();
            var coords = new Float32Array( numEdges * 2 * 3 );
            var index = 0;
            for (key in hash) {
                h = hash[key];
                
                // ditch any edges that are bordered by two coplanar faces
                if ( h.face2 !== undefined ) {
                    normal1 = new THREE.Vector3(normals[h.face1+0], normals[h.face1+1], normals[h.face1+2]);
                    normal2 = new THREE.Vector3(normals[h.face2+0], normals[h.face2+1], normals[h.face2+2]);
                    if ( normal1.dot( normal2 ) >= 0.9999 ) { continue; }
                }
    
                coords[ index ++ ] = positions[h.vert1+0];
                coords[ index ++ ] = positions[h.vert1+1];
                coords[ index ++ ] = positions[h.vert1+2];
    
                coords[ index ++ ] = positions[h.vert2+0];
                coords[ index ++ ] = positions[h.vert2+1];
                coords[ index ++ ] = positions[h.vert2+2];
            }
            geometry2.addAttribute( 'position', new THREE.BufferAttribute( coords, 3 ) );
    
            // Build Line object from the geometry
            return new THREE.Line(geometry2, new THREE.LineBasicMaterial( { color: 0xff0000 } ), THREE.LinePieces);
        }
    
        // Build geometry 
        var geometry = new THREE.BoxGeometry(1, 1, 1);
        geometry.computeFaceNormals();
        geometry.computeTangents();
        geometry = new THREE.BufferGeometry().fromGeometry(geometry);
        var line = BufferEdgesHelper(geometry);
    
        // Build shader
        var vertexShader = document.getElementById( 'vertexShader' ).textContent;
        var fragmentShader = document.getElementById( 'fragmentShader' ).textContent;
    
        var material = new THREE.ShaderMaterial( { 
            uniforms: {}, 
            attributes: {
                'center': { type: 'v3', value: null,  boundTo: 'faceVertices' }
            }, 
            vertexShader: vertexShader, 
            fragmentShader: fragmentShader 
        } );
    
        var cube = new THREE.Mesh(geometry, material);
    
        // ------------------------------------------------------------------------
        // (Boilerplate)
    
        var scene = new THREE.Scene();
        var camera = new THREE.PerspectiveCamera(75, 
                                                 window.innerWidth / 
                                                 window.innerHeight, 
                                                 0.1, 1000);
    
        var renderer = new THREE.WebGLRenderer();
        renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(renderer.domElement);
        scene.add(cube);
        scene.add(line);
    
        camera.position.z = 5;
    
        var render = function () {
            requestAnimationFrame(render);
            cube.rotation.x += 0.01;
            cube.rotation.y += 0.01;
            line.rotation.x = cube.rotation.x;
            line.rotation.y = cube.rotation.y;
            renderer.render(scene, camera);
        };
    
        render();
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r100/three.min.js"></script>
    <script type="x-shader/x-vertex" id="vertexShader">
        attribute vec3 center;
        varying vec3 vCenter;
        
        void main() {
    
            vCenter = center;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    
        }
    </script>
    <script type="x-shader/x-fragment" id="fragmentShader">
        #extension GL_OES_standard_derivatives: enable
    
        varying vec3 vCenter;
    
        float edgeFactorTri() {
    
            vec3 d = fwidth(vCenter.xyz);
            vec3 a3 = smoothstep(vec3(0.0), d * 1.5, vCenter.xyz);
            return min(min(a3.x, a3.y), a3.z);
    
        }
    
        void main() {
            gl_FragColor.rgb = mix(vec3(1.0), vec3(0.2), edgeFactorTri());
            gl_FragColor.a = 1.0;
        }
    </script>

  3. Attempted to adapt the BufferEdgesHelper to save its results in a custom attribute (isEdge), then read that attribute in the custom shader when deciding whether to render the edge or not: http://jsfiddle.net/4tf4c6sf/ \

        function BufferEdgesHelper(geometry) {
    
            var positions = geometry.attributes.position.array;
            var normals = geometry.attributes.normal.array;
    
            // Build new attribute storing barycentric coordinates
            // for each vertex
            var centers = new THREE.BufferAttribute(new Float32Array( 3 * positions.length ), 3);
            for( var f = 0; f < positions.length; f += 9 ) {
                centers.array[ f + 0 ] = 1;
                centers.array[ f + 1 ] = 0;
                centers.array[ f + 2 ] = 0;
    
                centers.array[ f + 3 ] = 0;
                centers.array[ f + 4 ] = 1;
                centers.array[ f + 5 ] = 0;
    
                centers.array[ f + 6 ] = 0;
                centers.array[ f + 7 ] = 0;
                centers.array[ f + 8 ] = 1;
            }
            geometry.addAttribute( 'center', centers );
    
    
            // Hash all the edges and remember which face they're associated with
            // (Adapted from THREE.EdgesHelper)
            function sortFunction ( a, b ) { 
                if (a[0] - b[0] != 0) {
                    return (a[0] - b[0]);
                } else if (a[1] - b[1] != 0) { 
                    return (a[1] - b[1]);
                } else { 
                    return (a[2] - b[2]);
                }
            }
            var edge = [ 0, 0 ];
            var hash = {};
            var face;
            var numEdges = 0;
    
            for (var i = 0; i < positions.length/9; i++) {
                var a = i * 9 
                face = [ [ positions[a+0], positions[a+1], positions[a+2] ] ,
                         [ positions[a+3], positions[a+4], positions[a+5] ] ,
                         [ positions[a+6], positions[a+7], positions[a+8] ] ];
                for (var j = 0; j < 3; j++) {
                    var k = (j + 1) % 3;
                    var b = j * 3
                    var c = k * 3
                    edge[ 0 ] = face[ j ];
                    edge[ 1 ] = face[ k ];
                    edge.sort( sortFunction );
                    key = edge[0] + ' | ' + edge[1];
                    if ( hash[ key ] == undefined ) {
                        hash[ key ] = { vert1: a + b, vert2: a + c, face1: a, face2: undefined };
                        numEdges++;
                    } else { 
                        hash[ key ].face2 = a;
                    }
                }
            }
    
            // Build a new geometry containing only the "hard" edges,
            // but also save this information to a custom attribute 
            // of the original geometry
            var isEdge = new THREE.BufferAttribute(new Float32Array( 3 * positions.length ), 1);
            var geometry2 = new THREE.BufferGeometry();
            var coords = new Float32Array( numEdges * 2 * 3 );
            var index = 0;
            for (key in hash) {
                h = hash[key];
                
                // ditch any edges that are bordered by two coplanar faces
                if ( h.face2 !== undefined ) {
                    normal1 = new THREE.Vector3(normals[h.face1+0], normals[h.face1+1], normals[h.face1+2]);
                    normal2 = new THREE.Vector3(normals[h.face2+0], normals[h.face2+1], normals[h.face2+2]);
                    if ( normal1.dot( normal2 ) >= 0.9999 ) { continue; }
                }
    
                // save edge vertices to the new geometry
                coords[ index ++ ] = positions[h.vert1+0];
                coords[ index ++ ] = positions[h.vert1+1];
                coords[ index ++ ] = positions[h.vert1+2];
    
                coords[ index ++ ] = positions[h.vert2+0];
                coords[ index ++ ] = positions[h.vert2+1];
                coords[ index ++ ] = positions[h.vert2+2];
    
                // mark edge vertices as such in a custom attribute
                isEdge.array[h.vert1+0] = 1.0;
                isEdge.array[h.vert1+1] = 1.0;
                isEdge.array[h.vert1+2] = 1.0;
                isEdge.array[h.vert2+0] = 1.0;
                isEdge.array[h.vert2+1] = 1.0;
                isEdge.array[h.vert2+2] = 1.0;
            }
            geometry2.addAttribute( 'position', new THREE.BufferAttribute( coords, 3 ) );
            geometry.addAttribute( 'isEdge', isEdge );
    
            return new THREE.Line(geometry2, new THREE.LineBasicMaterial( { color: 0xff0000 } ), THREE.LinePieces);
        }
    
        // Build geometry 
        var geometry = new THREE.BoxGeometry(1, 1, 1);
        geometry.computeFaceNormals();
        geometry.computeTangents();
        geometry = new THREE.BufferGeometry().fromGeometry(geometry);
        var line = BufferEdgesHelper(geometry);
    
        // Build shader
        var vertexShader = document.getElementById( 'vertexShader' ).textContent;
        var fragmentShader = document.getElementById( 'fragmentShader' ).textContent;
    
        var material = new THREE.ShaderMaterial( { 
            uniforms: {}, 
            attributes: {
                'center': { type: 'v3', value: null,  boundTo: 'faceVertices' },
                'isEdge': { type: 'f', value: null }
            }, 
            vertexShader: vertexShader, 
            fragmentShader: fragmentShader 
        } );
    
        var cube = new THREE.Mesh(geometry, material);
    
        // ------------------------------------------------------------------------
        // (Boilerplate)
    
        var scene = new THREE.Scene();
        var camera = new THREE.PerspectiveCamera(75, 
                                                 window.innerWidth / 
                                                 window.innerHeight, 
                                                 0.1, 1000);
    
        var renderer = new THREE.WebGLRenderer();
        renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(renderer.domElement);
        scene.add(cube);
        scene.add(line);
    
        camera.position.z = 5;
    
        var render = function () {
            requestAnimationFrame(render);
            cube.rotation.x += 0.01;
            cube.rotation.y += 0.01;
            line.rotation.x = cube.rotation.x;
            line.rotation.y = cube.rotation.y;
            renderer.render(scene, camera);
        };
    
        render();
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r100/three.min.js"></script>
    
    <script type="x-shader/x-vertex" id="vertexShader">
        attribute vec3 center;
        varying vec3 vCenter;
        
        attribute float isEdge;
        varying float vIsEdge;
    
        void main() {
    
            vCenter = center;
            vIsEdge = isEdge;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    
        }
    </script>
    <script type="x-shader/x-fragment" id="fragmentShader">
        #extension GL_OES_standard_derivatives: enable
    
        varying vec3 vCenter;
        varying float vIsEdge;
    
        float edgeFactorTri() {
    
            vec3 d = fwidth(vCenter.xyz);
            vec3 a3 = smoothstep(vec3(0.0), d * 1.5, vCenter.xyz);
            return min(min(a3.x, a3.y), a3.z);
    
        }
    
        void main() {
            if (vIsEdge > 0.5) {
                gl_FragColor.rgb = mix(vec3(1.0), vec3(0.2), edgeFactorTri());
            } else {
                gl_FragColor.rgb = vec3(0.2);
            }
            gl_FragColor.a = 1.0;
        }
    </script>

The first two fiddles work as expected, showing (1) the white wireframe edge rendered by the shader, then (2) the white edges from the shader plus the red "hard" edges from the Line. However, (3) gives the same results as (2), rather than using the isEdge attribute to decide whether to draw a line or not; I can't figure out why that is.

Any idea how to fix this so that only the hard edges are rendered by the shader (e.g. the red and white lines overlap)?

Thanks!


Solution

  • First off, the edge-pruning algorithm needs to be tuned a bit. You need to save the vertices for both faces, not just the first face, because you need to alter both triangles associated with the edge for them to render properly using barycentric coordinates.

    Second, I think that this can be done without a new isEdge variable, but just by altering centers.

    The normal setup for barycentric coordinates is having the three vertices be (1,0,0), (0,1,0), (0,0,1). However, if we want to not draw the edge between vertices 0 and 1, we can change this to (1,0,1), (0,1,1), (0,0,1), so that no matter how far from vertex 2 we get, vCenter.z is always 1. Then, we can start with centers filled with ones (all edges disabled), and enable the edges one by one as we see which edges should stay.

    With that in mind, I've reworked your code a bit. I've stripped out the edge object and just left the barycentric stuff. below is the link to js fiddle

    I found that the call to compute normals should be done after the conversion to BufferGeometry. Calling .fromGeometry does indeed duplicate the vertices, but the normals have to be recomputed if the object you're working has shared vertices.

    http://jsfiddle.net/Gangula/rnv1xtu2/1/

    function setUpBarycentricCoordinates(geometry) {
      
        var positions = geometry.attributes.position.array;
        var normals = geometry.attributes.normal.array;
    
        // Build new attribute storing barycentric coordinates
        // for each vertex
        var centers = new THREE.BufferAttribute(new Float32Array(positions.length), 3);
        // start with all edges disabled
        for (var f = 0; f < positions.length; f++) { centers.array[f] = 1; }
        geometry.addAttribute( 'center', centers );
    
        // Hash all the edges and remember which face they're associated with
        // (Adapted from THREE.EdgesHelper)
        function sortFunction ( a, b ) { 
            if (a[0] - b[0] != 0) {
                return (a[0] - b[0]);
            } else if (a[1] - b[1] != 0) { 
                return (a[1] - b[1]);
            } else { 
                return (a[2] - b[2]);
            }
        }
        var edge = [ 0, 0 ];
        var hash = {};
        var face;
        var numEdges = 0;
    
        for (var i = 0; i < positions.length/9; i++) {
            var a = i * 9 
            face = [ [ positions[a+0], positions[a+1], positions[a+2] ] ,
                     [ positions[a+3], positions[a+4], positions[a+5] ] ,
                     [ positions[a+6], positions[a+7], positions[a+8] ] ];
            for (var j = 0; j < 3; j++) {
                var k = (j + 1) % 3;
                var b = j * 3;
                var c = k * 3;
                edge[ 0 ] = face[ j ];
                edge[ 1 ] = face[ k ];
                edge.sort( sortFunction );
                key = edge[0] + ' | ' + edge[1];
                if ( hash[ key ] == undefined ) {
                    hash[ key ] = {
                      face1: a,
                      face1vert1: a + b,
                      face1vert2: a + c,
                      face2: undefined,
                      face2vert1: undefined,
                      face2vert2: undefined
                    };
                    numEdges++;
                } else { 
                    hash[ key ].face2 = a;
                    hash[ key ].face2vert1 = a + b;
                    hash[ key ].face2vert2 = a + c;
                }
            }
        }
    
        var index = 0;
        for (key in hash) {
            h = hash[key];
            
            // ditch any edges that are bordered by two coplanar faces
            var normal1, normal2;
            if ( h.face2 !== undefined ) {
                normal1 = new THREE.Vector3(normals[h.face1+0], normals[h.face1+1], normals[h.face1+2]);
                normal2 = new THREE.Vector3(normals[h.face2+0], normals[h.face2+1], normals[h.face2+2]);
                if ( normal1.dot( normal2 ) >= 0.9999 ) { continue; }
            }
    
            // mark edge vertices as such by altering barycentric coordinates
            var otherVert;
            otherVert = 3 - (h.face1vert1 / 3) % 3 - (h.face1vert2 / 3) % 3;
            centers.array[h.face1vert1 + otherVert] = 0;
            centers.array[h.face1vert2 + otherVert] = 0;
            
            otherVert = 3 - (h.face2vert1 / 3) % 3 - (h.face2vert2 / 3) % 3;
            centers.array[h.face2vert1 + otherVert] = 0;
            centers.array[h.face2vert2 + otherVert] = 0;
        }
    }
    
    // Build geometry 
    var geometry = new THREE.BoxGeometry(2, 2, 2);
    geometry = new THREE.BufferGeometry().fromGeometry(geometry);
    geometry.computeVertexNormals();
    setUpBarycentricCoordinates(geometry);
    
    // Build shader
    var vertexShader = document.getElementById( 'vertexShader' ).textContent;
    var fragmentShader = document.getElementById( 'fragmentShader' ).textContent;
    
    var material = new THREE.ShaderMaterial( { 
        uniforms: {}, 
        // attributes: {
        //     'center': { type: 'v3', value: null,  boundTo: 'faceVertices' }
        // }, 
        vertexShader: vertexShader, 
        fragmentShader: fragmentShader 
    } );
    
    var cube = new THREE.Mesh(geometry, material);
    
    // ------------------------------------------------------------------------
    // (Boilerplate)
    
    var scene = new THREE.Scene();
    var camera = new THREE.PerspectiveCamera(75, 
                                             window.innerWidth / 
                                             window.innerHeight, 
                                             0.1, 1000);
    
    var renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);
    scene.add(cube);
    
    camera.position.z = 5;
    
    var render = function () {
        requestAnimationFrame(render);
        cube.rotation.x += 0.01;
        cube.rotation.y += 0.01;
        renderer.render(scene, camera);
    };
    
    render();
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r100/three.min.js"></script>
    <script type="x-shader/x-vertex" id="vertexShader">
        attribute vec3 center;
        varying vec3 vCenter;
    
        void main() {
            vCenter = center;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
    </script>
    <script type="x-shader/x-fragment" id="fragmentShader">
        #extension GL_OES_standard_derivatives: enable
    
        varying vec3 vCenter;
    
        float edgeFactorTri() {
            vec3 d = fwidth(vCenter.xyz);
            vec3 a3 = smoothstep(vec3(0.0), d * 1.5, vCenter.xyz);
            return min(min(a3.x, a3.y), a3.z);
        }
    
        void main() {
            gl_FragColor.rgb = mix(vec3(1.0), vec3(0.2), edgeFactorTri());
            gl_FragColor.a = 1.0;
        }
    </script>