├── .babelrc ├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── app ├── bundle.js ├── index.html └── main.css ├── lib ├── geom.js ├── gui.json ├── index.js ├── wire.frag └── wire.vert ├── package-lock.json ├── package.json └── screenshots ├── banner.jpg ├── edge-removal.png └── screenshot.png /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: [ "es2015" ] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | test 7 | test.js 8 | demo/ 9 | .npmignore 10 | LICENSE.md -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2017 Matt DesLauriers 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webgl-wireframes 2 | 3 | ![banner](./screenshots/banner.jpg) 4 | 5 | This is the code for a November 2017 net magazine tutorial, _Stylized Wireframe Rendering in WebGL_. Check out the article (when it's released) for more details. 6 | 7 | ## Stylized Wireframe Rendering in WebGL 8 | 9 | The code here uses barycentric coordinates to create stylized wireframes in ThreeJS and WebGL. Some features of the code and its shaders include: 10 | 11 | - Alpha to Coverage for crisp alpha cutouts and depth testing with Multisample Anti-Aliasing 12 | - Thick and anti-aliased single-pass wireframe rendering 13 | - Basic support for animated line dashes 14 | - Inner edge removal to render quads instead of triangles 15 | - A few other effects, such as noise, tapered lines, dual strokes and backface coloring 16 | 17 | ## Demo 18 | 19 | Click [here](https://mattdesl.github.io/webgl-wireframes/app/) to see a live demo. 20 | 21 | [](https://mattdesl.github.io/webgl-wireframes/app/) 22 | 23 | ## Usage 24 | 25 | To build & run this project locally, first clone the repository, then use npm to install and run it: 26 | 27 | ```sh 28 | npm install 29 | npm start 30 | ``` 31 | 32 | Now open `localhost:9966` to see it in your browser. 33 | 34 | To build: 35 | 36 | ```sh 37 | npm run build 38 | ``` 39 | 40 | ## Further Reading 41 | 42 | The technique here is just one approach to wireframe rendering. You may find these other articles interesting: 43 | 44 | - [Easy Wireframes with barycentric coordinates – Florian Bösch](http://codeflow.org/entries/2012/aug/02/easy-wireframe-display-with-barycentric-coordinates/) 45 | - [Two Methods for Antialiased Wireframe Drawing with Hidden Line Removal](http://dl.acm.org/citation.cfm?id=1921300) 46 | - [glsl-solid-wireframe – drawing wireframes and grids in a fragment shader by Ricky Reusser](https://github.com/rreusser/glsl-solid-wireframe) 47 | - [Drawing Lines is Hard](https://mattdesl.svbtle.com/drawing-lines-is-hard) 48 | 49 | ## License 50 | 51 | MIT, see [LICENSE.md](http://github.com/mattdesl/webgl-wireframes/blob/master/LICENSE.md) for details. 52 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | webgl-wireframes 7 | 8 | 9 | 10 | 11 | 12 |
13 | Stylized Wireframe Rendering in WebGL 14 |
15 |
16 |

made by @mattdesl

17 |

see the code

18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /app/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Raleway', 'Helvetica', sans-serif; 3 | font-size: 11px; 4 | background: #fff; 5 | color: #222222; 6 | overflow: hidden; 7 | margin: 0; 8 | padding: 0; 9 | -webkit-text-size-adjust: 100%; 10 | } 11 | 12 | html { 13 | width: 100%; 14 | height: 100%; 15 | font-size: 62.5%; 16 | } 17 | 18 | * { 19 | -webkit-touch-callout: none; /* prevent callout to copy image, etc when tap to hold */ 20 | -webkit-text-size-adjust: none; /* prevent webkit from resizing text to fit */ 21 | -webkit-tap-highlight-color: rgba(0,0,0,0); /* prevent tap highlight color / shadow */ 22 | -webkit-user-select: none; /* prevent copy paste, to allow, change 'none' to 'text' */ 23 | } 24 | 25 | canvas { 26 | position: absolute; 27 | top: 0; 28 | left: 0; 29 | } 30 | 31 | .info-container { 32 | position: absolute; 33 | bottom: 0; 34 | left: 0; 35 | padding: 1.5rem; 36 | } 37 | 38 | .main-title { 39 | position: absolute; 40 | top: 0; 41 | left: 0; 42 | padding: 1.5rem; 43 | font-size: 12px; 44 | } 45 | 46 | .info { 47 | font-weight: 700; 48 | } 49 | 50 | .code { 51 | font-weight: 300; 52 | } 53 | 54 | p { 55 | line-height: 0.6rem; 56 | } 57 | 58 | a, a:active, a:visited { 59 | color: #2b84e0; 60 | text-decoration: none; 61 | position: relative; 62 | } 63 | a::after { 64 | position: absolute; 65 | content: ' '; 66 | display: block; 67 | width: 100%; 68 | height: 2px; 69 | bottom: 0px; 70 | opacity: 0; 71 | left: 0; 72 | background: currentColor; 73 | -webkit-transition: all 0.25s cubic-bezier(0.190, 1.000, 0.220, 1.000); 74 | -moz-transition: all 0.25s cubic-bezier(0.190, 1.000, 0.220, 1.000); 75 | -o-transition: all 0.25s cubic-bezier(0.190, 1.000, 0.220, 1.000); 76 | transition: all 0.25s cubic-bezier(0.190, 1.000, 0.220, 1.000); 77 | } 78 | a:hover::after { 79 | height: 2px; 80 | opacity: 1; 81 | bottom: -3px; 82 | -webkit-transition: all 0.25s cubic-bezier(0.190, 1.000, 0.220, 1.000); 83 | -moz-transition: all 0.25s cubic-bezier(0.190, 1.000, 0.220, 1.000); 84 | -o-transition: all 0.25s cubic-bezier(0.190, 1.000, 0.220, 1.000); 85 | transition: all 0.25s cubic-bezier(0.190, 1.000, 0.220, 1.000); 86 | } -------------------------------------------------------------------------------- /lib/geom.js: -------------------------------------------------------------------------------- 1 | module.exports.addBarycentricCoordinates = function addBarycentricCoordinates (bufferGeometry, removeEdge = false) { 2 | const attrib = bufferGeometry.getIndex() || bufferGeometry.getAttribute('position'); 3 | const count = attrib.count / 3; 4 | const barycentric = []; 5 | 6 | // for each triangle in the geometry, add the barycentric coordinates 7 | for (let i = 0; i < count; i++) { 8 | const even = i % 2 === 0; 9 | const Q = removeEdge ? 1 : 0; 10 | if (even) { 11 | barycentric.push( 12 | 0, 0, 1, 13 | 0, 1, 0, 14 | 1, 0, Q 15 | ); 16 | } else { 17 | barycentric.push( 18 | 0, 1, 0, 19 | 0, 0, 1, 20 | 1, 0, Q 21 | ); 22 | } 23 | } 24 | 25 | // add the attribute to the geometry 26 | const array = new Float32Array(barycentric); 27 | const attribute = new THREE.BufferAttribute(array, 3); 28 | bufferGeometry.addAttribute('barycentric', attribute); 29 | }; 30 | 31 | module.exports.unindexBufferGeometry = function unindexBufferGeometry (bufferGeometry) { 32 | // un-indices the geometry, copying all attributes like position and uv 33 | const index = bufferGeometry.getIndex(); 34 | if (!index) return; // already un-indexed 35 | 36 | const indexArray = index.array; 37 | const triangleCount = indexArray.length / 3; 38 | 39 | const attributes = bufferGeometry.attributes; 40 | const newAttribData = Object.keys(attributes).map(key => { 41 | return { 42 | array: [], 43 | attribute: bufferGeometry.getAttribute(key) 44 | }; 45 | }); 46 | 47 | for (let i = 0; i < triangleCount; i++) { 48 | // indices into attributes 49 | const a = indexArray[i * 3 + 0]; 50 | const b = indexArray[i * 3 + 1]; 51 | const c = indexArray[i * 3 + 2]; 52 | const indices = [ a, b, c ]; 53 | 54 | // for each attribute, put vertex into unindexed list 55 | newAttribData.forEach(data => { 56 | const attrib = data.attribute; 57 | const dim = attrib.itemSize; 58 | // add [a, b, c] vertices 59 | for (let i = 0; i < indices.length; i++) { 60 | const index = indices[i]; 61 | for (let d = 0; d < dim; d++) { 62 | const v = attrib.array[index * dim + d]; 63 | data.array.push(v); 64 | } 65 | } 66 | }); 67 | } 68 | index.array = null; 69 | bufferGeometry.setIndex(null); 70 | 71 | // now copy over new data 72 | newAttribData.forEach(data => { 73 | const newArray = new data.attribute.array.constructor(data.array); 74 | data.attribute.setArray(newArray); 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /lib/gui.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "Sleek", 3 | "closed": false, 4 | "remembered": { 5 | "Sleek": { 6 | "0": { 7 | "seeThrough": true, 8 | "thickness": 0.01, 9 | "backgroundHex": "#e3e3e3", 10 | "fillHex": "#c5c5c5", 11 | "strokeHex": "#202020", 12 | "dashEnabled": true, 13 | "dashAnimate": false, 14 | "dashRepeats": 9, 15 | "dashLength": 0.7000000000000001, 16 | "dashOverlap": true, 17 | "noiseA": false, 18 | "noiseB": false, 19 | "insideAltColor": true, 20 | "squeeze": true, 21 | "squeezeMin": 0.1, 22 | "squeezeMax": 1, 23 | "dualStroke": false, 24 | "secondThickness": 0.05, 25 | "name": "Icosphere", 26 | "edgeRemoval": false 27 | } 28 | }, 29 | "FunkyTorus": { 30 | "0": { 31 | "seeThrough": false, 32 | "thickness": 0.02, 33 | "backgroundHex": "#e8d5b7", 34 | "fillHex": "#0e2430", 35 | "strokeHex": "#fc3a51", 36 | "dashEnabled": false, 37 | "dashAnimate": false, 38 | "dashRepeats": 1, 39 | "dashLength": 0.8, 40 | "dashOverlap": true, 41 | "noiseA": false, 42 | "noiseB": false, 43 | "insideAltColor": true, 44 | "squeeze": true, 45 | "squeezeMin": 0, 46 | "squeezeMax": 1, 47 | "dualStroke": true, 48 | "secondThickness": 0.05, 49 | "name": "TorusKnot", 50 | "edgeRemoval": true 51 | } 52 | }, 53 | "BlockyFade": { 54 | "0": { 55 | "seeThrough": true, 56 | "thickness": 0.01, 57 | "backgroundHex": "#eff3cd", 58 | "fillHex": "#b2d5ba", 59 | "strokeHex": "#61ada0", 60 | "dashEnabled": false, 61 | "dashAnimate": false, 62 | "dashRepeats": 1, 63 | "dashLength": 0.9, 64 | "dashOverlap": true, 65 | "noiseA": true, 66 | "noiseB": false, 67 | "insideAltColor": true, 68 | "squeeze": true, 69 | "squeezeMin": 0.1, 70 | "squeezeMax": 1, 71 | "dualStroke": false, 72 | "secondThickness": 0.05, 73 | "name": "Tube", 74 | "edgeRemoval": true 75 | } 76 | }, 77 | "SimpleWire": { 78 | "0": { 79 | "seeThrough": true, 80 | "thickness": 0.03, 81 | "backgroundHex": "#f2f2f2", 82 | "fillHex": "#d2d2d2", 83 | "strokeHex": "#000000", 84 | "dashEnabled": false, 85 | "dashAnimate": false, 86 | "dashRepeats": 1, 87 | "dashLength": 0.8, 88 | "dashOverlap": true, 89 | "noiseA": false, 90 | "noiseB": false, 91 | "insideAltColor": false, 92 | "squeeze": false, 93 | "squeezeMin": 0.5, 94 | "squeezeMax": 1, 95 | "dualStroke": false, 96 | "secondThickness": 0.05, 97 | "name": "Icosphere", 98 | "edgeRemoval": false 99 | } 100 | }, 101 | "NicerWire": { 102 | "0": { 103 | "seeThrough": true, 104 | "thickness": 0.03, 105 | "backgroundHex": "#f6f6f6", 106 | "fillHex": "#e6e6e6", 107 | "strokeHex": "#252525", 108 | "dashEnabled": false, 109 | "dashAnimate": false, 110 | "dashRepeats": 1, 111 | "dashLength": 0.8, 112 | "dashOverlap": true, 113 | "noiseA": false, 114 | "noiseB": false, 115 | "insideAltColor": true, 116 | "squeeze": true, 117 | "squeezeMin": 0.4, 118 | "squeezeMax": 1, 119 | "dualStroke": false, 120 | "secondThickness": 0.05, 121 | "name": "Torus", 122 | "edgeRemoval": true 123 | } 124 | }, 125 | "Animated": { 126 | "0": { 127 | "seeThrough": false, 128 | "thickness": 0.02, 129 | "backgroundHex": "#fdf1cc", 130 | "fillHex": "#c6d6b8", 131 | "strokeHex": "#987f69", 132 | "dashEnabled": true, 133 | "dashAnimate": true, 134 | "dashRepeats": 6, 135 | "dashLength": 0.6000000000000001, 136 | "dashOverlap": true, 137 | "noiseA": false, 138 | "noiseB": false, 139 | "insideAltColor": false, 140 | "squeeze": true, 141 | "squeezeMin": 0.2, 142 | "squeezeMax": 1, 143 | "dualStroke": false, 144 | "secondThickness": 0.03, 145 | "name": "TorusKnot", 146 | "edgeRemoval": true 147 | } 148 | }, 149 | "FunZone": { 150 | "0": { 151 | "seeThrough": true, 152 | "thickness": 0.02, 153 | "backgroundHex": "#dad6ca", 154 | "fillHex": "#1bb0ce", 155 | "strokeHex": "#4f8699", 156 | "dashEnabled": false, 157 | "dashAnimate": false, 158 | "dashRepeats": 2, 159 | "dashLength": 0.7000000000000001, 160 | "dashOverlap": true, 161 | "noiseA": true, 162 | "noiseB": true, 163 | "insideAltColor": true, 164 | "squeeze": true, 165 | "squeezeMin": 0.2, 166 | "squeezeMax": 1, 167 | "dualStroke": false, 168 | "secondThickness": 0.03, 169 | "name": "Icosphere", 170 | "edgeRemoval": false 171 | } 172 | }, 173 | "Dotted": { 174 | "0": { 175 | "seeThrough": true, 176 | "thickness": 0.04, 177 | "backgroundHex": "#e69472", 178 | "fillHex": "#cf7f5e", 179 | "strokeHex": "#933333", 180 | "dashEnabled": true, 181 | "dashAnimate": false, 182 | "dashRepeats": 8, 183 | "dashLength": 0.5, 184 | "dashOverlap": true, 185 | "noiseA": false, 186 | "noiseB": false, 187 | "insideAltColor": true, 188 | "squeeze": true, 189 | "squeezeMin": 0.2, 190 | "squeezeMax": 1, 191 | "dualStroke": false, 192 | "secondThickness": 0.05, 193 | "name": "Sphere", 194 | "edgeRemoval": true 195 | } 196 | }, 197 | "Flower": { 198 | "0": { 199 | "seeThrough": true, 200 | "thickness": 0.09, 201 | "backgroundHex": "#edebe6", 202 | "fillHex": "#d6e1c7", 203 | "strokeHex": "#94c7b6", 204 | "dashEnabled": true, 205 | "dashAnimate": false, 206 | "dashRepeats": 1, 207 | "dashLength": 0.8, 208 | "dashOverlap": false, 209 | "noiseA": false, 210 | "noiseB": false, 211 | "insideAltColor": true, 212 | "squeeze": true, 213 | "squeezeMin": 0.2, 214 | "squeezeMax": 0, 215 | "dualStroke": false, 216 | "secondThickness": 0.05, 217 | "name": "Icosphere", 218 | "edgeRemoval": false 219 | } 220 | }, 221 | "Tapered": { 222 | "0": { 223 | "seeThrough": false, 224 | "thickness": 0.015, 225 | "backgroundHex": "#a7c5bd", 226 | "fillHex": "#e5ddcb", 227 | "strokeHex": "#eb7b59", 228 | "dashEnabled": true, 229 | "dashAnimate": false, 230 | "dashRepeats": 1, 231 | "dashLength": 0.6, 232 | "dashOverlap": true, 233 | "noiseA": false, 234 | "noiseB": false, 235 | "insideAltColor": true, 236 | "squeeze": true, 237 | "squeezeMin": 0, 238 | "squeezeMax": 0.64, 239 | "dualStroke": false, 240 | "secondThickness": 0.03, 241 | "name": "TorusKnot", 242 | "edgeRemoval": true 243 | } 244 | } 245 | }, 246 | "folders": { 247 | "Shader": { 248 | "preset": "Default", 249 | "closed": false, 250 | "folders": { 251 | "Dash": { 252 | "preset": "Default", 253 | "closed": true, 254 | "folders": {} 255 | }, 256 | "Effects": { 257 | "preset": "Default", 258 | "closed": true, 259 | "folders": {} 260 | }, 261 | "Geometry": { 262 | "preset": "Default", 263 | "closed": false, 264 | "folders": {} 265 | } 266 | } 267 | } 268 | } 269 | } -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | // Require ThreeJS and any necessary extensions 2 | global.THREE = require('three'); 3 | require('three/examples/js/curves/NURBSUtils'); 4 | require('three/examples/js/curves/NURBSCurve'); 5 | 6 | // Include dat.gui sliders 7 | const dat = require('dat.gui/build/dat.gui.js'); 8 | const gui = new dat.GUI({ load: require('./gui.json'), preset: 'Sleek' }); 9 | 10 | // Grab some nice color palettes 11 | const palettes = require('nice-color-palettes'); 12 | 13 | // glslify is used for including GLSL shader code 14 | const glslify = require('glslify'); 15 | const path = require('path'); 16 | 17 | // our geometry helper functions 18 | const { 19 | addBarycentricCoordinates, 20 | unindexBufferGeometry 21 | } = require('./geom'); 22 | 23 | // grab a default palette 24 | let palette = palettes[13].slice(); 25 | 26 | const canvas = document.querySelector('#canvas'); 27 | 28 | // the 1st color in our palette is our background 29 | const background = palette.shift(); 30 | canvas.style.background = background; 31 | 32 | // create an anti-aliased ThreeJS WebGL renderer 33 | const renderer = new THREE.WebGLRenderer({ 34 | antialias: true, 35 | canvas 36 | }); 37 | 38 | // Enable Alpha to Coverage for alpha cutouts + depth test 39 | const gl = renderer.getContext(); 40 | gl.enable(gl.SAMPLE_ALPHA_TO_COVERAGE); 41 | 42 | // Configure renderer 43 | renderer.setClearColor(background, 1); 44 | renderer.setPixelRatio(window.devicePixelRatio); 45 | 46 | // Listen for window resizes 47 | window.addEventListener('resize', () => resize()); 48 | 49 | // Create a scene and camera for 3D world 50 | const scene = new THREE.Scene(); 51 | const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100); 52 | 53 | // Create our custom wireframe shader material 54 | const material = new THREE.ShaderMaterial({ 55 | extensions: { 56 | // needed for anti-alias smoothstep, aastep() 57 | derivatives: true 58 | }, 59 | transparent: true, 60 | side: THREE.DoubleSide, 61 | uniforms: { // some parameters for the shader 62 | time: { value: 0 }, 63 | fill: { value: new THREE.Color(palette[0]) }, 64 | stroke: { value: new THREE.Color(palette[1]) }, 65 | noiseA: { value: false }, 66 | noiseB: { value: false }, 67 | dualStroke: { value: false }, 68 | seeThrough: { value: false }, 69 | insideAltColor: { value: true }, 70 | thickness: { value: 0.01 }, 71 | secondThickness: { value: 0.05 }, 72 | dashEnabled: { value: true }, 73 | dashRepeats: { value: 2.0 }, 74 | dashOverlap: { value: false }, 75 | dashLength: { value: 0.55 }, 76 | dashAnimate: { value: false }, 77 | squeeze: { value: false }, 78 | squeezeMin: { value: 0.1 }, 79 | squeezeMax: { value: 1.0 } 80 | }, 81 | // use glslify here to bring in the GLSL code 82 | fragmentShader: glslify(path.resolve(__dirname, 'wire.frag')), 83 | vertexShader: glslify(path.resolve(__dirname, 'wire.vert')) 84 | }); 85 | 86 | // add the mesh with an empty geometry for now, we will change it later 87 | const mesh = new THREE.Mesh(new THREE.Geometry(), material); 88 | scene.add(mesh); 89 | 90 | const clock = new THREE.Clock(); 91 | 92 | // set up scene and start drawing 93 | createGeometry(); 94 | setupGUI(); 95 | resize(); 96 | renderer.animate(update); 97 | canvas.style.visibility = ''; 98 | update(); 99 | draw(); 100 | 101 | function update () { 102 | // orbit the camera and update shader time 103 | const time = clock.getElapsedTime(); 104 | const radius = 4; 105 | const angle = time * 2.5 * Math.PI / 180; 106 | camera.position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius); 107 | camera.lookAt(new THREE.Vector3()); 108 | mesh.material.uniforms.time.value = time; 109 | draw(); 110 | } 111 | 112 | function draw () { 113 | // render a single frame 114 | renderer.render(scene, camera); 115 | } 116 | 117 | function resize (width = window.innerWidth, height = window.innerHeight, pixelRatio = window.devicePixelRatio) { 118 | // handle window resize 119 | renderer.setPixelRatio(pixelRatio); 120 | renderer.setSize(width, height); 121 | camera.aspect = width / height; 122 | camera.updateProjectionMatrix(); 123 | draw(); 124 | } 125 | 126 | function createGeometry (type = 'TorusKnot', edgeRemoval = true) { 127 | // dispose the old geometry if we have one 128 | if (mesh.geometry) mesh.geometry.dispose(); 129 | 130 | // here we change the geometry of the mesh to visualize 131 | // the shader in different applications 132 | let geometry; 133 | switch (type) { 134 | case 'TorusKnot': 135 | geometry = new THREE.TorusKnotBufferGeometry(0.7, 0.3, 30, 4); 136 | geometry.rotateY(-Math.PI * 0.5); 137 | break; 138 | case 'Icosphere': 139 | geometry = new THREE.IcosahedronBufferGeometry(1, 1); 140 | break; 141 | case 'Tube': 142 | const baseGeom = new THREE.IcosahedronGeometry(1, 0); 143 | const points = baseGeom.vertices; 144 | baseGeom.dispose(); 145 | const curve = toSpline(points); 146 | geometry = new THREE.TubeBufferGeometry(curve, 30, 0.3, 4, false); 147 | break; 148 | case 'Sphere': 149 | geometry = new THREE.SphereBufferGeometry(1, 20, 10); 150 | break; 151 | case 'Torus': 152 | geometry = new THREE.TorusBufferGeometry(1, 0.3, 8, 30); 153 | break; 154 | } 155 | 156 | // the BufferGeometry needs to be un-indexed, then we can 157 | // add barycentric coordiantes for the wireframe effect 158 | unindexBufferGeometry(geometry); 159 | addBarycentricCoordinates(geometry, edgeRemoval); 160 | 161 | // set the new geometry on the mesh 162 | mesh.geometry = geometry; 163 | } 164 | 165 | function toSpline (points) { 166 | // This helper function makes a smooth NURBS curve from a set of points 167 | const nurbsDegree = 3; 168 | const nurbsKnots = []; 169 | for (let i = 0; i <= nurbsDegree; i++) { 170 | nurbsKnots.push(0); 171 | } 172 | let nurbsControlPoints = points.map((p, i, list) => { 173 | const knot = (i + 1) / (list.length - nurbsDegree); 174 | nurbsKnots.push(Math.max(Math.min(1, knot), 0)); 175 | return new THREE.Vector4(p.x, p.y, p.z, 1); 176 | }); 177 | return new THREE.NURBSCurve(nurbsDegree, nurbsKnots, nurbsControlPoints); 178 | } 179 | 180 | function saveScreenshot () { 181 | // force a specific output size 182 | const width = 2048; 183 | const height = 2048; 184 | resize(width, height, 1); 185 | 186 | const dataURI = canvas.toDataURL('image/png'); 187 | 188 | // revert to old size 189 | resize(); 190 | 191 | // force download 192 | const link = document.createElement('a'); 193 | link.download = 'Screenshot.png'; 194 | link.href = dataURI; 195 | link.click(); 196 | } 197 | 198 | function setupGUI () { 199 | // This function handles all the GUI sliders and updates 200 | const shader = gui.addFolder('Shader'); 201 | 202 | const guiData = { 203 | name: 'TorusKnot', 204 | edgeRemoval: true, 205 | backgroundHex: background, 206 | saveScreenshot, 207 | fillHex: `#${mesh.material.uniforms.fill.value.getHexString()}`, 208 | strokeHex: `#${mesh.material.uniforms.stroke.value.getHexString()}` 209 | }; 210 | 211 | // add all the uniforms into our gui data 212 | Object.keys(mesh.material.uniforms).forEach(key => { 213 | const uniform = mesh.material.uniforms[key]; 214 | if (typeof uniform.value === 'boolean' || typeof uniform.value === 'number') { 215 | guiData[key] = uniform.value; 216 | } 217 | }); 218 | 219 | const randomColors = () => { 220 | palette = palettes[Math.floor(Math.random() * palettes.length)].slice(); 221 | guiData.backgroundHex = palette.shift(); 222 | guiData.fillHex = palette[0]; 223 | guiData.strokeHex = palette[1]; 224 | updateColors(); 225 | 226 | // Iterate over all controllers 227 | for (let k in gui.__folders.Shader.__controllers) { 228 | gui.__folders.Shader.__controllers[k].updateDisplay(); 229 | } 230 | }; 231 | 232 | const updateColors = () => { 233 | canvas.style.background = guiData.backgroundHex; 234 | renderer.setClearColor(guiData.backgroundHex, 1.0); 235 | mesh.material.uniforms.fill.value.setStyle(guiData.fillHex); 236 | mesh.material.uniforms.stroke.value.setStyle(guiData.strokeHex); 237 | }; 238 | 239 | const updateUniforms = () => { 240 | Object.keys(guiData).forEach(key => { 241 | if (key in mesh.material.uniforms) { 242 | mesh.material.uniforms[key].value = guiData[key]; 243 | } 244 | }); 245 | }; 246 | 247 | const updateGeom = () => createGeometry(guiData.name, guiData.edgeRemoval); 248 | 249 | guiData.randomColors = randomColors; 250 | gui.remember(guiData); 251 | 252 | shader.add(guiData, 'seeThrough').name('See Through').onChange(updateUniforms); 253 | shader.add(guiData, 'thickness', 0.005, 0.2).step(0.001).name('Thickness').onChange(updateUniforms); 254 | shader.addColor(guiData, 'backgroundHex').name('Background').onChange(updateColors); 255 | shader.addColor(guiData, 'fillHex').name('Fill').onChange(updateColors); 256 | shader.addColor(guiData, 'strokeHex').name('Stroke').onChange(updateColors); 257 | shader.add(guiData, 'randomColors').name('Random Palette'); 258 | shader.add(guiData, 'saveScreenshot').name('Save PNG'); 259 | 260 | const dash = shader.addFolder('Dash'); 261 | dash.add(guiData, 'dashEnabled').name('Enabled').onChange(updateUniforms); 262 | dash.add(guiData, 'dashAnimate').name('Animate').onChange(updateUniforms); 263 | dash.add(guiData, 'dashRepeats', 1, 10).step(1).name('Repeats').onChange(updateUniforms); 264 | dash.add(guiData, 'dashLength', 0, 1).step(0.01).name('Length').onChange(updateUniforms); 265 | dash.add(guiData, 'dashOverlap').name('Overlap Join').onChange(updateUniforms); 266 | 267 | const effects = shader.addFolder('Effects'); 268 | effects.add(guiData, 'noiseA').name('Noise Big').onChange(updateUniforms); 269 | effects.add(guiData, 'noiseB').name('Noise Small').onChange(updateUniforms); 270 | effects.add(guiData, 'insideAltColor').name('Backface Color').onChange(updateUniforms); 271 | effects.add(guiData, 'squeeze').name('Squeeze').onChange(updateUniforms); 272 | effects.add(guiData, 'squeezeMin', 0, 1).step(0.01).name('Squeeze Min').onChange(updateUniforms); 273 | effects.add(guiData, 'squeezeMax', 0, 1).step(0.01).name('Squeeze Max').onChange(updateUniforms); 274 | effects.add(guiData, 'dualStroke').name('Dual Stroke').onChange(updateUniforms); 275 | effects.add(guiData, 'secondThickness', 0, 0.2).step(0.001).name('Dual Thick').onChange(updateUniforms); 276 | 277 | const geom = shader.addFolder('Geometry'); 278 | geom.add(guiData, 'name', [ 279 | 'TorusKnot', 280 | 'Icosphere', 281 | 'Tube', 282 | 'Sphere', 283 | 'Torus' 284 | ]).name('Geometry').onChange(updateGeom); 285 | geom.add(guiData, 'edgeRemoval').name('Edge Removal').onChange(updateGeom); 286 | 287 | // close GUI for mobile devices 288 | const isMobile = /(Android|iPhone|iOS|iPod|iPad)/i.test(navigator.userAgent); 289 | if (isMobile) { 290 | gui.close(); 291 | } 292 | 293 | updateGeom(); 294 | updateColors(); 295 | updateUniforms(); 296 | } 297 | -------------------------------------------------------------------------------- /lib/wire.frag: -------------------------------------------------------------------------------- 1 | varying vec3 vBarycentric; 2 | varying float vEven; 3 | varying vec2 vUv; 4 | varying vec3 vPosition; 5 | 6 | uniform float time; 7 | uniform float thickness; 8 | uniform float secondThickness; 9 | 10 | uniform float dashRepeats; 11 | uniform float dashLength; 12 | uniform bool dashOverlap; 13 | uniform bool dashEnabled; 14 | uniform bool dashAnimate; 15 | 16 | uniform bool seeThrough; 17 | uniform bool insideAltColor; 18 | uniform bool dualStroke; 19 | uniform bool noiseA; 20 | uniform bool noiseB; 21 | 22 | uniform bool squeeze; 23 | uniform float squeezeMin; 24 | uniform float squeezeMax; 25 | 26 | uniform vec3 stroke; 27 | uniform vec3 fill; 28 | 29 | #pragma glslify: noise = require('glsl-noise/simplex/4d'); 30 | #pragma glslify: PI = require('glsl-pi'); 31 | 32 | // This is like 33 | float aastep (float threshold, float dist) { 34 | float afwidth = fwidth(dist) * 0.5; 35 | return smoothstep(threshold - afwidth, threshold + afwidth, dist); 36 | } 37 | 38 | // This function is not currently used, but it can be useful 39 | // to achieve a fixed width wireframe regardless of z-depth 40 | float computeScreenSpaceWireframe (vec3 barycentric, float lineWidth) { 41 | vec3 dist = fwidth(barycentric); 42 | vec3 smoothed = smoothstep(dist * ((lineWidth * 0.5) - 0.5), dist * ((lineWidth * 0.5) + 0.5), barycentric); 43 | return 1.0 - min(min(smoothed.x, smoothed.y), smoothed.z); 44 | } 45 | 46 | // This function returns the fragment color for our styled wireframe effect 47 | // based on the barycentric coordinates for this fragment 48 | vec4 getStyledWireframe (vec3 barycentric) { 49 | // this will be our signed distance for the wireframe edge 50 | float d = min(min(barycentric.x, barycentric.y), barycentric.z); 51 | 52 | // we can modify the distance field to create interesting effects & masking 53 | float noiseOff = 0.0; 54 | if (noiseA) noiseOff += noise(vec4(vPosition.xyz * 1.0, time * 0.35)) * 0.15; 55 | if (noiseB) noiseOff += noise(vec4(vPosition.xyz * 80.0, time * 0.5)) * 0.12; 56 | d += noiseOff; 57 | 58 | // for dashed rendering, we can use this to get the 0 .. 1 value of the line length 59 | float positionAlong = max(barycentric.x, barycentric.y); 60 | if (barycentric.y < barycentric.x && barycentric.y < barycentric.z) { 61 | positionAlong = 1.0 - positionAlong; 62 | } 63 | 64 | // the thickness of the stroke 65 | float computedThickness = thickness; 66 | 67 | // if we want to shrink the thickness toward the center of the line segment 68 | if (squeeze) { 69 | computedThickness *= mix(squeezeMin, squeezeMax, (1.0 - sin(positionAlong * PI))); 70 | } 71 | 72 | // if we should create a dash pattern 73 | if (dashEnabled) { 74 | // here we offset the stroke position depending on whether it 75 | // should overlap or not 76 | float offset = 1.0 / dashRepeats * dashLength / 2.0; 77 | if (!dashOverlap) { 78 | offset += 1.0 / dashRepeats / 2.0; 79 | } 80 | 81 | // if we should animate the dash or not 82 | if (dashAnimate) { 83 | offset += time * 0.22; 84 | } 85 | 86 | // create the repeating dash pattern 87 | float pattern = fract((positionAlong + offset) * dashRepeats); 88 | computedThickness *= 1.0 - aastep(dashLength, pattern); 89 | } 90 | 91 | // compute the anti-aliased stroke edge 92 | float edge = 1.0 - aastep(computedThickness, d); 93 | 94 | // now compute the final color of the mesh 95 | vec4 outColor = vec4(0.0); 96 | if (seeThrough) { 97 | outColor = vec4(stroke, edge); 98 | if (insideAltColor && !gl_FrontFacing) { 99 | outColor.rgb = fill; 100 | } 101 | } else { 102 | vec3 mainStroke = mix(fill, stroke, edge); 103 | outColor.a = 1.0; 104 | if (dualStroke) { 105 | float inner = 1.0 - aastep(secondThickness, d); 106 | vec3 wireColor = mix(fill, stroke, abs(inner - edge)); 107 | outColor.rgb = wireColor; 108 | } else { 109 | outColor.rgb = mainStroke; 110 | } 111 | } 112 | 113 | return outColor; 114 | } 115 | 116 | void main () { 117 | gl_FragColor = getStyledWireframe(vBarycentric); 118 | } -------------------------------------------------------------------------------- /lib/wire.vert: -------------------------------------------------------------------------------- 1 | attribute vec3 barycentric; 2 | attribute float even; 3 | 4 | varying vec3 vBarycentric; 5 | 6 | varying vec3 vPosition; 7 | varying float vEven; 8 | varying vec2 vUv; 9 | 10 | 11 | void main () { 12 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position.xyz, 1.0); 13 | vBarycentric = barycentric; 14 | vPosition = position.xyz; 15 | vEven = even; 16 | vUv = uv; 17 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webgl-wireframes", 3 | "version": "1.0.0", 4 | "description": "Stylistic Wireframe Rendering in WebGL", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Matt DesLauriers", 9 | "email": "dave.des@gmail.com", 10 | "url": "https://github.com/mattdesl" 11 | }, 12 | "dependencies": { 13 | "dat.gui": "github:dataarts/dat.gui", 14 | "glsl-noise": "0.0.0", 15 | "glsl-pi": "^1.0.0", 16 | "glslify": "^6.1.0", 17 | "nice-color-palettes": "^2.0.0", 18 | "three": "^0.87.1" 19 | }, 20 | "devDependencies": { 21 | "babel-preset-es2015": "^6.24.1", 22 | "babelify": "^7.3.0", 23 | "browserify": "^14.4.0", 24 | "budo": "^10.0.4", 25 | "uglify-js": "^3.0.28" 26 | }, 27 | "scripts": { 28 | "start": "budo lib/index.js:bundle.js --live --dir app -- -t babelify -t glslify", 29 | "build": "browserify lib/index.js -t babelify -t glslify | uglifyjs -m -c warnings=false > app/bundle.js" 30 | }, 31 | "keywords": [], 32 | "repository": { 33 | "type": "git", 34 | "url": "git://github.com/mattdesl/webgl-wireframes.git" 35 | }, 36 | "semistandard": { 37 | "globals": [ 38 | "THREE" 39 | ] 40 | }, 41 | "homepage": "https://github.com/mattdesl/webgl-wireframes", 42 | "bugs": { 43 | "url": "https://github.com/mattdesl/webgl-wireframes/issues" 44 | } 45 | } -------------------------------------------------------------------------------- /screenshots/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/webgl-wireframes/bf073f8f18215dec87cb910f6da3775f1e8bfb6f/screenshots/banner.jpg -------------------------------------------------------------------------------- /screenshots/edge-removal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/webgl-wireframes/bf073f8f18215dec87cb910f6da3775f1e8bfb6f/screenshots/edge-removal.png -------------------------------------------------------------------------------- /screenshots/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattdesl/webgl-wireframes/bf073f8f18215dec87cb910f6da3775f1e8bfb6f/screenshots/screenshot.png --------------------------------------------------------------------------------