├── .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 | 
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 |
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
--------------------------------------------------------------------------------