├── .gitignore ├── LICENSE ├── README.md ├── css └── base.css ├── favicon.ico ├── index.html ├── js └── main.js └── voxels-preview.gif /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | .parcel-cache 4 | package-lock.json 5 | .DS_Store 6 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2009 - 2022 [Codrops](https://tympanus.net/codrops) 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 | # Turning 3D models to voxel art with Three.js 2 | 3 | Final demo for the tutorial on how to turn glTF models to voxels with Three.js 4 | 5 | ![Image Title](voxels-preview.gif) 6 | 7 | [Article on Codrops](https://tympanus.net/codrops/?p=70997) 8 | 9 | [Demo](http://tympanus.net/Tutorials/Voxelizer/) 10 | 11 | 12 | ## Installation 13 | 14 | No package manager / build system is needed since both Three.js modules and GSAP lib are imported with CDN + import map. You can run the page as it is on local server (any web server, really). 15 | 16 | 17 | The page is using the following libs: 18 | 19 | - Three.js + their addons OrbitControls, GLTFLoader and RoundedBoxGeometry, [more info](https://threejs.org/docs/#manual/en/introduction/Installation) 20 | 21 | - GSAP to handle transitions between 3D models [more info](https://greensock.com/docs/v3/Installation?checked=core) 22 | 23 | ## Credits 24 | 25 | - [Chili Pepper](https://poly.pizza/m/2x3UVYE7D-R) by [jeremy](https://poly.pizza/u/jeremy) [[CC-BY](https://creativecommons.org/licenses/by/3.0/)] via Poly Pizza 26 | - [Chicken](https://poly.pizza/m/1YE8U35HXsI) by [jeremy](https://poly.pizza/u/jeremy) [[CC-BY](https://creativecommons.org/licenses/by/3.0/)] via Poly Pizza 27 | - [Cherry](https://poly.pizza/m/8BsjISKsNIz) by [Poly by Google](https://poly.pizza/u/Poly%20by%20Google) [[CC-BY](https://creativecommons.org/licenses/by/3.0/)] via Poly Pizza 28 | - [Banana Bundle](https://poly.pizza/m/1ySgHdwK0q) by [BlenderVoyage](https://poly.pizza/u/BlenderVoyage) 29 | - [Bonsai](https://poly.pizza/m/44XK5UHTd4Q) by [Don Carson](https://poly.pizza/u/Don%20Carson) [[CC-BY](https://creativecommons.org/licenses/by/3.0/)] via Poly Pizza 30 | - [Egg sunny side up](https://poly.pizza/m/7KnEsnu6Db1) by [Poly by Google](https://poly.pizza/u/Poly%20by%20Google) [[CC-BY](https://creativecommons.org/licenses/by/3.0/)] [[CC-BY](https://creativecommons.org/licenses/by/3.0/)] via Poly Pizza 31 | 32 | ## Misc 33 | 34 | Follow Ksenia: [Twitter](https://twitter.com/uuuuuulala), [Codepen](https://codepen.io/ksenia-k), [website](https://ksenia-k.com/), [Instagram](https://instagram.com/ksenia_showcase/) 35 | 36 | Follow Codrops: [Twitter](http://www.twitter.com/codrops), [Facebook](http://www.facebook.com/codrops), [GitHub](https://github.com/codrops), [Instagram](https://www.instagram.com/codropsss/) 37 | 38 | ## License 39 | [MIT](LICENSE) 40 | 41 | Made with :blue_heart: by [Codrops](http://www.codrops.com) -------------------------------------------------------------------------------- /css/base.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::after, 3 | *::before { 4 | box-sizing: border-box; 5 | } 6 | 7 | :root { 8 | font-size: 16px; 9 | --color-text: #201d1d; 10 | --color-bg: #eeeff2; 11 | --color-link: #b020bc; 12 | --color-link-hover: #201d1d; 13 | } 14 | 15 | body { 16 | margin: 0; 17 | color: var(--color-text); 18 | background-color: var(--color-bg); 19 | font-family: monospace; 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | height: 100vh; 23 | background: radial-gradient(#eeeff2, #f0e1f0); 24 | } 25 | 26 | /* Page Loader */ 27 | .js .loading::before, 28 | .js .loading::after { 29 | content: ''; 30 | position: fixed; 31 | z-index: 1000; 32 | } 33 | 34 | .js .loading::before { 35 | top: 0; 36 | left: 0; 37 | width: 100%; 38 | height: 100%; 39 | background: var(--color-bg); 40 | } 41 | 42 | .js .loading::after { 43 | top: 50%; 44 | left: 50%; 45 | width: 60px; 46 | height: 60px; 47 | margin: -30px 0 0 -30px; 48 | border-radius: 50%; 49 | opacity: 0.4; 50 | background: var(--color-link); 51 | animation: loaderAnim 0.7s linear infinite alternate forwards; 52 | 53 | } 54 | 55 | @keyframes loaderAnim { 56 | to { 57 | opacity: 1; 58 | transform: scale3d(0.5,0.5,1); 59 | } 60 | } 61 | 62 | a { 63 | text-decoration: none; 64 | color: var(--color-link); 65 | outline: none; 66 | cursor: pointer; 67 | } 68 | 69 | a:hover { 70 | color: var(--color-link-hover); 71 | outline: none; 72 | } 73 | 74 | /* Better focus styles from https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible */ 75 | a:focus { 76 | /* Provide a fallback style for browsers 77 | that don't support :focus-visible */ 78 | outline: none; 79 | background: lightgrey; 80 | } 81 | 82 | a:focus:not(:focus-visible) { 83 | /* Remove the focus indicator on mouse-focus for browsers 84 | that do support :focus-visible */ 85 | background: transparent; 86 | } 87 | 88 | a:focus-visible { 89 | /* Draw a very noticeable focus style for 90 | keyboard-focus on browsers that do support 91 | :focus-visible */ 92 | outline: 2px solid red; 93 | background: transparent; 94 | } 95 | 96 | .unbutton { 97 | background: none; 98 | border: 0; 99 | padding: 0; 100 | margin: 0; 101 | font: inherit; 102 | cursor: pointer; 103 | } 104 | 105 | .unbutton:focus { 106 | outline: none; 107 | } 108 | 109 | .frame { 110 | text-align: right; 111 | position: fixed; 112 | z-index: 600; 113 | top: 0; 114 | width: 100%; 115 | padding: 1.5rem; 116 | display: grid; 117 | grid-template-areas: 118 | 'title title' 119 | 'back prev' 120 | 'sponsor sponsor'; 121 | grid-template-columns: auto auto; 122 | justify-content: end; 123 | align-items: end; 124 | grid-gap: 0.5rem; 125 | } 126 | 127 | .frame a { 128 | pointer-events: auto; 129 | white-space: nowrap; 130 | overflow: hidden; 131 | position: relative; 132 | justify-self: end; 133 | } 134 | 135 | .frame a::before { 136 | content: ''; 137 | height: 1px; 138 | width: 100%; 139 | background: currentColor; 140 | position: absolute; 141 | top: 90%; 142 | transition: transform 0.3s; 143 | transform-origin: 0% 50%; 144 | } 145 | 146 | .frame a:hover::before { 147 | transform: scaleX(0); 148 | transform-origin: 100% 50%; 149 | } 150 | 151 | .frame__title { 152 | grid-area: title; 153 | font-size: 2.5rem; 154 | margin: 0; 155 | font-family: "rixvideogame-pro", sans-serif; 156 | font-weight: 400; 157 | } 158 | 159 | .frame__back { 160 | grid-area: back; 161 | } 162 | 163 | .frame__prev { 164 | grid-area: prev; 165 | } 166 | 167 | .content, 168 | .container { 169 | position: fixed; 170 | top: 0; 171 | left: 0; 172 | width: 100%; 173 | height: 100vh; 174 | } 175 | 176 | .container { 177 | display: flex; 178 | justify-content: center; 179 | align-items: center; 180 | } 181 | 182 | #selector { 183 | position: fixed; 184 | bottom: 20px; 185 | left: 0; 186 | display: flex; 187 | flex-direction: row; 188 | align-items: center; 189 | justify-content: center; 190 | width: 100%; 191 | } 192 | 193 | #selector .model-prev { 194 | position: relative; 195 | touch-action: auto !important; 196 | cursor: pointer; 197 | border: 2px solid transparent; 198 | } 199 | 200 | #selector .model-prev:not(.active):hover { 201 | border: 2px solid var(--color-text); 202 | } 203 | 204 | #selector .model-prev:before { 205 | content: ""; 206 | float: left; 207 | padding-top: 100%; 208 | } 209 | 210 | #selector .model-prev.active { 211 | cursor: auto; 212 | } 213 | 214 | #selector .model-prev:after { 215 | position: absolute; 216 | content: ''; 217 | top: 5px; 218 | right: 5px; 219 | width: 10px; 220 | height: 10px; 221 | border-radius: 10px; 222 | background-color: var(--color-text); 223 | display: none; 224 | } 225 | 226 | #selector .model-prev.active:after { 227 | display: block; 228 | } 229 | 230 | @media screen and (min-width: 53em) { 231 | .frame { 232 | text-align: left; 233 | justify-content: start; 234 | align-items: start; 235 | grid-gap: 0.5rem; 236 | display: grid; 237 | grid-template-areas: 238 | 'title title sponsor' 239 | 'back prev sponsor'; 240 | grid-template-columns: auto auto 1fr; 241 | grid-template-rows: auto auto; 242 | } 243 | .frame a { 244 | justify-self: start; 245 | } 246 | } -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uuuulala/Threejs-voxel-art-tutorial/4cc9f04b8cbdcd703b295146f7b3fcdb11c648e7/favicon.ico -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Three.js Voxelizer | Codrops 7 | 8 | 9 | 10 | 11 | 12 | 13 | 23 | 24 | 25 | 26 |
27 |
28 |

Three.js Voxelizer

29 | Read the tutorial 30 | Previous demo 31 |
32 |
33 | 34 |
35 |
36 | loading the models... 37 |
38 |
39 |
40 |
41 |
42 |
43 | 44 | 45 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /js/main.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import {OrbitControls} from "three/addons/controls/OrbitControls.js"; 3 | import {GLTFLoader} from "three/addons/loaders/GLTFLoader.js"; 4 | import {RoundedBoxGeometry} from "three/addons/geometries/RoundedBoxGeometry.js"; 5 | 6 | const containerEl = document.querySelector('.container'); 7 | const canvasEl = document.querySelector('#canvas'); 8 | const selectorEl = document.querySelector('#selector'); 9 | const loaderEl = document.querySelector('#loader'); 10 | 11 | let renderer, mainScene, mainCamera, mainOrbit, lightHolder, topLight; 12 | let instancedMesh, voxelGeometry, voxelMaterial; 13 | let dummy, rayCaster, rayCasterIntersects = []; 14 | let previewScenes = []; 15 | 16 | const voxelsPerModel = []; 17 | let voxels = []; 18 | 19 | let activeModelIdx = 4; 20 | const modelURLs = [ 21 | 'https://ksenia-k.com/models/Chili%20Pepper.glb', 22 | 'https://ksenia-k.com/models/Chicken.glb', 23 | 'https://ksenia-k.com/models/Cherry.glb', 24 | 'https://ksenia-k.com/models/Banana%20Bundle.glb', 25 | 'https://ksenia-k.com/models/Bonsai.glb', 26 | 'https://ksenia-k.com/models/egg.glb', 27 | ] 28 | 29 | const params = { 30 | modelPreviewSize: 2, 31 | modelSize: 9, 32 | gridSize: .24, 33 | boxSize: .24, 34 | boxRoundness: .03 35 | } 36 | 37 | createMainScene(); 38 | loadModels(); 39 | 40 | window.addEventListener('resize', updateSceneSize); 41 | 42 | function createMainScene() { 43 | 44 | renderer = new THREE.WebGLRenderer({ 45 | canvas: canvasEl, 46 | alpha: true, 47 | antialias: true 48 | }); 49 | renderer.shadowMap.enabled = true; 50 | renderer.shadowMapSoft = true; 51 | 52 | renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 53 | renderer.setScissorTest(true); 54 | 55 | mainScene = new THREE.Scene(); 56 | 57 | mainCamera = new THREE.PerspectiveCamera(45, containerEl.clientWidth / containerEl.clientHeight, .01, 1000); 58 | mainCamera.position.set(0, .5, 2).multiplyScalar(8); 59 | 60 | rayCaster = new THREE.Raycaster(); 61 | dummy = new THREE.Object3D(); 62 | 63 | const ambientLight = new THREE.AmbientLight(0xffffff, .5); 64 | mainScene.add(ambientLight); 65 | 66 | lightHolder = new THREE.Group(); 67 | 68 | topLight = new THREE.SpotLight(0xffffff, .4); 69 | topLight.position.set(0, 15, 3); 70 | topLight.castShadow = true; 71 | topLight.shadow.camera.near = 10; 72 | topLight.shadow.camera.far = 30; 73 | topLight.shadow.mapSize = new THREE.Vector2(1024, 1024); 74 | lightHolder.add(topLight); 75 | 76 | const sideLight = new THREE.SpotLight(0xffffff, .4); 77 | sideLight.position.set(0, -4, 5); 78 | lightHolder.add(sideLight); 79 | 80 | mainScene.add(lightHolder); 81 | 82 | mainOrbit = new OrbitControls(mainCamera, containerEl); 83 | mainOrbit.enablePan = false; 84 | mainOrbit.autoRotate = true; 85 | mainOrbit.minDistance = 20; 86 | mainOrbit.maxDistance = 30; 87 | mainOrbit.minPolarAngle = .35 * Math.PI; 88 | mainOrbit.maxPolarAngle = .65 * Math.PI; 89 | mainOrbit.enableDamping = true; 90 | 91 | voxelGeometry = new RoundedBoxGeometry(params.boxSize, params.boxSize, params.boxSize, 2, params.boxRoundness); 92 | voxelMaterial = new THREE.MeshLambertMaterial({}); 93 | 94 | const planeGeometry = new THREE.PlaneGeometry(35, 35); 95 | const shadowPlaneMaterial = new THREE.ShadowMaterial({ 96 | opacity: .1 97 | }); 98 | const shadowPlaneMesh = new THREE.Mesh(planeGeometry, shadowPlaneMaterial); 99 | shadowPlaneMesh.position.y = -4; 100 | shadowPlaneMesh.rotation.x = -.5 * Math.PI; 101 | shadowPlaneMesh.receiveShadow = true; 102 | 103 | lightHolder.add(shadowPlaneMesh); 104 | } 105 | 106 | function createPreviewScene(modelIdx) { 107 | const scene = new THREE.Scene(); 108 | 109 | scene.background = new THREE.Color().setHSL((modelIdx / modelURLs.length), .5, .7); 110 | 111 | const element = document.createElement('div'); 112 | element.className = "model-prev"; 113 | scene.userData.element = element; 114 | scene.userData.modelIdx = modelIdx; 115 | selectorEl.appendChild(element); 116 | 117 | const camera = new THREE.PerspectiveCamera(50, 1, 1, 100); 118 | camera.position.set(0, 1, 2).multiplyScalar(1.2); 119 | scene.userData.camera = camera; 120 | 121 | const orbit = new OrbitControls(scene.userData.camera, scene.userData.element); 122 | orbit.minDistance = 2; 123 | orbit.maxDistance = 5; 124 | orbit.autoRotate = true; 125 | orbit.autoRotateSpeed = 6; 126 | orbit.enableDamping = true; 127 | scene.userData.orbit = orbit; 128 | 129 | const ambientLight = new THREE.AmbientLight(0xffffff, .9); 130 | scene.add(ambientLight); 131 | const sideLight = new THREE.PointLight(0xffffff, .7); 132 | sideLight.position.set(2, 0, 5); 133 | scene.add(sideLight); 134 | 135 | return scene; 136 | } 137 | 138 | function loadModels() { 139 | 140 | recreateInstancedMesh(100); 141 | 142 | const loader = new GLTFLoader(); 143 | let modelsLoadCnt = 0; 144 | modelURLs.forEach((url, modelIdx) => { 145 | 146 | // prepare
and Three.js scene for model preview 147 | const scene = createPreviewScene(modelIdx); 148 | previewScenes.push(scene); 149 | 150 | // load .glb file 151 | loader.load(url, (gltf) => { 152 | 153 | // add scaled and centered model to the preview panel; 154 | addModelToPreview(modelIdx, gltf.scene) 155 | 156 | // get the voxel data from the model 157 | voxelizeModel(modelIdx, gltf.scene); 158 | 159 | // update the instanced mesh 160 | const numberOfInstances = Math.max(...voxelsPerModel.map(m => m.length)); 161 | if (numberOfInstances > instancedMesh.count) { 162 | recreateInstancedMesh(numberOfInstances); 163 | } 164 | 165 | // once all the models are loaded... 166 | modelsLoadCnt++; 167 | if (modelsLoadCnt === 1) { 168 | // Once we have once voxelized model ready, start rendering the available content 169 | gsap.set(loaderEl, { 170 | innerHTML: "calculating the voxels...", 171 | y: .3 * window.innerHeight 172 | }) 173 | updateSceneSize(); 174 | render(); 175 | } 176 | if (modelsLoadCnt === modelURLs.length) { 177 | // Once we have all the models voxelized, start the animation 178 | gsap.to(loaderEl, { 179 | duration: .3, 180 | opacity: 0 181 | }) 182 | animateVoxels(0, activeModelIdx); 183 | setupSelectorEvents(); 184 | } 185 | }, undefined, (error) => { 186 | console.error(error); 187 | }); 188 | }) 189 | } 190 | 191 | function setupSelectorEvents() { 192 | 193 | const highlightActivePreview = () => { 194 | Array.from(document.querySelectorAll('.model-prev')).forEach((el, idx) => { 195 | if (idx !== activeModelIdx) { 196 | el.classList.remove('active') 197 | } else { 198 | el.classList.add('active') 199 | } 200 | }) 201 | } 202 | 203 | let timeOut, isHeldDown = false; 204 | highlightActivePreview(); 205 | 206 | previewScenes.forEach(scene => { 207 | scene.userData.element.addEventListener('mouseup', () => { 208 | clearTimeout(timeOut); 209 | if (!isHeldDown) { 210 | animateVoxels(activeModelIdx, scene.userData.modelIdx); 211 | activeModelIdx = scene.userData.modelIdx; 212 | highlightActivePreview(); 213 | } 214 | isHeldDown = false; 215 | }) 216 | }); 217 | window.addEventListener('mousedown', () => { 218 | timeOut = setTimeout(() => { 219 | isHeldDown = true; 220 | }, 200); 221 | }); 222 | window.addEventListener('mouseup', e => { 223 | clearTimeout(timeOut); 224 | if (!isHeldDown) { 225 | if (!e.target.classList.contains('model-prev')) { 226 | if (modelURLs[activeModelIdx + 1]) { 227 | animateVoxels(activeModelIdx, activeModelIdx + 1); 228 | activeModelIdx++; 229 | } else { 230 | animateVoxels(activeModelIdx, 0); 231 | activeModelIdx = 0; 232 | } 233 | highlightActivePreview(); 234 | } 235 | } 236 | isHeldDown = false; 237 | }); 238 | } 239 | 240 | function addModelToPreview(modelIdx, importedScene) { 241 | const model = importedScene.clone(); 242 | const box = new THREE.Box3().setFromObject(model); 243 | const size = box.getSize(new THREE.Vector3()); 244 | const scaleFactor = params.modelPreviewSize / size.length(); 245 | 246 | const center = box.getCenter(new THREE.Vector3()).multiplyScalar(-scaleFactor); 247 | model.position.copy(center); 248 | model.scale.set(scaleFactor, scaleFactor, scaleFactor); 249 | previewScenes[modelIdx].add(model); 250 | } 251 | 252 | function voxelizeModel(modelIdx, importedScene) { 253 | 254 | const importedMeshes = []; 255 | importedScene.traverse((child) => { 256 | if (child instanceof THREE.Mesh) { 257 | child.material.side = THREE.DoubleSide; 258 | importedMeshes.push(child); 259 | } 260 | }); 261 | 262 | let boundingBox = new THREE.Box3().setFromObject(importedScene); 263 | const size = boundingBox.getSize(new THREE.Vector3()); 264 | const scaleFactor = params.modelSize / size.length(); 265 | const center = boundingBox.getCenter(new THREE.Vector3()).multiplyScalar(-scaleFactor); 266 | 267 | importedScene.scale.multiplyScalar(scaleFactor); 268 | importedScene.position.copy(center); 269 | 270 | boundingBox = new THREE.Box3().setFromObject(importedScene); 271 | boundingBox.min.y += .5 * params.gridSize; // for egg grid to look better 272 | 273 | let modelVoxels = []; 274 | 275 | for (let i = boundingBox.min.x; i < boundingBox.max.x; i += params.gridSize) { 276 | for (let j = boundingBox.min.y; j < boundingBox.max.y; j += params.gridSize) { 277 | for (let k = boundingBox.min.z; k < boundingBox.max.z; k += params.gridSize) { 278 | for (let meshCnt = 0; meshCnt < importedMeshes.length; meshCnt++) { 279 | const mesh = importedMeshes[meshCnt]; 280 | 281 | const color = new THREE.Color(); 282 | const {h, s, l} = mesh.material.color.getHSL(color); 283 | color.setHSL(h, s * .8, l * .8 + .2); 284 | const pos = new THREE.Vector3(i, j, k); 285 | 286 | if (isInsideMesh(pos, new THREE.Vector3(0, 0, 1), mesh)) { 287 | modelVoxels.push({color: color, position: pos}); 288 | break; 289 | } 290 | } 291 | } 292 | } 293 | } 294 | 295 | voxelsPerModel[modelIdx] = modelVoxels; 296 | } 297 | 298 | function isInsideMesh(pos, ray, mesh) { 299 | rayCaster.set(pos, ray); 300 | rayCasterIntersects = rayCaster.intersectObject(mesh, false); 301 | return rayCasterIntersects.length % 2 === 1; 302 | } 303 | 304 | function recreateInstancedMesh(cnt) { 305 | 306 | // remove the old mesh and voxels data 307 | voxels = []; 308 | mainScene.remove(instancedMesh); 309 | 310 | // re-initiate the voxel array with random colors and positions 311 | for (let i = 0; i < cnt; i++) { 312 | const randomCoordinate = () => { 313 | let v = (Math.random() - .5); 314 | v -= (v % params.gridSize); 315 | return v; 316 | } 317 | voxels.push({ 318 | position: new THREE.Vector3(randomCoordinate(), randomCoordinate(), randomCoordinate()), 319 | color: new THREE.Color().setHSL(Math.random(), .8, .8) 320 | }) 321 | } 322 | 323 | // create a new instanced mesh object 324 | instancedMesh = new THREE.InstancedMesh(voxelGeometry, voxelMaterial, cnt); 325 | instancedMesh.castShadow = true; 326 | instancedMesh.receiveShadow = true; 327 | 328 | // assign voxels data to the instanced mesh 329 | for (let i = 0; i < cnt; i++) { 330 | instancedMesh.setColorAt(i, voxels[i].color); 331 | dummy.position.copy(voxels[i].position); 332 | dummy.updateMatrix(); 333 | instancedMesh.setMatrixAt(i, dummy.matrix); 334 | } 335 | instancedMesh.instanceMatrix.needsUpdate = true; 336 | instancedMesh.instanceColor.needsUpdate = true; 337 | 338 | // add a new mesh to the scene 339 | mainScene.add(instancedMesh); 340 | } 341 | 342 | function animateVoxels(oldModelIdx, newModelIdx) { 343 | 344 | // animate voxels data 345 | for (let i = 0; i < voxels.length; i++) { 346 | 347 | gsap.killTweensOf(voxels[i].color); 348 | gsap.killTweensOf(voxels[i].position); 349 | 350 | const duration = .5 + .5 * Math.pow(Math.random(), 6); 351 | let targetPos; 352 | 353 | // move to new position if we have one; 354 | // otherwise, move to a randomly selected existing position 355 | // 356 | // animate to new color if it's determined 357 | // otherwise, voxel will be just hidden by animation of instancedMesh.count 358 | 359 | if (voxelsPerModel[newModelIdx][i]) { 360 | targetPos = voxelsPerModel[newModelIdx][i].position; 361 | gsap.to(voxels[i].color, { 362 | delay: .7 * Math.random() * duration, 363 | duration: .05, 364 | r: voxelsPerModel[newModelIdx][i].color.r, 365 | g: voxelsPerModel[newModelIdx][i].color.g, 366 | b: voxelsPerModel[newModelIdx][i].color.b, 367 | ease: "power1.in", 368 | onUpdate: () => { 369 | instancedMesh.setColorAt(i, voxels[i].color); 370 | } 371 | }) 372 | } else { 373 | targetPos = voxelsPerModel[newModelIdx][Math.floor(voxelsPerModel[newModelIdx].length * Math.random())].position; 374 | } 375 | 376 | // move to new position if it's determined 377 | gsap.to(voxels[i].position, { 378 | delay: .2 * Math.random(), 379 | duration: duration, 380 | x: targetPos.x, 381 | y: targetPos.y, 382 | z: targetPos.z, 383 | ease: "back.out(3)", 384 | onUpdate: () => { 385 | dummy.position.copy(voxels[i].position); 386 | dummy.updateMatrix(); 387 | instancedMesh.setMatrixAt(i, dummy.matrix); 388 | } 389 | }); 390 | } 391 | 392 | // increase the model rotation during transition 393 | gsap.to(instancedMesh.rotation, { 394 | duration: 1.2, 395 | y: "+=" + 1.3 * Math.PI, 396 | ease: "power2.out" 397 | }) 398 | 399 | // show the right number of voxels 400 | gsap.to(instancedMesh, { 401 | duration: .4, 402 | count: voxelsPerModel[newModelIdx].length 403 | }) 404 | 405 | // update the instanced mesh accordingly to voxels data 406 | gsap.to({}, { 407 | duration: 1, // max transition duration 408 | onUpdate: () => { 409 | instancedMesh.instanceColor.needsUpdate = true; 410 | instancedMesh.instanceMatrix.needsUpdate = true; 411 | } 412 | }); 413 | } 414 | 415 | function render() { 416 | renderer.setViewport(0, 0, containerEl.clientWidth, containerEl.clientHeight); 417 | renderer.setScissor(0, 0, containerEl.clientWidth, containerEl.clientHeight); 418 | mainOrbit.update(); 419 | lightHolder.quaternion.copy(mainCamera.quaternion); 420 | renderer.render(mainScene, mainCamera); 421 | 422 | // render previews 423 | previewScenes.forEach((scene) => { 424 | renderer.setViewport(scene.userData.rect.left, scene.userData.rect.bottom, scene.userData.rect.width, scene.userData.rect.height); 425 | renderer.setScissor(scene.userData.rect.left, scene.userData.rect.bottom, scene.userData.rect.width, scene.userData.rect.height); 426 | scene.userData.orbit.update(); 427 | renderer.render(scene, scene.userData.camera); 428 | }); 429 | 430 | requestAnimationFrame(render); 431 | } 432 | 433 | function updateSceneSize() { 434 | mainCamera.aspect = containerEl.clientWidth / containerEl.clientHeight; 435 | mainCamera.updateProjectionMatrix(); 436 | 437 | previewScenes.forEach(scene => { 438 | scene.userData.element.style.width = Math.min(90, window.innerHeight * .8 / modelURLs.length) + 'px'; 439 | }) 440 | previewScenes.forEach(scene => { 441 | const rect = scene.userData.element.getBoundingClientRect(); 442 | scene.userData.rect = { 443 | width: rect.right - rect.left, 444 | height: rect.bottom - rect.top, 445 | left: rect.left, 446 | bottom: containerEl.clientHeight - rect.bottom 447 | } 448 | scene.userData.camera.aspect = scene.userData.element.clientWidth / scene.userData.element.clientHeight; 449 | scene.userData.camera.updateProjectionMatrix(); 450 | }); 451 | 452 | renderer.setSize(containerEl.clientWidth, containerEl.clientHeight); 453 | } -------------------------------------------------------------------------------- /voxels-preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uuuulala/Threejs-voxel-art-tutorial/4cc9f04b8cbdcd703b295146f7b3fcdb11c648e7/voxels-preview.gif --------------------------------------------------------------------------------