this repo has no description
1import {
2 AmbientLight,
3 AnimationMixer,
4 AxesHelper,
5 Box3,
6 Cache,
7 Color,
8 DirectionalLight,
9 GridHelper,
10 HemisphereLight,
11 LoaderUtils,
12 LoadingManager,
13 PMREMGenerator,
14 PerspectiveCamera,
15 PointsMaterial,
16 REVISION,
17 Scene,
18 SkeletonHelper,
19 Vector3,
20 WebGLRenderer,
21 LinearToneMapping,
22 ACESFilmicToneMapping,
23} from 'three';
24import Stats from 'three/addons/libs/stats.module.js';
25import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
26import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
27import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
28import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
29import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
30import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
31import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
32
33import { GUI } from 'dat.gui';
34
35import { environments } from './environments.js';
36
37const DEFAULT_CAMERA = '[default]';
38
39const MANAGER = new LoadingManager();
40const THREE_PATH = `https://unpkg.com/three@0.${REVISION}.x`;
41const DRACO_LOADER = new DRACOLoader(MANAGER).setDecoderPath(
42 `${THREE_PATH}/examples/jsm/libs/draco/gltf/`,
43);
44const KTX2_LOADER = new KTX2Loader(MANAGER).setTranscoderPath(
45 `${THREE_PATH}/examples/jsm/libs/basis/`,
46);
47
48const IS_IOS = isIOS();
49
50const Preset = { ASSET_GENERATOR: 'assetgenerator' };
51
52Cache.enabled = true;
53
54export class Viewer {
55 constructor(el, options) {
56 this.el = el;
57 this.options = options;
58
59 this.lights = [];
60 this.content = null;
61 this.mixer = null;
62 this.clips = [];
63 this.gui = null;
64
65 this.state = {
66 environment:
67 options.preset === Preset.ASSET_GENERATOR
68 ? environments.find((e) => e.id === 'footprint-court').name
69 : environments[1].name,
70 background: false,
71 playbackSpeed: 1.0,
72 actionStates: {},
73 animationPaused: false,
74 camera: DEFAULT_CAMERA,
75 wireframe: false,
76 skeleton: false,
77 grid: false,
78 autoRotate: false,
79
80 // Lights
81 punctualLights: true,
82 exposure: 0.0,
83 toneMapping: LinearToneMapping,
84 ambientIntensity: 0.3,
85 ambientColor: '#FFFFFF',
86 directIntensity: 0.8 * Math.PI, // TODO(#116)
87 directColor: '#FFFFFF',
88 bgColor: '#191919',
89
90 pointSize: 1.0,
91 };
92
93 this.prevTime = 0;
94
95 this.stats = new Stats();
96 this.stats.dom.height = '48px';
97 [].forEach.call(this.stats.dom.children, (child) => (child.style.display = ''));
98
99 this.backgroundColor = new Color(this.state.bgColor);
100
101 this.scene = new Scene();
102 this.scene.background = this.backgroundColor;
103
104 const fov = options.preset === Preset.ASSET_GENERATOR ? (0.8 * 180) / Math.PI : 60;
105 const aspect = el.clientWidth / el.clientHeight;
106 this.defaultCamera = new PerspectiveCamera(fov, aspect, 0.01, 1000);
107 this.activeCamera = this.defaultCamera;
108 this.scene.add(this.defaultCamera);
109
110 this.renderer = window.renderer = new WebGLRenderer({ antialias: true });
111 this.renderer.setClearColor(0xcccccc);
112 this.renderer.setPixelRatio(window.devicePixelRatio);
113 this.renderer.setSize(el.clientWidth, el.clientHeight);
114
115 this.pmremGenerator = new PMREMGenerator(this.renderer);
116 this.pmremGenerator.compileEquirectangularShader();
117
118 this.neutralEnvironment = this.pmremGenerator.fromScene(new RoomEnvironment()).texture;
119
120 this.controls = new OrbitControls(this.defaultCamera, this.renderer.domElement);
121 this.controls.screenSpacePanning = true;
122
123 this.el.appendChild(this.renderer.domElement);
124
125 this.cameraCtrl = null;
126 this.cameraFolder = null;
127 this.animFolder = null;
128 this.animCtrls = [];
129 this.morphFolder = null;
130 this.morphCtrls = [];
131 this.skeletonHelpers = [];
132 this.gridHelper = null;
133 this.axesHelper = null;
134
135 this.addAxesHelper();
136 this.addGUI();
137 if (options.kiosk) this.gui.close();
138
139 this.animate = this.animate.bind(this);
140 requestAnimationFrame(this.animate);
141 window.addEventListener('resize', this.resize.bind(this), false);
142 }
143
144 animate(time) {
145 requestAnimationFrame(this.animate);
146
147 const dt = (time - this.prevTime) / 1000;
148
149 this.controls.update();
150 this.stats.update();
151 this.mixer && this.mixer.update(dt);
152 this.render();
153
154 this.prevTime = time;
155 }
156
157 render() {
158 this.renderer.render(this.scene, this.activeCamera);
159 if (this.state.grid) {
160 this.axesCamera.position.copy(this.defaultCamera.position);
161 this.axesCamera.lookAt(this.axesScene.position);
162 this.axesRenderer.render(this.axesScene, this.axesCamera);
163 }
164 }
165
166 resize() {
167 const { clientHeight, clientWidth } = this.el.parentElement;
168
169 this.defaultCamera.aspect = clientWidth / clientHeight;
170 this.defaultCamera.updateProjectionMatrix();
171 this.renderer.setSize(clientWidth, clientHeight);
172
173 this.axesCamera.aspect = this.axesDiv.clientWidth / this.axesDiv.clientHeight;
174 this.axesCamera.updateProjectionMatrix();
175 this.axesRenderer.setSize(this.axesDiv.clientWidth, this.axesDiv.clientHeight);
176 }
177
178 load(url, rootPath, assetMap) {
179 const baseURL = LoaderUtils.extractUrlBase(url);
180
181 // Load.
182 return new Promise((resolve, reject) => {
183 // Intercept and override relative URLs.
184 MANAGER.setURLModifier((url, path) => {
185 // URIs in a glTF file may be escaped, or not. Assume that assetMap is
186 // from an un-escaped source, and decode all URIs before lookups.
187 // See: https://github.com/donmccurdy/three-gltf-viewer/issues/146
188 const normalizedURL =
189 rootPath +
190 decodeURI(url)
191 .replace(baseURL, '')
192 .replace(/^(\.?\/)/, '');
193
194 if (assetMap.has(normalizedURL)) {
195 const blob = assetMap.get(normalizedURL);
196 const blobURL = URL.createObjectURL(blob);
197 blobURLs.push(blobURL);
198 return blobURL;
199 }
200
201 return (path || '') + url;
202 });
203
204 const loader = new GLTFLoader(MANAGER)
205 .setCrossOrigin('anonymous')
206 .setDRACOLoader(DRACO_LOADER)
207 .setKTX2Loader(KTX2_LOADER.detectSupport(this.renderer))
208 .setMeshoptDecoder(MeshoptDecoder);
209
210 const blobURLs = [];
211
212 loader.load(
213 url,
214 (gltf) => {
215 window.VIEWER.json = gltf;
216
217 const scene = gltf.scene || gltf.scenes[0];
218 const clips = gltf.animations || [];
219
220 if (!scene) {
221 // Valid, but not supported by this viewer.
222 throw new Error(
223 'This model contains no scene, and cannot be viewed here. However,' +
224 ' it may contain individual 3D resources.',
225 );
226 }
227
228 this.setContent(scene, clips);
229
230 blobURLs.forEach(URL.revokeObjectURL);
231
232 // See: https://github.com/google/draco/issues/349
233 // DRACOLoader.releaseDecoderModule();
234
235 resolve(gltf);
236 },
237 undefined,
238 reject,
239 );
240 });
241 }
242
243 /**
244 * @param {THREE.Object3D} object
245 * @param {Array<THREE.AnimationClip} clips
246 */
247 setContent(object, clips) {
248 this.clear();
249
250 object.updateMatrixWorld(); // donmccurdy/three-gltf-viewer#330
251
252 const box = new Box3().setFromObject(object);
253 const size = box.getSize(new Vector3()).length();
254 const center = box.getCenter(new Vector3());
255
256 this.controls.reset();
257
258 object.position.x -= center.x;
259 object.position.y -= center.y;
260 object.position.z -= center.z;
261
262 this.controls.maxDistance = size * 10;
263
264 this.defaultCamera.near = size / 100;
265 this.defaultCamera.far = size * 100;
266 this.defaultCamera.updateProjectionMatrix();
267
268 if (this.options.cameraPosition) {
269 this.defaultCamera.position.fromArray(this.options.cameraPosition);
270 this.defaultCamera.lookAt(new Vector3());
271 } else {
272 this.defaultCamera.position.copy(center);
273 this.defaultCamera.position.x += size / 2.0;
274 this.defaultCamera.position.y += size / 5.0;
275 this.defaultCamera.position.z += size / 2.0;
276 this.defaultCamera.lookAt(center);
277 }
278
279 this.setCamera(DEFAULT_CAMERA);
280
281 this.axesCamera.position.copy(this.defaultCamera.position);
282 this.axesCamera.lookAt(this.axesScene.position);
283 this.axesCamera.near = size / 100;
284 this.axesCamera.far = size * 100;
285 this.axesCamera.updateProjectionMatrix();
286 this.axesCorner.scale.set(size, size, size);
287
288 this.controls.saveState();
289
290 this.scene.add(object);
291 this.content = object;
292
293 this.state.punctualLights = true;
294
295 this.content.traverse((node) => {
296 if (node.isLight) {
297 this.state.punctualLights = false;
298 }
299 });
300
301 this.setClips(clips);
302
303 this.updateLights();
304 this.updateGUI();
305 this.updateEnvironment();
306 this.updateDisplay();
307
308 window.VIEWER.scene = this.content;
309
310 this.printGraph(this.content);
311 }
312
313 printGraph(node) {
314 console.group(' <' + node.type + '> ' + node.name);
315 node.children.forEach((child) => this.printGraph(child));
316 console.groupEnd();
317 }
318
319 /**
320 * @param {Array<THREE.AnimationClip} clips
321 */
322 setClips(clips) {
323 if (this.mixer) {
324 this.mixer.stopAllAction();
325 this.mixer.uncacheRoot(this.mixer.getRoot());
326 this.mixer = null;
327 }
328
329 this.state.animationPaused = false;
330
331 this.clips = clips;
332 if (!clips.length) return;
333
334 this.mixer = new AnimationMixer(this.content);
335 }
336
337 playAllClips() {
338 this.clips.forEach((clip) => {
339 this.mixer.clipAction(clip).reset().play();
340 this.state.actionStates[clip.name] = true;
341 });
342 }
343
344 toggleAnimation(paused) {
345 if (!this.mixer) return;
346
347 if (paused) {
348 this.mixer.timeScale = 0;
349 } else {
350 this.mixer.timeScale = this.state.playbackSpeed;
351 }
352 }
353
354 /**
355 * @param {string} name
356 */
357 setCamera(name) {
358 if (name === DEFAULT_CAMERA) {
359 this.controls.enabled = true;
360 this.activeCamera = this.defaultCamera;
361 } else {
362 this.controls.enabled = false;
363 this.content.traverse((node) => {
364 if (node.isCamera && node.name === name) {
365 this.activeCamera = node;
366 }
367 });
368 }
369 }
370
371 updateLights() {
372 const state = this.state;
373 const lights = this.lights;
374
375 if (state.punctualLights && !lights.length) {
376 this.addLights();
377 } else if (!state.punctualLights && lights.length) {
378 this.removeLights();
379 }
380
381 this.renderer.toneMapping = Number(state.toneMapping);
382 this.renderer.toneMappingExposure = Math.pow(2, state.exposure);
383
384 if (lights.length === 2) {
385 lights[0].intensity = state.ambientIntensity;
386 lights[0].color.set(state.ambientColor);
387 lights[1].intensity = state.directIntensity;
388 lights[1].color.set(state.directColor);
389 }
390 }
391
392 addLights() {
393 const state = this.state;
394
395 if (this.options.preset === Preset.ASSET_GENERATOR) {
396 const hemiLight = new HemisphereLight();
397 hemiLight.name = 'hemi_light';
398 this.scene.add(hemiLight);
399 this.lights.push(hemiLight);
400 return;
401 }
402
403 const light1 = new AmbientLight(state.ambientColor, state.ambientIntensity);
404 light1.name = 'ambient_light';
405 this.defaultCamera.add(light1);
406
407 const light2 = new DirectionalLight(state.directColor, state.directIntensity);
408 light2.position.set(0.5, 0, 0.866); // ~60º
409 light2.name = 'main_light';
410 this.defaultCamera.add(light2);
411
412 this.lights.push(light1, light2);
413 }
414
415 removeLights() {
416 this.lights.forEach((light) => light.parent.remove(light));
417 this.lights.length = 0;
418 }
419
420 updateEnvironment() {
421 const environment = environments.filter(
422 (entry) => entry.name === this.state.environment,
423 )[0];
424
425 this.getCubeMapTexture(environment).then(({ envMap }) => {
426 this.scene.environment = envMap;
427 this.scene.background = this.state.background ? envMap : this.backgroundColor;
428 });
429 }
430
431 getCubeMapTexture(environment) {
432 const { id, path } = environment;
433
434 // neutral (THREE.RoomEnvironment)
435 if (id === 'neutral') {
436 return Promise.resolve({ envMap: this.neutralEnvironment });
437 }
438
439 // none
440 if (id === '') {
441 return Promise.resolve({ envMap: null });
442 }
443
444 return new Promise((resolve, reject) => {
445 new EXRLoader().load(
446 path,
447 (texture) => {
448 const envMap = this.pmremGenerator.fromEquirectangular(texture).texture;
449 this.pmremGenerator.dispose();
450
451 resolve({ envMap });
452 },
453 undefined,
454 reject,
455 );
456 });
457 }
458
459 updateDisplay() {
460 if (this.skeletonHelpers.length) {
461 this.skeletonHelpers.forEach((helper) => this.scene.remove(helper));
462 }
463
464 traverseMaterials(this.content, (material) => {
465 material.wireframe = this.state.wireframe;
466
467 if (material instanceof PointsMaterial) {
468 material.size = this.state.pointSize;
469 }
470 });
471
472 this.content.traverse((node) => {
473 if (node.geometry && node.skeleton && this.state.skeleton) {
474 const helper = new SkeletonHelper(node.skeleton.bones[0].parent);
475 helper.material.linewidth = 3;
476 this.scene.add(helper);
477 this.skeletonHelpers.push(helper);
478 }
479 });
480
481 if (this.state.grid !== Boolean(this.gridHelper)) {
482 if (this.state.grid) {
483 this.gridHelper = new GridHelper();
484 this.axesHelper = new AxesHelper();
485 this.axesHelper.renderOrder = 999;
486 this.axesHelper.onBeforeRender = (renderer) => renderer.clearDepth();
487 this.scene.add(this.gridHelper);
488 this.scene.add(this.axesHelper);
489 } else {
490 this.scene.remove(this.gridHelper);
491 this.scene.remove(this.axesHelper);
492 this.gridHelper = null;
493 this.axesHelper = null;
494 this.axesRenderer.clear();
495 }
496 }
497
498 this.controls.autoRotate = this.state.autoRotate;
499 }
500
501 updateBackground() {
502 this.backgroundColor.set(this.state.bgColor);
503 }
504
505 /**
506 * Adds AxesHelper.
507 *
508 * See: https://stackoverflow.com/q/16226693/1314762
509 */
510 addAxesHelper() {
511 this.axesDiv = document.createElement('div');
512 this.el.appendChild(this.axesDiv);
513 this.axesDiv.classList.add('axes');
514
515 const { clientWidth, clientHeight } = this.axesDiv;
516
517 this.axesScene = new Scene();
518 this.axesCamera = new PerspectiveCamera(50, clientWidth / clientHeight, 0.1, 10);
519 this.axesScene.add(this.axesCamera);
520
521 this.axesRenderer = new WebGLRenderer({ alpha: true });
522 this.axesRenderer.setPixelRatio(window.devicePixelRatio);
523 this.axesRenderer.setSize(this.axesDiv.clientWidth, this.axesDiv.clientHeight);
524
525 this.axesCamera.up = this.defaultCamera.up;
526
527 this.axesCorner = new AxesHelper(5);
528 this.axesScene.add(this.axesCorner);
529 this.axesDiv.appendChild(this.axesRenderer.domElement);
530 }
531
532 addGUI() {
533 const gui = (this.gui = new GUI({
534 autoPlace: false,
535 width: 260,
536 hideable: true,
537 }));
538
539 // Display controls.
540 const dispFolder = gui.addFolder('Display');
541 const envBackgroundCtrl = dispFolder.add(this.state, 'background');
542 envBackgroundCtrl.onChange(() => this.updateEnvironment());
543 const autoRotateCtrl = dispFolder.add(this.state, 'autoRotate');
544 autoRotateCtrl.onChange(() => this.updateDisplay());
545 const wireframeCtrl = dispFolder.add(this.state, 'wireframe');
546 wireframeCtrl.onChange(() => this.updateDisplay());
547 const skeletonCtrl = dispFolder.add(this.state, 'skeleton');
548 skeletonCtrl.onChange(() => this.updateDisplay());
549 const gridCtrl = dispFolder.add(this.state, 'grid');
550 gridCtrl.onChange(() => this.updateDisplay());
551 dispFolder.add(this.controls, 'screenSpacePanning');
552 const pointSizeCtrl = dispFolder.add(this.state, 'pointSize', 1, 16);
553 pointSizeCtrl.onChange(() => this.updateDisplay());
554 const bgColorCtrl = dispFolder.addColor(this.state, 'bgColor');
555 bgColorCtrl.onChange(() => this.updateBackground());
556
557 // Lighting controls.
558 const lightFolder = gui.addFolder('Lighting');
559 const envMapCtrl = lightFolder.add(
560 this.state,
561 'environment',
562 environments.map((env) => env.name),
563 );
564 envMapCtrl.onChange(() => this.updateEnvironment());
565 [
566 lightFolder.add(this.state, 'toneMapping', {
567 Linear: LinearToneMapping,
568 'ACES Filmic': ACESFilmicToneMapping,
569 }),
570 lightFolder.add(this.state, 'exposure', -10, 10, 0.01),
571 lightFolder.add(this.state, 'punctualLights').listen(),
572 lightFolder.add(this.state, 'ambientIntensity', 0, 2),
573 lightFolder.addColor(this.state, 'ambientColor'),
574 lightFolder.add(this.state, 'directIntensity', 0, 4), // TODO(#116)
575 lightFolder.addColor(this.state, 'directColor'),
576 ].forEach((ctrl) => ctrl.onChange(() => this.updateLights()));
577
578 // Animation controls.
579 this.animFolder = gui.addFolder('Animation');
580 this.animFolder.domElement.style.display = 'none';
581 const playbackSpeedCtrl = this.animFolder.add(this.state, 'playbackSpeed', 0, 1);
582 playbackSpeedCtrl.onChange((speed) => {
583 if (this.mixer) this.mixer.timeScale = speed;
584 });
585 this.animFolder.add({ playAll: () => this.playAllClips() }, 'playAll');
586
587 const animationToggleCtrl = this.animFolder.add(this.state, 'animationPaused');
588 animationToggleCtrl.onChange((paused) => this.toggleAnimation(paused));
589 animationToggleCtrl.name('animationOff');
590
591 // Morph target controls.
592 this.morphFolder = gui.addFolder('Morph Targets');
593 this.morphFolder.domElement.style.display = 'none';
594
595 // Camera controls.
596 this.cameraFolder = gui.addFolder('Cameras');
597 this.cameraFolder.domElement.style.display = 'none';
598
599 // Stats.
600 const perfFolder = gui.addFolder('Performance');
601 const perfLi = document.createElement('li');
602 this.stats.dom.style.position = 'static';
603 perfLi.appendChild(this.stats.dom);
604 perfLi.classList.add('gui-stats');
605 perfFolder.__ul.appendChild(perfLi);
606
607 const guiWrap = document.createElement('div');
608 this.el.appendChild(guiWrap);
609 guiWrap.classList.add('gui-wrap');
610 guiWrap.appendChild(gui.domElement);
611 gui.open();
612 }
613
614 updateGUI() {
615 this.cameraFolder.domElement.style.display = 'none';
616
617 this.morphCtrls.forEach((ctrl) => ctrl.remove());
618 this.morphCtrls.length = 0;
619 this.morphFolder.domElement.style.display = 'none';
620
621 this.animCtrls.forEach((ctrl) => ctrl.remove());
622 this.animCtrls.length = 0;
623 this.animFolder.domElement.style.display = 'none';
624
625 const cameraNames = [];
626 const morphMeshes = [];
627 this.content.traverse((node) => {
628 if (node.geometry && node.morphTargetInfluences) {
629 morphMeshes.push(node);
630 }
631 if (node.isCamera) {
632 node.name = node.name || `VIEWER__camera_${cameraNames.length + 1}`;
633 cameraNames.push(node.name);
634 }
635 });
636
637 if (cameraNames.length) {
638 this.cameraFolder.domElement.style.display = '';
639 if (this.cameraCtrl) this.cameraCtrl.remove();
640 const cameraOptions = [DEFAULT_CAMERA].concat(cameraNames);
641 this.cameraCtrl = this.cameraFolder.add(this.state, 'camera', cameraOptions);
642 this.cameraCtrl.onChange((name) => this.setCamera(name));
643 }
644
645 if (morphMeshes.length) {
646 this.morphFolder.domElement.style.display = '';
647 morphMeshes.forEach((mesh) => {
648 if (mesh.morphTargetInfluences.length) {
649 const nameCtrl = this.morphFolder.add(
650 { name: mesh.name || 'Untitled' },
651 'name',
652 );
653 this.morphCtrls.push(nameCtrl);
654 }
655 for (let i = 0; i < mesh.morphTargetInfluences.length; i++) {
656 const ctrl = this.morphFolder
657 .add(mesh.morphTargetInfluences, i, 0, 1, 0.01)
658 .listen();
659 Object.keys(mesh.morphTargetDictionary).forEach((key) => {
660 if (key && mesh.morphTargetDictionary[key] === i) ctrl.name(key);
661 });
662 this.morphCtrls.push(ctrl);
663 }
664 });
665 }
666
667 if (this.clips.length) {
668 this.animFolder.domElement.style.display = '';
669 const actionStates = (this.state.actionStates = {});
670 this.clips.forEach((clip, clipIndex) => {
671 clip.name = `${clipIndex + 1}. ${clip.name}`;
672
673 // Autoplay the first clip.
674 let action;
675 if (clipIndex === 0) {
676 actionStates[clip.name] = true;
677 action = this.mixer.clipAction(clip);
678 action.play();
679 } else {
680 actionStates[clip.name] = false;
681 }
682
683 // Play other clips when enabled.
684 const ctrl = this.animFolder.add(actionStates, clip.name).listen();
685 ctrl.onChange((playAnimation) => {
686 action = action || this.mixer.clipAction(clip);
687 action.setEffectiveTimeScale(1);
688 playAnimation ? action.play() : action.stop();
689 });
690 this.animCtrls.push(ctrl);
691 });
692 }
693 }
694
695 clear() {
696 if (!this.content) return;
697
698 this.scene.remove(this.content);
699
700 // dispose geometry
701 this.content.traverse((node) => {
702 if (!node.geometry) return;
703
704 node.geometry.dispose();
705 });
706
707 // dispose textures
708 traverseMaterials(this.content, (material) => {
709 for (const key in material) {
710 if (key !== 'envMap' && material[key] && material[key].isTexture) {
711 material[key].dispose();
712 }
713 }
714 });
715 }
716}
717
718function traverseMaterials(object, callback) {
719 object.traverse((node) => {
720 if (!node.geometry) return;
721 const materials = Array.isArray(node.material) ? node.material : [node.material];
722 materials.forEach(callback);
723 });
724}
725
726// https://stackoverflow.com/a/9039885/1314762
727function isIOS() {
728 return (
729 ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'].includes(
730 navigator.platform,
731 ) ||
732 // iPad on iOS 13 detection
733 (navigator.userAgent.includes('Mac') && 'ontouchend' in document)
734 );
735}