├── LICENSE ├── README.md ├── examples ├── _js │ └── root.js ├── animated │ ├── index.html │ └── main.js ├── character │ ├── index.html │ └── main.js └── text │ ├── index.html │ └── main.js ├── package-lock.json ├── package.json └── src └── FuzzyMesh.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Szenia Zadvornykh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # three-fuzzy-mesh 2 | 3 | Code for a Three.js experiment that uses Three.bas to create a fuzzy/hairy mesh based on a Three.js geometry. 4 | 5 | Check out the [Medium post](https://medium.com/@Zadvorsky/fuzzy-meshes-4c7fd3910d6f) for details about the implementation and approach. 6 | 7 | See it running [here](https://codepen.io/dpdknl/pen/JrgrJN/). 8 | 9 | ## Usage 10 | 11 | No package / build system yet. Just grab `src/FuzzyMesh.js` and drop it in your project somewhere. 12 | -------------------------------------------------------------------------------- /examples/_js/root.js: -------------------------------------------------------------------------------- 1 | function THREERoot(params) { 2 | // defaults 3 | params = Object.assign({ 4 | container:'#three-container', 5 | fov:60, 6 | zNear:1, 7 | zFar:10000, 8 | createCameraControls: true, 9 | autoStart: true, 10 | pixelRatio: window.devicePixelRatio, 11 | antialias: (window.devicePixelRatio === 1), 12 | alpha: false 13 | }, params); 14 | 15 | // maps and arrays 16 | this.updateCallbacks = []; 17 | this.resizeCallbacks = []; 18 | this.objects = {}; 19 | 20 | // renderer 21 | this.renderer = new THREE.WebGLRenderer({ 22 | antialias: params.antialias, 23 | alpha: params.alpha 24 | }); 25 | this.renderer.setPixelRatio(params.pixelRatio); 26 | 27 | // container 28 | this.container = (typeof params.container === 'string') ? document.querySelector(params.container) : params.container; 29 | this.container.appendChild(this.renderer.domElement); 30 | 31 | // camera 32 | this.camera = new THREE.PerspectiveCamera( 33 | params.fov, 34 | window.innerWidth / window.innerHeight, 35 | params.zNear, 36 | params.zFar 37 | ); 38 | 39 | // scene 40 | this.scene = new THREE.Scene(); 41 | 42 | // resize handling 43 | this.resize = this.resize.bind(this); 44 | this.resize(); 45 | window.addEventListener('resize', this.resize, false); 46 | 47 | // tick / update / render 48 | this.tick = this.tick.bind(this); 49 | params.autoStart && this.tick(); 50 | 51 | // optional camera controls 52 | params.createCameraControls && this.createOrbitControls(); 53 | } 54 | THREERoot.prototype = { 55 | createOrbitControls: function() { 56 | this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement); 57 | this.addUpdateCallback(this.controls.update.bind(this.controls)); 58 | }, 59 | start: function() { 60 | this.tick(); 61 | }, 62 | addUpdateCallback: function(callback) { 63 | this.updateCallbacks.push(callback); 64 | }, 65 | addResizeCallback: function(callback) { 66 | this.resizeCallbacks.push(callback); 67 | }, 68 | add: function(object, key) { 69 | key && (this.objects[key] = object); 70 | this.scene.add(object); 71 | }, 72 | addTo: function(object, parentKey, key) { 73 | key && (this.objects[key] = object); 74 | this.get(parentKey).add(object); 75 | }, 76 | get: function(key) { 77 | return this.objects[key]; 78 | }, 79 | remove: function(o) { 80 | var object; 81 | 82 | if (typeof o === 'string') { 83 | object = this.objects[o]; 84 | } 85 | else { 86 | object = o; 87 | } 88 | 89 | if (object) { 90 | object.parent.remove(object); 91 | delete this.objects[o]; 92 | } 93 | }, 94 | tick: function() { 95 | this.update(); 96 | this.render(); 97 | requestAnimationFrame(this.tick); 98 | }, 99 | update: function() { 100 | this.updateCallbacks.forEach(function(callback) {callback()}); 101 | }, 102 | render: function() { 103 | this.renderer.render(this.scene, this.camera); 104 | }, 105 | resize: function() { 106 | var width = window.innerWidth; 107 | var height = window.innerHeight; 108 | 109 | this.camera.aspect = width / height; 110 | this.camera.updateProjectionMatrix(); 111 | 112 | this.renderer.setSize(width, height); 113 | this.resizeCallbacks.forEach(function(callback) {callback()}); 114 | }, 115 | initPostProcessing:function(passes) { 116 | var size = this.renderer.getSize(); 117 | var pixelRatio = this.renderer.getPixelRatio(); 118 | size.width *= pixelRatio; 119 | size.height *= pixelRatio; 120 | 121 | var composer = this.composer = new THREE.EffectComposer(this.renderer, new THREE.WebGLRenderTarget(size.width, size.height, { 122 | minFilter: THREE.LinearFilter, 123 | magFilter: THREE.LinearFilter, 124 | format: THREE.RGBAFormat, 125 | stencilBuffer: false 126 | })); 127 | 128 | var renderPass = new THREE.RenderPass(this.scene, this.camera); 129 | this.composer.addPass(renderPass); 130 | 131 | for (var i = 0; i < passes.length; i++) { 132 | var pass = passes[i]; 133 | pass.renderToScreen = (i === passes.length - 1); 134 | this.composer.addPass(pass); 135 | } 136 | 137 | this.renderer.autoClear = false; 138 | this.render = function() { 139 | this.renderer.clear(); 140 | this.composer.render(); 141 | }.bind(this); 142 | 143 | this.addResizeCallback(function() { 144 | var width = window.innerWidth; 145 | var height = window.innerHeight; 146 | 147 | composer.setSize(width * pixelRatio, height * pixelRatio); 148 | }.bind(this)); 149 | } 150 | }; 151 | -------------------------------------------------------------------------------- /examples/animated/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /examples/animated/main.js: -------------------------------------------------------------------------------- 1 | const colors = { 2 | turquoise: 0x47debd, 3 | darkPurple: 0x2e044e, 4 | purple: 0x7821ec, 5 | yellow: 0xfff95d, 6 | white: 0xffffff, 7 | black: 0x000000 8 | }; 9 | 10 | const root = new THREERoot({ 11 | createCameraControls: true, 12 | zNear: 0.01, 13 | zFar: 1000, 14 | antialias: true 15 | }); 16 | 17 | root.renderer.shadowMap.enabled = true; 18 | root.renderer.setClearColor(colors.darkPurple); 19 | root.camera.position.set(-10, 0, 20); 20 | 21 | const light = new THREE.DirectionalLight(colors.turquoise); 22 | light.position.set(0.125, 1, 0); 23 | root.add(light); 24 | 25 | const light2 = new THREE.DirectionalLight(colors.yellow); 26 | light2.position.set(-0.125, -1, 0); 27 | root.add(light2); 28 | 29 | root.add(new THREE.AmbientLight(colors.purple)); 30 | 31 | new THREE.JSONLoader().load('https://s3-us-west-2.amazonaws.com/s.cdpn.io/304639/plus.json', (geometry) => { 32 | // test shapes, try different ones :D 33 | // model = new THREE.SphereGeometry(1, 16, 16); 34 | // model = new THREE.PlaneGeometry(40, 10, 200, 40); 35 | // model = new THREE.CylinderGeometry(2, 2, 8, 128, 64, true); 36 | // model = new THREE.TorusGeometry(8, 1, 128, 256); 37 | // model = new THREE.TorusKnotGeometry(2, 0.1, 64, 64, 3, 5); 38 | 39 | const fuzzy = new FuzzyMesh({ 40 | geometry: geometry, 41 | // directions: model.vertices, 42 | config: { 43 | hairLength: 2, 44 | hairRadialSegments: 4, 45 | hairRadiusTop: 0.0, 46 | hairRadiusBase: 0.1, 47 | }, 48 | materialUniformValues: { 49 | roughness: 1.0 50 | } 51 | }); 52 | root.add(fuzzy); 53 | root.addUpdateCallback(() => { 54 | fuzzy.update(); 55 | }); 56 | 57 | const axes = [ 58 | new THREE.Vector3(1, 0, 0), 59 | new THREE.Vector3(0, 1, 0), 60 | new THREE.Vector3(0, 0, 1), 61 | ]; 62 | 63 | const proxy = { 64 | position: new THREE.Vector3(), 65 | angle: 0, 66 | }; 67 | 68 | const tl = new TimelineMax({ 69 | repeat: -1, 70 | delay: 1, 71 | repeatDelay: 1, 72 | onRepeat: () => { 73 | fuzzy.setRotationAxis(BAS.Utils.randomAxis()); 74 | // fuzzy.setRotationAxis(axes[Math.random() * 3 | 0]); 75 | }, 76 | onUpdate: () => { 77 | fuzzy.setPosition(proxy.position); 78 | fuzzy.setRotationAngle(proxy.angle); 79 | } 80 | }); 81 | 82 | tl.to(proxy.position, 0.5, {y: 8, ease: Power2.easeOut}); 83 | tl.to(proxy.position, 0.5, {y: 0, ease: Power2.easeIn}); 84 | tl.to(proxy.position, 0.1, {y: -2, ease: Power2.easeOut}); 85 | tl.to(proxy.position, 0.5, {y: 0, ease: Power2.easeOut}); 86 | tl.fromTo(proxy, 1.0, {angle: 0}, {angle: Math.PI * 2 * (Math.random() > 0.5 ? 1 : -1), ease: Power1.easeInOut}, 0); 87 | }); 88 | -------------------------------------------------------------------------------- /examples/character/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /examples/character/main.js: -------------------------------------------------------------------------------- 1 | 2 | // hero class, based on work by Karim Maaloul 3 | 4 | function Hero() { 5 | this.runningCycle = 0; 6 | this.mesh = new THREE.Group(); 7 | this.body = new THREE.Group(); 8 | this.mesh.add(this.body); 9 | 10 | 11 | this.head = new FuzzyMesh({ 12 | geometry: new THREE.SphereGeometry(4, 32, 16, 0, Math.PI * 2, 0, Math.PI * 0.55), 13 | materialUniformValues: { 14 | roughness: 1.0 15 | }, 16 | config: { 17 | hairLength: 6, 18 | hairRadiusBase: 0.5, 19 | hairRadialSegments: 6, 20 | gravity: 2, 21 | fuzz: 0.25, 22 | minForceFactor: 0.5, 23 | maxForceFactor: 0.75 24 | } 25 | }); 26 | this.head.position.y = this.headAnchorY = 13; 27 | this.head.castShadow = true; 28 | this.head.setRotationAxis(new THREE.Vector3(1, 0, 0)); 29 | this.body.add(this.head); 30 | 31 | 32 | this.torso = new FuzzyMesh({ 33 | geometry: new THREE.SphereGeometry(3, 32, 16, 0, Math.PI * 2, Math.PI * 0.25, Math.PI * 0.70), 34 | materialUniformValues: { 35 | roughness: 1.0 36 | }, 37 | config: { 38 | hairLength: 5, 39 | hairRadiusBase: 0.5, 40 | hairRadialSegments: 6, 41 | gravity: 2, 42 | fuzz: 0.5, 43 | minForceFactor: 1.0, 44 | maxForceFactor: 4.0, 45 | centrifugalForceFactor: 4, 46 | } 47 | }); 48 | this.torso.position.y = this.torsoAnchorY = 9; 49 | this.body.add(this.torso); 50 | 51 | 52 | this.handR = new FuzzyMesh({ 53 | geometry: new THREE.SphereGeometry(1, 12, 12), 54 | materialUniformValues: { 55 | roughness: 1.0 56 | }, 57 | config: { 58 | hairLength: 2, 59 | hairRadiusBase: 0.25, 60 | hairRadialSegments: 6, 61 | gravity: 2, 62 | fuzz: 0.25, 63 | } 64 | }); 65 | this.handR.position.y = this.handAnchorY = 8; 66 | this.handR.position.z = this.handAnchorZ = 6; 67 | this.handR.setRotationAxis(new THREE.Vector3(0, 0, 1)); 68 | this.body.add(this.handR); 69 | 70 | 71 | this.handL = new FuzzyMesh({ 72 | geometry: new THREE.SphereGeometry(1, 12, 12), 73 | materialUniformValues: { 74 | roughness: 1.0 75 | }, 76 | config: { 77 | hairLength: 2, 78 | hairRadiusBase: 0.25, 79 | hairRadialSegments: 6, 80 | gravity: 2, 81 | fuzz: 0.25, 82 | } 83 | }); 84 | this.handL.position.y = this.handAnchorY; 85 | this.handL.position.z = -this.handAnchorZ; 86 | this.handL.setRotationAxis(new THREE.Vector3(0, 0, 1)); 87 | this.body.add(this.handL); 88 | 89 | 90 | this.legR = new FuzzyMesh({ 91 | geometry: new THREE.SphereGeometry(2, 48, 16, 0, Math.PI * 2, 0, Math.PI * 0.5), 92 | materialUniformValues: { 93 | roughness: 1.0, 94 | side: THREE.DoubleSide 95 | }, 96 | config: { 97 | hairLength: 2, 98 | hairRadiusBase: 0.5, 99 | hairRadialSegments: 6, 100 | gravity: 1, 101 | fuzz: 0.25, 102 | } 103 | }); 104 | this.legR.position.z = this.legAnchorZ = 3; 105 | this.legR.setRotationAxis(new THREE.Vector3(0, 0, 1)); 106 | this.body.add(this.legR); 107 | 108 | 109 | this.legL = new FuzzyMesh({ 110 | geometry: new THREE.SphereGeometry(2, 48, 16, 0, Math.PI * 2, 0, Math.PI * 0.5), 111 | materialUniformValues: { 112 | roughness: 1.0, 113 | side: THREE.DoubleSide 114 | }, 115 | config: { 116 | hairLength: 2, 117 | hairRadiusBase: 0.5, 118 | hairRadialSegments: 6, 119 | gravity: 1, 120 | fuzz: 0.25, 121 | } 122 | }); 123 | this.legL.position.z = -this.legAnchorZ; 124 | this.legL.setRotationAxis(new THREE.Vector3(0, 0, 1)); 125 | this.body.add(this.legL); 126 | 127 | 128 | const color = new THREE.Color().setHSL(Math.random(), 0.75, 0.5); 129 | this.head.setColor(color); 130 | this.torso.setColor(color); 131 | this.handR.setColor(color); 132 | this.handL.setColor(color); 133 | this.legR.setColor(color); 134 | this.legL.setColor(color); 135 | 136 | this.tempV = new THREE.Vector3(); 137 | } 138 | 139 | Hero.prototype.run = function(){ 140 | var s = 0.125; 141 | var t = this.runningCycle; 142 | var amp = 4; 143 | 144 | t = t % (2*Math.PI); 145 | 146 | this.runningCycle += s; 147 | 148 | this.head.setPosition(this.tempV.set( 149 | this.head.position.x, 150 | this.headAnchorY - Math.cos( t * 2 ) * amp * .3, 151 | this.head.position.z 152 | )); 153 | this.head.setRotationAngle(Math.cos(t) * amp * .02); 154 | 155 | this.torso.setPosition(this.tempV.set( 156 | this.torso.position.x, 157 | this.torsoAnchorY - Math.cos( t * 2 ) * amp * .2, 158 | this.torso.position.z 159 | )); 160 | this.torso.setRotationAngle(-Math.cos( t + Math.PI ) * amp * .05); 161 | 162 | this.handR.setPosition(this.tempV.set( 163 | -Math.cos( t ) * amp, 164 | this.handR.position.y, 165 | this.handR.position.z 166 | )); 167 | this.handR.setRotationAngle(-Math.cos(t) * Math.PI / 8); 168 | 169 | this.handL.setPosition(this.tempV.set( 170 | -Math.cos( t + Math.PI) * amp, 171 | this.handL.position.y, 172 | this.handL.position.z 173 | )); 174 | this.handL.setRotationAngle(-Math.cos(t + Math.PI) * Math.PI / 8); 175 | 176 | this.legR.setPosition(this.tempV.set( 177 | Math.cos(t) * amp, 178 | Math.max(0, -Math.sin(t) * amp), 179 | this.legAnchorZ 180 | )); 181 | 182 | this.legL.setPosition(this.tempV.set( 183 | Math.cos(t + Math.PI) * amp, 184 | Math.max(0, -Math.sin(t + Math.PI) * amp), 185 | -this.legAnchorZ 186 | )); 187 | 188 | if (t > Math.PI){ 189 | this.legR.setRotationAngle(Math.cos(t * 2 + Math.PI / 2) * Math.PI / 4); 190 | this.legL.setRotationAngle(0); 191 | } 192 | else { 193 | this.legR.setRotationAngle(0); 194 | this.legL.setRotationAngle(Math.cos(t * 2 + Math.PI / 2) * Math.PI / 4); 195 | } 196 | 197 | this.torso.update(); 198 | this.head.update(); 199 | this.handL.update(); 200 | this.handR.update(); 201 | this.legL.update(); 202 | this.legR.update(); 203 | }; 204 | 205 | // scene stuff 206 | 207 | const root = new THREERoot({ 208 | createCameraControls: true, 209 | zNear: 0.01, 210 | zFar: 1000, 211 | antialias: true 212 | }); 213 | 214 | root.renderer.setClearColor(0xf1f1f1); 215 | root.controls.autoRotate = true; 216 | root.controls.autoRotateSpeed = -6; 217 | root.camera.position.set(30, 10, 30); 218 | root.scene.fog = new THREE.FogExp2(0xf1f1f1, 0.01); 219 | 220 | const light = new THREE.DirectionalLight(0xffffff, 1); 221 | light.position.set(0, 1, 0); 222 | root.add(light); 223 | 224 | const light2 = new THREE.DirectionalLight(0xffffff, 1); 225 | light2.position.set(0, -1, 0); 226 | root.add(light2); 227 | 228 | root.add(new THREE.AmbientLight(0xaaaaaa)); 229 | 230 | const hero = new Hero(); 231 | hero.mesh.position.y = -8; 232 | root.add(hero.mesh); 233 | 234 | root.addUpdateCallback(() => { 235 | hero.run(); 236 | }); 237 | 238 | const floor = new THREE.Mesh( 239 | new THREE.PlaneGeometry(400, 400), 240 | new THREE.MeshBasicMaterial({ 241 | color: 0xcccccc 242 | }) 243 | ); 244 | floor.position.y = -8; 245 | floor.rotation.x = -Math.PI * 0.5; 246 | root.add(floor); 247 | 248 | -------------------------------------------------------------------------------- /examples/text/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /examples/text/main.js: -------------------------------------------------------------------------------- 1 | 2 | // set the correct triangulate function for generating text geometries 3 | 4 | THREE.ShapeUtils.triangulateShape = function ( contour, holes ) { 5 | function removeDupEndPts( points ) { 6 | var l = points.length; 7 | if ( l > 2 && points[ l - 1 ].equals( points[ 0 ] ) ) { 8 | points.pop(); 9 | } 10 | } 11 | function addContour( vertices, contour ) { 12 | for ( var i = 0; i < contour.length; i ++ ) { 13 | vertices.push( contour[ i ].x ); 14 | vertices.push( contour[ i ].y ); 15 | } 16 | } 17 | removeDupEndPts( contour ); 18 | holes.forEach( removeDupEndPts ); 19 | var vertices = []; 20 | addContour( vertices, contour ); 21 | var holeIndices = []; 22 | var holeIndex = contour.length; 23 | for ( i = 0; i < holes.length; i ++ ) { 24 | holeIndices.push( holeIndex ); 25 | holeIndex += holes[ i ].length; 26 | addContour( vertices, holes[ i ] ); 27 | } 28 | var result = earcut( vertices, holeIndices, 2 ); 29 | var grouped = []; 30 | for ( var i = 0; i < result.length; i += 3 ) { 31 | grouped.push( result.slice( i, i + 3 ) ); 32 | } 33 | return grouped; 34 | }; 35 | 36 | // scene stuff 37 | 38 | const root = new THREERoot({ 39 | createCameraControls: true, 40 | zNear: 0.01, 41 | zFar: 1000, 42 | antialias: true 43 | }); 44 | 45 | root.renderer.setClearColor(0x000000); 46 | // root.controls.autoRotate = true; 47 | // root.controls.autoRotateSpeed = -6; 48 | root.camera.position.set(0, 0, 60); 49 | root.scene.fog = new THREE.FogExp2(0xf1f1f1, 0.001); 50 | 51 | const light = new THREE.DirectionalLight(0xffffff, 0.75); 52 | light.position.set(1, 1, 1); 53 | root.add(light); 54 | 55 | const light2 = new THREE.DirectionalLight(0xffffff, 0.75); 56 | light2.position.set(-1, 1, 1); 57 | root.add(light2); 58 | 59 | // root.add(new THREE.AmbientLight(0x888888)); 60 | 61 | // text stuff 62 | const string = 'CODEVEMBER'; 63 | const fontUrl = 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/175711/droid_sans_bold.typeface.js'; 64 | const fontParams = { 65 | size: 12, 66 | height: 1, 67 | curveSegments: 1, 68 | 69 | bevelEnabled: false, 70 | bevelThickness: 1, 71 | bevelSize: 1, 72 | material: 0, 73 | extrudeMaterial: 0, 74 | 75 | letterSpacing: 200 76 | }; 77 | 78 | new THREE.FontLoader().load(fontUrl, (font) => { 79 | const letterMeshes = string.split('').map((letter) => { 80 | return createLetterMesh(letter, font); 81 | }); 82 | 83 | const letterGroup = new THREE.Group(); 84 | root.add(letterGroup); 85 | 86 | let offsetX = 0; 87 | letterMeshes.forEach((mesh, i) => { 88 | letterGroup.add(mesh); 89 | 90 | mesh.position.x = offsetX; 91 | offsetX += mesh.userData.ha; 92 | 93 | mesh.setColor(new THREE.Color().setHSL(i / letterMeshes.length, 1.0, 0.5)); 94 | }); 95 | 96 | const bounds = new THREE.Box3(); 97 | 98 | bounds.setFromObject(letterGroup); 99 | 100 | const size = bounds.getSize(); 101 | 102 | letterGroup.position.x = -size.x * 0.5; 103 | 104 | const v = new THREE.Vector3(); 105 | let t = 0; 106 | 107 | root.addUpdateCallback(() => { 108 | letterGroup.children.forEach((child, i) => { 109 | v.copy(child.position); 110 | v.y = (Math.sin((t + i) * 1.2)) * 5; 111 | 112 | child.setPosition(v); 113 | child.update(); 114 | 115 | t += (1/60); 116 | }); 117 | }); 118 | }); 119 | 120 | function createLetterMesh(char, font) { 121 | const geometry = new THREE.TextGeometry(char, { 122 | font, 123 | ...fontParams 124 | }); 125 | 126 | // geometry.center(); 127 | 128 | const modifier = new THREE.TessellateModifier(1); 129 | for (let i = 0; i < 6; i++) { 130 | modifier.modify(geometry); 131 | } 132 | 133 | const mesh = new FuzzyMesh({ 134 | geometry, 135 | config: { 136 | hairLength: 3, 137 | hairRadiusBase: 0.20, 138 | hairRadiusTop: 0.20, 139 | hairRadialSegments: 4, 140 | fuzz: 2, 141 | gravity: 4, 142 | minForceFactor: 0.5, 143 | maxForceFactor: 1.0, 144 | movementForceFactor: 0.9 145 | }, 146 | materialUniformValues: { 147 | roughness: 0.4, 148 | metalness: 0.1 149 | } 150 | }); 151 | 152 | const scale = fontParams.size / font.data.resolution; 153 | const glyph = font.data.glyphs[char]; 154 | 155 | // todo: this doesn't feel like the correct way to calculate letter spacing 156 | mesh.userData.ha = glyph.ha * scale + fontParams.letterSpacing * scale; 157 | 158 | return mesh; 159 | } 160 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fuzzy_geometries", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "three": { 8 | "version": "0.87.1", 9 | "resolved": "https://npm.dpdk.com/three/-/three-0.87.1.tgz", 10 | "integrity": "sha1-Rmo07cRUNFnO2bnX0na2Uhb+K6g=" 11 | }, 12 | "three-bas": { 13 | "version": "2.2.0", 14 | "resolved": "https://npm.dpdk.com/three-bas/-/three-bas-2.2.0.tgz", 15 | "integrity": "sha1-3lFveqbZcAd3+13cBVdbMKK6Sa8=", 16 | "requires": { 17 | "three": "0.87.1" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fuzzy_geometries", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "", 6 | "scripts": { 7 | "dev": "./bin/node_modules/live-server --watch=src,examples" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "three": "^0.87.1", 13 | "three-bas": "^2.2.0" 14 | }, 15 | "devDependencies": { 16 | "live-server": "^1.2.0", 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/FuzzyMesh.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a fuzzy mesh! 3 | * @param params 4 | * @constructor 5 | */ 6 | function FuzzyMesh(params) { 7 | const config = this.config = { 8 | recursiveRotation: true, 9 | hairLength: 1, 10 | hairRadialSegments: 3, 11 | hairHeightSegments: 16, 12 | hairRadiusTop: 0.0, 13 | hairRadiusBase: 0.1, 14 | minForceFactor: 1.0, 15 | maxForceFactor: 1.0, 16 | fuzz: 0.25, 17 | gravity: 1.0, 18 | centrifugalForceFactor: 1, 19 | centrifugalDecay: 0.8, 20 | movementForceFactor: 0.75, 21 | movementDecay: 0.7, 22 | settleDecay: 0.97, // should always be higher than movementDecay and centrifugal decay 23 | ...params.config 24 | }; 25 | const materialUniformValues = { 26 | metalness: 0.5, 27 | roughness: 0.5, 28 | ...params.materialUniformValues 29 | }; 30 | const positions = params.geometry.vertices; 31 | 32 | // create a cone prefab for pointy hair 33 | // create a cylinder prefab for non-pointy hair 34 | let prefab; 35 | 36 | if (config.hairRadiusTop === 0) { 37 | prefab = new THREE.ConeGeometry( 38 | config.hairRadiusBase, 39 | config.hairLength, 40 | config.hairRadialSegments, 41 | config.hairHeightSegments, 42 | true 43 | ); 44 | } 45 | else { 46 | prefab = new THREE.CylinderGeometry( 47 | config.hairRadiusTop, 48 | config.hairRadiusBase, 49 | config.hairLength, 50 | config.hairRadialSegments, 51 | config.hairHeightSegments, 52 | false 53 | ); 54 | } 55 | // cone and cylinder geometries are created around the center 56 | // translate them so the vertices start at y=0 and move up 57 | prefab.translate(0, config.hairLength * 0.5, 0); 58 | 59 | // create a geometry with 1 prefab per vertex of the supplied geometry 60 | const geometry = new BAS.PrefabBufferGeometry(prefab, positions.length); 61 | 62 | // forceFactor is a scalar that multiplies the total force affecting the vertex 63 | geometry.createAttribute('forceFactor', 1, (data) => { 64 | data[0] = THREE.Math.randFloat(config.minForceFactor, config.maxForceFactor); 65 | }); 66 | 67 | // settleOffset is used to make sure the hair don't stop moving at the same time 68 | geometry.createAttribute('settleOffset', 1, (data) => { 69 | data[0] = THREE.Math.randFloat(0, Math.PI * 2); 70 | }); 71 | 72 | // hair positions based on model vertices 73 | geometry.createAttribute('hairPosition', 3, (data, i) => { 74 | positions[i].toArray(data); 75 | }); 76 | 77 | // hair directions 78 | let directions; 79 | 80 | if (params.directions) { 81 | directions = params.directions; 82 | } 83 | // if params.directions is not set, we use vertex normals instead 84 | else { 85 | directions = []; 86 | 87 | params.geometry.computeVertexNormals(); 88 | 89 | // get a flat array of vertex normals 90 | for (let i = 0; i < params.geometry.faces.length; i++) { 91 | const face = params.geometry.faces[i]; 92 | 93 | directions[face.a] = face.vertexNormals[0]; 94 | directions[face.b] = face.vertexNormals[1]; 95 | directions[face.c] = face.vertexNormals[2]; 96 | } 97 | } 98 | 99 | // base hair directions (which direction the hair goes with no force applied to it) 100 | const direction = new THREE.Vector3(); 101 | 102 | geometry.createAttribute('baseDirection', 3, (data, i) => { 103 | direction.copy(directions[i]); 104 | direction.x += THREE.Math.randFloatSpread(config.fuzz); 105 | direction.y += THREE.Math.randFloatSpread(config.fuzz); 106 | direction.z += THREE.Math.randFloatSpread(config.fuzz); 107 | direction.normalize(); 108 | direction.toArray(data); 109 | }); 110 | 111 | const simpleShader = ` 112 | float f = position.y / HAIR_LENGTH; 113 | 114 | vec3 totalForce = globalForce; 115 | 116 | totalForce *= 1.0 - (sin(settleTime + settleOffset) * 0.05 * settleScale); 117 | totalForce += hairPosition * centrifugalDirection * centrifugalForce; 118 | totalForce *= forceFactor; 119 | 120 | vec3 to = normalize(baseDirection + totalForce * f); 121 | vec4 quat = quatFromUnitVectors(UP, to); 122 | 123 | transformed = rotateVector(quat, transformed) + hairPosition; 124 | `; 125 | 126 | const recursiveShader = ` 127 | // accumulator for total force 128 | vec3 totalForce = globalForce; 129 | // add a little offset so the hairs don't all stop moving at the same time 130 | // settleScale is increased when forces are applied, then gradually goes back to zero 131 | totalForce *= 1.0 - (sin(settleTime + settleOffset) * 0.05 * settleScale); 132 | // add force based on rotation 133 | totalForce += hairPosition * centrifugalDirection * centrifugalForce; 134 | // scale force based on a magic number! 135 | totalForce *= forceFactor; 136 | 137 | // accumulator for position 138 | vec3 finalPosition = vec3(0.0, 0.0, 0.0); 139 | // get height fraction between 0.0 and 1.0 140 | float f = position.y / HAIR_LENGTH; 141 | // determine target position based on force and height fraction 142 | vec3 to = normalize(baseDirection + totalForce * f); 143 | // calculate quaterion needed to rotate UP to target rotation 144 | vec4 q = quatFromUnitVectors(UP, to); 145 | // only apply this rotation to position x and z 146 | // position y will be calculated in the loop below 147 | vec3 v = vec3(position.x, 0.0, position.z); 148 | 149 | finalPosition += rotateVector(q, v); 150 | 151 | // recursively calculate rotations using the same approach as above 152 | for (float i = 0.0; i < HAIR_LENGTH; i += SEGMENT_STEP) { 153 | if (position.y <= i) break; 154 | 155 | float f = i * FORCE_STEP; 156 | vec3 to = normalize(baseDirection + totalForce * f); 157 | vec4 q = quatFromUnitVectors(UP, to); 158 | // apply this rotation to a 'segment' 159 | vec3 v = vec3(0.0, SEGMENT_STEP, 0.0); 160 | // all segments leading up to the Y position are added to the final position 161 | finalPosition += rotateVector(q, v); 162 | } 163 | 164 | transformed = finalPosition + hairPosition; 165 | `; 166 | 167 | const material = new BAS.StandardAnimationMaterial({ 168 | flatShading: true, 169 | wireframe: false, 170 | uniformValues: materialUniformValues, 171 | uniforms: { 172 | hairLength: {value: config.hairLength}, 173 | settleTime: {value: 0.0}, 174 | settleScale: {value: 1.0}, 175 | globalForce: {value: new THREE.Vector3(0.0, -config.gravity, 0.0)}, 176 | centrifugalForce: {value: 0.0}, 177 | centrifugalDirection: {value: new THREE.Vector3(1, 0, 1).normalize()} 178 | }, 179 | defines: { 180 | 'HAIR_LENGTH': (config.hairLength).toFixed(2), 181 | 'SEGMENT_STEP': (config.hairLength / config.hairHeightSegments).toFixed(2), 182 | 'FORCE_STEP': (1.0 / config.hairLength).toFixed(2) 183 | }, 184 | vertexParameters: ` 185 | uniform float hairLength; 186 | uniform float heightSteps; 187 | uniform float heightStepSize; 188 | 189 | uniform vec3 globalForce; 190 | uniform float centrifugalForce; 191 | uniform vec3 centrifugalDirection; 192 | uniform float settleTime; 193 | uniform float settleScale; 194 | 195 | attribute float forceFactor; 196 | attribute float settleOffset; 197 | attribute vec3 hairPosition; 198 | attribute vec3 baseDirection; 199 | 200 | vec3 UP = vec3(0.0, 1.0, 0.0); 201 | `, 202 | vertexFunctions: [ 203 | BAS.ShaderChunk.quaternion_rotation, 204 | ` 205 | // based on THREE.Quaternion.setFromUnitVectors 206 | // would be great if we can get rid of the conditionals 207 | vec4 quatFromUnitVectors(vec3 from, vec3 to) { 208 | vec3 v = vec3(0.0, 0.0, 0.0); 209 | float r = dot(from, to) + 1.0; 210 | 211 | if (r < 0.00001) { 212 | r = 0.0; 213 | 214 | if (abs(from.x) > abs(from.z)) { 215 | v.x = -from.y; 216 | v.y = from.x; 217 | v.z = 0.0; 218 | } 219 | else { 220 | v.x = 0.0; 221 | v.y = -from.z; 222 | v.z = from.y; 223 | } 224 | } 225 | else { 226 | v = cross(from, to); 227 | } 228 | 229 | return normalize(vec4(v.xyz, r)); 230 | } 231 | ` 232 | ], 233 | vertexPosition: config.recursiveRotation ? recursiveShader : simpleShader 234 | }); 235 | 236 | THREE.Mesh.call(this, geometry, material); 237 | 238 | // since the bounding box for the hair is never updated, 239 | // set frustumCulled to false so the object doesn't disappear suddenly 240 | this.frustumCulled = false; 241 | // add the base geometry to self 242 | this.baseMesh = new THREE.Mesh( 243 | params.geometry, 244 | new THREE.MeshStandardMaterial(materialUniformValues) 245 | ); 246 | this.add(this.baseMesh); 247 | 248 | // rotation stuff 249 | this._quat = new THREE.Quaternion(); 250 | this.conjugate = new THREE.Quaternion(); 251 | this.rotationAxis = new THREE.Vector3(0, 1, 0); 252 | this.angle = 0.0; 253 | this.previousAngle = this.angle; 254 | 255 | // position stuff 256 | this.previousPosition = this.position.clone(); 257 | this.positionDelta = new THREE.Vector3(); 258 | this.movementForce = new THREE.Vector3(); 259 | } 260 | 261 | FuzzyMesh.prototype = Object.create(THREE.Mesh.prototype); 262 | FuzzyMesh.prototype.constructor = FuzzyMesh; 263 | 264 | FuzzyMesh.prototype.setColor = function(color) { 265 | this.baseMesh.material.color.set(color); 266 | this.material.uniforms.diffuse.value.set(color); 267 | }; 268 | 269 | FuzzyMesh.prototype.setPosition = function(position) { 270 | this.previousPosition.copy(this.position); 271 | this.position.copy(position); 272 | }; 273 | 274 | FuzzyMesh.prototype.setRotationAngle = function(angle) { 275 | this.previousAngle = this.angle; 276 | this.angle = angle; 277 | }; 278 | 279 | FuzzyMesh.prototype.setRotationAxis = function(axis) { 280 | this.setRotationAngle(0); 281 | 282 | const ra = this.rotationAxis; 283 | const cd = this.material.uniforms.centrifugalDirection.value; 284 | const q = this._quat; 285 | 286 | // reset rotation axis and centrifugal direction; 287 | ra.set(0, 1, 0); 288 | cd.set(1, 0, 1); 289 | 290 | // get angle between default rotation axis and target rotation axis 291 | q.setFromUnitVectors(ra, axis); 292 | // apply angle to centrifugal direction 293 | cd.applyQuaternion(q); 294 | // normalize the angle, and make the values absolute 295 | cd.normalize(); 296 | cd.x = Math.abs(cd.x); 297 | cd.y = Math.abs(cd.y); 298 | cd.z = Math.abs(cd.z); 299 | // finally don't forget to update the rotation axis 300 | ra.copy(axis); 301 | }; 302 | 303 | FuzzyMesh.prototype.update = function() { 304 | // apply movement force 305 | this.positionDelta.copy(this.previousPosition).sub(this.position); 306 | 307 | this.movementForce.multiplyScalar(this.config.movementDecay); 308 | this.movementForce.x += this.positionDelta.x * this.config.movementForceFactor; 309 | this.movementForce.y += this.positionDelta.y * this.config.movementForceFactor; 310 | this.movementForce.z += this.positionDelta.z * this.config.movementForceFactor; 311 | 312 | this.material.uniforms.globalForce.value.set( 313 | this.movementForce.x, 314 | this.movementForce.y - this.config.gravity, 315 | this.movementForce.z 316 | ); 317 | 318 | this.previousPosition.copy(this.position); 319 | 320 | // apply centrifugal force 321 | const rotationSpeed = Math.abs(this.previousAngle - this.angle) % (Math.PI * 2); 322 | this.material.uniforms.centrifugalForce.value *= this.config.centrifugalDecay; 323 | this.material.uniforms.centrifugalForce.value += rotationSpeed * this.config.centrifugalForceFactor; 324 | 325 | this.previousAngle = this.angle; 326 | 327 | // adjust global force based on rotation 328 | this.conjugate.copy(this.quaternion).conjugate(); 329 | this.material.uniforms.globalForce.value.applyQuaternion(this.conjugate); 330 | 331 | // apply rotation to object 332 | this.quaternion.setFromAxisAngle(this.rotationAxis, this.angle); 333 | 334 | // rest / settle values 335 | this.material.uniforms.settleTime.value += (1/10); 336 | this.material.uniforms.settleScale.value *= this.config.settleDecay; 337 | this.material.uniforms.settleScale.value += (this.movementForce.length() + rotationSpeed) * 0.1; 338 | this.material.uniforms.settleScale.value > 1.0 && (this.material.uniforms.settleScale.value = 1.0); 339 | }; 340 | --------------------------------------------------------------------------------