├── LICENSE ├── README.md ├── assets ├── environment │ ├── Bridge2 │ │ ├── README.md │ │ ├── negx.jpg │ │ ├── negy.jpg │ │ ├── negz.jpg │ │ ├── posx.jpg │ │ ├── posy.jpg │ │ └── posz.jpg │ ├── FootprintCourt │ │ ├── negx.hdr │ │ ├── negy.hdr │ │ ├── negz.hdr │ │ ├── posx.hdr │ │ ├── posy.hdr │ │ └── posz.hdr │ ├── Park2 │ │ ├── README.md │ │ ├── negx.jpg │ │ ├── negy.jpg │ │ ├── negz.jpg │ │ ├── posx.jpg │ │ ├── posy.jpg │ │ └── posz.jpg │ ├── Park3Med │ │ ├── README.md │ │ ├── negx.jpg │ │ ├── negy.jpg │ │ ├── negz.jpg │ │ ├── posx.jpg │ │ ├── posy.jpg │ │ └── posz.jpg │ ├── SwedishRoyalCastle │ │ ├── README.md │ │ ├── negx.jpg │ │ ├── negy.jpg │ │ ├── negz.jpg │ │ ├── posx.jpg │ │ ├── posy.jpg │ │ └── posz.jpg │ ├── index.js │ └── skybox │ │ ├── negx.jpg │ │ ├── negy.jpg │ │ ├── negz.jpg │ │ ├── posx.jpg │ │ ├── posy.jpg │ │ └── posz.jpg ├── favicon.ico ├── github@1X.png ├── github@2X.png └── icons │ ├── fbx-Viewer.icns │ ├── fbx-Viewer.ico │ └── fbx-Viewer.png ├── electron └── main.js ├── index.html ├── lib ├── WebGL.js ├── inflate.min.js └── stats.min.js ├── package.json ├── scripts └── gen_test.js ├── src ├── app.js └── viewer.js └── style.css /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Pavel Kuznetsov 4 | Copyright (c) 2017 Don McCurdy 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # three-fbx-viewer 2 | Drag-and-drop preview for FBX models in WebGL using three.js. 3 | 4 | Demo https://limitless.pro/three-fbx-viewer 5 | 6 | ![screenshot](https://limitless.pro/storage/7/responsive-images/three-fbx-viewer___medialibrary_original_1365_529.jpg) 7 | ## Quickstart 8 | 9 | ### Web 10 | 11 | ``` 12 | npm install 13 | npm run dev 14 | ``` 15 | 16 | ### Desktop (Electron) 17 | 18 | To build the desktop application, run: 19 | 20 | ```shell 21 | # development build 22 | npm run dev:electron 23 | 24 | # package for release 25 | npm run package 26 | ``` 27 | -------------------------------------------------------------------------------- /assets/environment/Bridge2/README.md: -------------------------------------------------------------------------------- 1 | Author 2 | ====== 3 | 4 | This is the work of Emil Persson, aka Humus. 5 | http://www.humus.name 6 | humus@comhem.se 7 | 8 | 9 | 10 | Legal stuff 11 | =========== 12 | 13 | This work is free and may be used by anyone for any purpose 14 | and may be distributed freely to anyone using any distribution 15 | media or distribution method as long as this file is included. 16 | Distribution without this file is allowed if it's distributed 17 | with free non-commercial software; however, fair credit of the 18 | original author is expected. 19 | Any commercial distribution of this software requires the written 20 | approval of Emil Persson. 21 | -------------------------------------------------------------------------------- /assets/environment/Bridge2/negx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Bridge2/negx.jpg -------------------------------------------------------------------------------- /assets/environment/Bridge2/negy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Bridge2/negy.jpg -------------------------------------------------------------------------------- /assets/environment/Bridge2/negz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Bridge2/negz.jpg -------------------------------------------------------------------------------- /assets/environment/Bridge2/posx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Bridge2/posx.jpg -------------------------------------------------------------------------------- /assets/environment/Bridge2/posy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Bridge2/posy.jpg -------------------------------------------------------------------------------- /assets/environment/Bridge2/posz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Bridge2/posz.jpg -------------------------------------------------------------------------------- /assets/environment/FootprintCourt/negx.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/FootprintCourt/negx.hdr -------------------------------------------------------------------------------- /assets/environment/FootprintCourt/negy.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/FootprintCourt/negy.hdr -------------------------------------------------------------------------------- /assets/environment/FootprintCourt/negz.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/FootprintCourt/negz.hdr -------------------------------------------------------------------------------- /assets/environment/FootprintCourt/posx.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/FootprintCourt/posx.hdr -------------------------------------------------------------------------------- /assets/environment/FootprintCourt/posy.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/FootprintCourt/posy.hdr -------------------------------------------------------------------------------- /assets/environment/FootprintCourt/posz.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/FootprintCourt/posz.hdr -------------------------------------------------------------------------------- /assets/environment/Park2/README.md: -------------------------------------------------------------------------------- 1 | Author 2 | ====== 3 | 4 | This is the work of Emil Persson, aka Humus. 5 | http://www.humus.name 6 | humus@comhem.se 7 | 8 | 9 | 10 | Legal stuff 11 | =========== 12 | 13 | This work is free and may be used by anyone for any purpose 14 | and may be distributed freely to anyone using any distribution 15 | media or distribution method as long as this file is included. 16 | Distribution without this file is allowed if it's distributed 17 | with free non-commercial software; however, fair credit of the 18 | original author is expected. 19 | Any commercial distribution of this software requires the written 20 | approval of Emil Persson. 21 | -------------------------------------------------------------------------------- /assets/environment/Park2/negx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park2/negx.jpg -------------------------------------------------------------------------------- /assets/environment/Park2/negy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park2/negy.jpg -------------------------------------------------------------------------------- /assets/environment/Park2/negz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park2/negz.jpg -------------------------------------------------------------------------------- /assets/environment/Park2/posx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park2/posx.jpg -------------------------------------------------------------------------------- /assets/environment/Park2/posy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park2/posy.jpg -------------------------------------------------------------------------------- /assets/environment/Park2/posz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park2/posz.jpg -------------------------------------------------------------------------------- /assets/environment/Park3Med/README.md: -------------------------------------------------------------------------------- 1 | Author 2 | ====== 3 | 4 | This is the work of Emil Persson, aka Humus. 5 | http://www.humus.name 6 | humus@comhem.se 7 | 8 | 9 | 10 | Legal stuff 11 | =========== 12 | 13 | This work is free and may be used by anyone for any purpose 14 | and may be distributed freely to anyone using any distribution 15 | media or distribution method as long as this file is included. 16 | Distribution without this file is allowed if it's distributed 17 | with free non-commercial software; however, fair credit of the 18 | original author is expected. 19 | Any commercial distribution of this software requires the written 20 | approval of Emil Persson. 21 | -------------------------------------------------------------------------------- /assets/environment/Park3Med/negx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park3Med/negx.jpg -------------------------------------------------------------------------------- /assets/environment/Park3Med/negy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park3Med/negy.jpg -------------------------------------------------------------------------------- /assets/environment/Park3Med/negz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park3Med/negz.jpg -------------------------------------------------------------------------------- /assets/environment/Park3Med/posx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park3Med/posx.jpg -------------------------------------------------------------------------------- /assets/environment/Park3Med/posy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park3Med/posy.jpg -------------------------------------------------------------------------------- /assets/environment/Park3Med/posz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/Park3Med/posz.jpg -------------------------------------------------------------------------------- /assets/environment/SwedishRoyalCastle/README.md: -------------------------------------------------------------------------------- 1 | Author 2 | ====== 3 | 4 | This is the work of Emil Persson, aka Humus. 5 | http://www.humus.name 6 | humus@comhem.se 7 | 8 | 9 | 10 | Legal stuff 11 | =========== 12 | 13 | This work is free and may be used by anyone for any purpose 14 | and may be distributed freely to anyone using any distribution 15 | media or distribution method as long as this file is included. 16 | Distribution without this file is allowed if it's distributed 17 | with free non-commercial software; however, fair credit of the 18 | original author is expected. 19 | Any commercial distribution of this software requires the written 20 | approval of Emil Persson. 21 | -------------------------------------------------------------------------------- /assets/environment/SwedishRoyalCastle/negx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/SwedishRoyalCastle/negx.jpg -------------------------------------------------------------------------------- /assets/environment/SwedishRoyalCastle/negy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/SwedishRoyalCastle/negy.jpg -------------------------------------------------------------------------------- /assets/environment/SwedishRoyalCastle/negz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/SwedishRoyalCastle/negz.jpg -------------------------------------------------------------------------------- /assets/environment/SwedishRoyalCastle/posx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/SwedishRoyalCastle/posx.jpg -------------------------------------------------------------------------------- /assets/environment/SwedishRoyalCastle/posy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/SwedishRoyalCastle/posy.jpg -------------------------------------------------------------------------------- /assets/environment/SwedishRoyalCastle/posz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/SwedishRoyalCastle/posz.jpg -------------------------------------------------------------------------------- /assets/environment/index.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | name: 'None', 4 | path: null, 5 | format: '.jpg' 6 | }, 7 | { 8 | name: 'Park (Day)', 9 | path: 'assets/environment/Park2/', 10 | format: '.jpg' 11 | }, 12 | { 13 | name: 'Park (Night)', 14 | path: 'assets/environment/Park3Med/', 15 | format: '.jpg' 16 | }, 17 | { 18 | name: 'Bridge', 19 | path: 'assets/environment/Bridge2/', 20 | format: '.jpg' 21 | }, 22 | { 23 | name: 'Sky', 24 | path: 'assets/environment/skybox/', 25 | format: '.jpg' 26 | }, 27 | { 28 | name: 'Castle', 29 | path: 'assets/environment/SwedishRoyalCastle/', 30 | format: '.jpg' 31 | }, 32 | { 33 | name: 'Footprint Court (HDR)', 34 | path: 'assets/environment/FootprintCourt/', 35 | format: '.hdr' 36 | } 37 | ]; 38 | -------------------------------------------------------------------------------- /assets/environment/skybox/negx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/skybox/negx.jpg -------------------------------------------------------------------------------- /assets/environment/skybox/negy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/skybox/negy.jpg -------------------------------------------------------------------------------- /assets/environment/skybox/negz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/skybox/negz.jpg -------------------------------------------------------------------------------- /assets/environment/skybox/posx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/skybox/posx.jpg -------------------------------------------------------------------------------- /assets/environment/skybox/posy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/skybox/posy.jpg -------------------------------------------------------------------------------- /assets/environment/skybox/posz.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/environment/skybox/posz.jpg -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/favicon.ico -------------------------------------------------------------------------------- /assets/github@1X.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/github@1X.png -------------------------------------------------------------------------------- /assets/github@2X.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/github@2X.png -------------------------------------------------------------------------------- /assets/icons/fbx-Viewer.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/icons/fbx-Viewer.icns -------------------------------------------------------------------------------- /assets/icons/fbx-Viewer.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/icons/fbx-Viewer.ico -------------------------------------------------------------------------------- /assets/icons/fbx-Viewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/limitlesspro/three-fbx-viewer/299a8d6ca79cb358f83f828d384771bac315029c/assets/icons/fbx-Viewer.png -------------------------------------------------------------------------------- /electron/main.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow } = require('electron'); 2 | const url = require('url'); 3 | const path = require('path'); 4 | const open = require('open'); 5 | 6 | // Keep a global reference of the window object, if you don't, the window will 7 | // be closed automatically when the JavaScript object is garbage collected. 8 | let win; 9 | 10 | function createWindow () { 11 | // Create the browser window. 12 | win = new BrowserWindow({width: 800, height: 600}); 13 | 14 | // and load the index.html of the app. 15 | win.loadURL(url.format({ 16 | pathname: path.join(__dirname, '../index.html'), 17 | protocol: 'file:', 18 | slashes: true 19 | })); 20 | 21 | // Open the DevTools. 22 | // win.webContents.openDevTools(); 23 | 24 | win.webContents.on('new-window', function(event, url){ 25 | event.preventDefault(); 26 | open(url); 27 | }); 28 | 29 | // Emitted when the window is closed. 30 | win.on('closed', () => { 31 | // Dereference the window object, usually you would store windows 32 | // in an array if your app supports multi windows, this is the time 33 | // when you should delete the corresponding element. 34 | win = null; 35 | }); 36 | } 37 | 38 | // This method will be called when Electron has finished 39 | // initialization and is ready to create browser windows. 40 | // Some APIs can only be used after this event occurs. 41 | app.on('ready', createWindow); 42 | 43 | // Quit when all windows are closed. 44 | app.on('window-all-closed', () => { 45 | // On macOS it is common for applications and their menu bar 46 | // to stay active until the user quits explicitly with Cmd + Q 47 | if (process.platform !== 'darwin') { 48 | app.quit(); 49 | } 50 | }); 51 | 52 | app.on('activate', () => { 53 | // On macOS it's common to re-create a window in the app when the 54 | // dock icon is clicked and there are no other windows open. 55 | if (win === null) { 56 | createWindow(); 57 | } 58 | }); 59 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FBX Viewer 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

FBX Viewer

18 | | 19 | 20 | three.js r110 21 | 22 | | 23 | 24 | THREE.FBXLoader@r110 25 | 26 | | 27 | 28 | GitHub 29 | 30 |
31 |
32 |
33 |
34 |

Drag FBX file or folder here

35 |
36 |
37 | 38 | 42 |
43 |
44 |
45 |
46 | 47 | -------------------------------------------------------------------------------- /lib/WebGL.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author alteredq / http://alteredqualia.com/ 3 | * @author mr.doob / http://mrdoob.com/ 4 | */ 5 | 6 | module.exports = { 7 | 8 | isWebGLAvailable: function () { 9 | 10 | try { 11 | 12 | var canvas = document.createElement( 'canvas' ); 13 | return !! ( window.WebGLRenderingContext && ( canvas.getContext( 'webgl' ) || canvas.getContext( 'experimental-webgl' ) ) ); 14 | 15 | } catch ( e ) { 16 | 17 | return false; 18 | 19 | } 20 | 21 | }, 22 | 23 | isWebGL2Available: function () { 24 | 25 | try { 26 | 27 | var canvas = document.createElement( 'canvas' ); 28 | return !! ( window.WebGL2RenderingContext && canvas.getContext( 'webgl2' ) ); 29 | 30 | } catch ( e ) { 31 | 32 | return false; 33 | 34 | } 35 | 36 | }, 37 | 38 | getWebGLErrorMessage: function () { 39 | 40 | return this.getErrorMessage( 1 ); 41 | 42 | }, 43 | 44 | getWebGL2ErrorMessage: function () { 45 | 46 | return this.getErrorMessage( 2 ); 47 | 48 | }, 49 | 50 | getErrorMessage: function ( version ) { 51 | 52 | var names = { 53 | 1: 'WebGL', 54 | 2: 'WebGL 2' 55 | }; 56 | 57 | var contexts = { 58 | 1: window.WebGLRenderingContext, 59 | 2: window.WebGL2RenderingContext 60 | }; 61 | 62 | var message = 'Your $0 does not seem to support $1'; 63 | 64 | var element = document.createElement( 'div' ); 65 | element.id = 'webglmessage'; 66 | element.style.fontFamily = 'monospace'; 67 | element.style.fontSize = '13px'; 68 | element.style.fontWeight = 'normal'; 69 | element.style.textAlign = 'center'; 70 | element.style.background = '#fff'; 71 | element.style.color = '#000'; 72 | element.style.padding = '1.5em'; 73 | element.style.width = '400px'; 74 | element.style.margin = '5em auto 0'; 75 | 76 | if ( contexts[ version ] ) { 77 | 78 | message = message.replace( '$0', 'graphics card' ); 79 | 80 | } else { 81 | 82 | message = message.replace( '$0', 'browser' ); 83 | 84 | } 85 | 86 | message = message.replace( '$1', names[ version ] ); 87 | 88 | element.innerHTML = message; 89 | 90 | return element; 91 | 92 | } 93 | 94 | }; 95 | -------------------------------------------------------------------------------- /lib/inflate.min.js: -------------------------------------------------------------------------------- 1 | /** @license zlib.js 2012 - imaya [ https://github.com/imaya/zlib.js ] The MIT License */(function() {'use strict';var l=void 0,aa=this;function r(c,d){var a=c.split("."),b=aa;!(a[0]in b)&&b.execScript&&b.execScript("var "+a[0]);for(var e;a.length&&(e=a.shift());)!a.length&&d!==l?b[e]=d:b=b[e]?b[e]:b[e]={}};var t="undefined"!==typeof Uint8Array&&"undefined"!==typeof Uint16Array&&"undefined"!==typeof Uint32Array&&"undefined"!==typeof DataView;function v(c){var d=c.length,a=0,b=Number.POSITIVE_INFINITY,e,f,g,h,k,m,n,p,s,x;for(p=0;pa&&(a=c[p]),c[p]>=1;x=g<<16|p;for(s=m;s>>=1;switch(c){case 0:var d=this.input,a=this.a,b=this.c,e=this.b,f=d.length,g=l,h=l,k=b.length,m=l;this.d=this.f=0;if(a+1>=f)throw Error("invalid uncompressed block header: LEN");g=d[a++]|d[a++]<<8;if(a+1>=f)throw Error("invalid uncompressed block header: NLEN");h=d[a++]|d[a++]<<8;if(g===~h)throw Error("invalid uncompressed block header: length verify");if(a+g>d.length)throw Error("input buffer is broken");switch(this.i){case A:for(;e+ 4 | g>b.length;){m=k-e;g-=m;if(t)b.set(d.subarray(a,a+m),e),e+=m,a+=m;else for(;m--;)b[e++]=d[a++];this.b=e;b=this.e();e=this.b}break;case y:for(;e+g>b.length;)b=this.e({p:2});break;default:throw Error("invalid inflate mode");}if(t)b.set(d.subarray(a,a+g),e),e+=g,a+=g;else for(;g--;)b[e++]=d[a++];this.a=a;this.b=e;this.c=b;break;case 1:this.j(ba,ca);break;case 2:for(var n=C(this,5)+257,p=C(this,5)+1,s=C(this,4)+4,x=new (t?Uint8Array:Array)(D.length),S=l,T=l,U=l,u=l,M=l,F=l,z=l,q=l,V=l,q=0;q=P?8:255>=P?9:279>=P?7:8;var ba=v(O),Q=new (t?Uint8Array:Array)(30),R,ga;R=0;for(ga=Q.length;R=g)throw Error("input buffer is broken");a|=e[f++]<>>d;c.d=b-d;c.a=f;return h} 8 | function E(c,d){for(var a=c.f,b=c.d,e=c.input,f=c.a,g=e.length,h=d[0],k=d[1],m,n;b=g);)a|=e[f++]<>>16;if(n>b)throw Error("invalid code length: "+n);c.f=a>>n;c.d=b-n;c.a=f;return m&65535} 9 | w.prototype.j=function(c,d){var a=this.c,b=this.b;this.o=c;for(var e=a.length-258,f,g,h,k;256!==(f=E(this,c));)if(256>f)b>=e&&(this.b=b,a=this.e(),b=this.b),a[b++]=f;else{g=f-257;k=I[g];0=e&&(this.b=b,a=this.e(),b=this.b);for(;k--;)a[b]=a[b++-h]}for(;8<=this.d;)this.d-=8,this.a--;this.b=b}; 10 | w.prototype.w=function(c,d){var a=this.c,b=this.b;this.o=c;for(var e=a.length,f,g,h,k;256!==(f=E(this,c));)if(256>f)b>=e&&(a=this.e(),e=a.length),a[b++]=f;else{g=f-257;k=I[g];0e&&(a=this.e(),e=a.length);for(;k--;)a[b]=a[b++-h]}for(;8<=this.d;)this.d-=8,this.a--;this.b=b}; 11 | w.prototype.e=function(){var c=new (t?Uint8Array:Array)(this.b-32768),d=this.b-32768,a,b,e=this.c;if(t)c.set(e.subarray(32768,c.length));else{a=0;for(b=c.length;aa;++a)e[a]=e[d+a];this.b=32768;return e}; 12 | w.prototype.z=function(c){var d,a=this.input.length/this.a+1|0,b,e,f,g=this.input,h=this.c;c&&("number"===typeof c.p&&(a=c.p),"number"===typeof c.u&&(a+=c.u));2>a?(b=(g.length-this.a)/this.o[2],f=258*(b/2)|0,e=fd&&(this.c.length=d),c=this.c);return this.buffer=c};function W(c,d){var a,b;this.input=c;this.a=0;if(d||!(d={}))d.index&&(this.a=d.index),d.verify&&(this.A=d.verify);a=c[this.a++];b=c[this.a++];switch(a&15){case ha:this.method=ha;break;default:throw Error("unsupported compression method");}if(0!==((a<<8)+b)%31)throw Error("invalid fcheck flag:"+((a<<8)+b)%31);if(b&32)throw Error("fdict flag is not supported");this.q=new w(c,{index:this.a,bufferSize:d.bufferSize,bufferType:d.bufferType,resize:d.resize})} 15 | W.prototype.k=function(){var c=this.input,d,a;d=this.q.k();this.a=this.q.a;if(this.A){a=(c[this.a++]<<24|c[this.a++]<<16|c[this.a++]<<8|c[this.a++])>>>0;var b=d;if("string"===typeof b){var e=b.split(""),f,g;f=0;for(g=e.length;f>>0;b=e}for(var h=1,k=0,m=b.length,n,p=0;0>>0)throw Error("invalid adler-32 checksum");}return d};var ha=8;r("Zlib.Inflate",W);r("Zlib.Inflate.prototype.decompress",W.prototype.k);var X={ADAPTIVE:B.s,BLOCK:B.t},Y,Z,$,ia;if(Object.keys)Y=Object.keys(X);else for(Z in Y=[],$=0,X)Y[$++]=Z;$=0;for(ia=Y.length;$e+1E3&&(r.update(1E3*a/(c-e),100),e=c,a=0,t)){var d=performance.memory;t.update(d.usedJSHeapSize/1048576,d.jsHeapSizeLimit/1048576)}return c},update:function(){g=this.end()},domElement:c,setMode:k}}; 4 | Stats.Panel=function(h,k,l){var c=Infinity,g=0,e=Math.round,a=e(window.devicePixelRatio||1),r=80*a,f=48*a,t=3*a,u=2*a,d=3*a,m=15*a,n=74*a,p=30*a,q=document.createElement("canvas");q.width=r;q.height=f;q.style.cssText="width:80px;height:48px";var b=q.getContext("2d");b.font="bold "+9*a+"px Helvetica,Arial,sans-serif";b.textBaseline="top";b.fillStyle=l;b.fillRect(0,0,r,f);b.fillStyle=k;b.fillText(h,t,u);b.fillRect(d,m,n,p);b.fillStyle=l;b.globalAlpha=.9;b.fillRect(d,m,n,p);return{dom:q,update:function(f, 5 | v){c=Math.min(c,f);g=Math.max(g,f);b.fillStyle=l;b.globalAlpha=1;b.fillRect(0,0,r,m);b.fillStyle=k;b.fillText(e(f)+" "+h+" ("+e(c)+"-"+e(g)+")",t,u);b.drawImage(q,d+a,m,n-a,p,d,m,n-a,p);b.fillRect(d+n-a,m,a,p);b.fillStyle=l;b.globalAlpha=.9;b.fillRect(d+n-a,m,a,e((1-f/v)*p))}}};"object"===typeof module&&(module.exports=Stats); 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "three-fbx-viewer", 3 | "productName": "FBX Viewer", 4 | "version": "1.0.0", 5 | "description": "Preview FBX models using three.js and a drag-and-drop interface.", 6 | "main": "electron/main.js", 7 | "browser": "src/app.js", 8 | "scripts": { 9 | "start": "electron .", 10 | "build": "browserify src/app.js -o bundle.js", 11 | "package": "npm run package:windows && npm run package:mac && npm run package:linux", 12 | "package:windows": "electron-packager . fbx-viewer --overwrite --asar --platform=win32 --arch=ia32 --icon=assets/icons/fbx-Viewer.ico --out=dist --version-string.CompanyName=CE --version-string.FileDescription=CE --version-string.ProductName=\"fbx Viewer\"", 13 | "package:mac": "electron-packager . --overwrite --platform=darwin --arch=x64 --icon=assets/icons/fbx-Viewer.icns --out=dist", 14 | "package:linux": "electron-packager . fbx-viewer --overwrite --asar --platform=linux --arch=x64 --icon=assets/icons/fbx-Viewer.png --out=dist", 15 | "dev": "budo src/app.js:bundle.js --port 3000", 16 | "dev:electron": "concurrently \"watchify src/app.js -o bundle.js\" \"npm start\"", 17 | "test": "node scripts/gen_test.js", 18 | "deploy": "npm run build && now --prod && npm run clean", 19 | "clean": "rm bundle.js", 20 | "postversion": "git push && git push --tags" 21 | }, 22 | "keywords": [ 23 | "fbx", 24 | "three.js", 25 | "three", 26 | "3d", 27 | "model", 28 | "modeling", 29 | "webgl" 30 | ], 31 | "author": "Don McCurdy (https://www.donmccurdy.com)", 32 | "contributors": [ 33 | "Pavel Kuznetsov (https://limitless.pro)" 34 | ], 35 | "license": "MIT", 36 | "dependencies": { 37 | "dat.gui": "^0.7.6", 38 | "glob-to-regexp": "^0.4.1", 39 | "open": "^7.0.0", 40 | "query-string": "^4.3.4", 41 | "serve": "^10.1.2", 42 | "simple-dropzone": "^0.5.3", 43 | "three": "^0.110.0", 44 | "three-vignette-background": "^1.0.3", 45 | "zipjs-browserify": "^1.0.1" 46 | }, 47 | "devDependencies": { 48 | "browserify": "^16.5.0", 49 | "budo": "^11.6.3", 50 | "chalk": "^2.4.2", 51 | "concurrently": "^3.6.1", 52 | "electron": "^3.1.13", 53 | "electron-packager": "^12.2.0", 54 | "glslify": "^6.4.1", 55 | "node-fetch": "^1.7.3", 56 | "watchify": "^3.11.1" 57 | }, 58 | "browserify": { 59 | "transform": [ 60 | "glslify" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /scripts/gen_test.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | const chalk = require('chalk'); 3 | 4 | const VERSION = '2.0'; 5 | const CONTENT_URL = `https://api.github.com/repos/KhronosGroup/glTF-Sample-Models/contents/${VERSION}/`; 6 | const RAW_BASE_URL = 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/master/'; 7 | const VIEWER_BASE_URL = `http://localhost:3000/#model=${RAW_BASE_URL}${VERSION}/`; 8 | 9 | fetch(CONTENT_URL) 10 | .then((response) => response.json()) 11 | .then((directories) => { 12 | console.log(chalk.green('Samples:')); 13 | directories.forEach((entry) => { 14 | const basename = entry.path.split('/').pop(); 15 | const prettyBasename = chalk.yellow(`${basename}.gltf:`); 16 | console.log(` - ${prettyBasename} ${VIEWER_BASE_URL}${basename}/glTF/${basename}.gltf`); 17 | }); 18 | console.log(chalk.black.bgGreen(`\n 🍺 Found ${directories.length} sample models. \n\n`)); 19 | }); 20 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const WEBGL = require('../lib/WebGL'); 2 | const Viewer = require('./viewer'); 3 | const SimpleDropzone = require('simple-dropzone'); 4 | const queryString = require('query-string'); 5 | 6 | if (!(window.File && window.FileReader && window.FileList && window.Blob)) { 7 | console.error('The File APIs are not fully supported in this browser.'); 8 | } else if (!WEBGL.isWebGLAvailable()) { 9 | console.error('WebGL is not supported in this browser.'); 10 | } 11 | 12 | class App { 13 | 14 | /** 15 | * @param {Element} el 16 | * @param {Location} location 17 | */ 18 | constructor(el, location) { 19 | 20 | const hash = location.hash ? queryString.parse(location.hash) : {}; 21 | this.el = el; 22 | this.viewer = null; 23 | this.viewerEl = null; 24 | this.spinnerEl = el.querySelector('.spinner'); 25 | this.dropEl = el.querySelector('.dropzone'); 26 | this.inputEl = el.querySelector('#file-input'); 27 | 28 | this.options = { 29 | model: hash.model || '', 30 | preset: hash.preset || '', 31 | cameraPosition: hash.cameraPosition 32 | ? hash.cameraPosition.split(',').map(Number) 33 | : null, 34 | spinner: this.spinnerEl 35 | }; 36 | 37 | this.createDropzone(); 38 | this.hideSpinner(); 39 | 40 | const options = this.options; 41 | 42 | if (options.model) { 43 | this.view(options.model, '', new Map()); 44 | } 45 | } 46 | 47 | /** 48 | * Sets up the drag-and-drop controller. 49 | */ 50 | createDropzone() { 51 | const dropCtrl = new SimpleDropzone(this.dropEl, this.inputEl); 52 | dropCtrl.on('drop', ({files}) => this.load(files)); 53 | dropCtrl.on('dropstart'); 54 | dropCtrl.on('droperror', () => this.hideSpinner()); 55 | } 56 | 57 | /** 58 | * Sets up the view manager. 59 | * @return {Viewer} 60 | */ 61 | createViewer() { 62 | this.viewerEl = document.createElement('div'); 63 | this.viewerEl.classList.add('viewer'); 64 | this.dropEl.innerHTML = ''; 65 | this.dropEl.appendChild(this.viewerEl); 66 | this.viewer = new Viewer(this.viewerEl, this.options); 67 | return this.viewer; 68 | } 69 | 70 | /** 71 | * Loads a fileset provided by user action. 72 | * @param {Map} fileMap 73 | */ 74 | load(fileMap) { 75 | let rootFile; 76 | let rootPath; 77 | Array.from(fileMap).forEach(([path, file]) => { 78 | if (file.name.match(/\.(fbx)$/)) { 79 | rootFile = file; 80 | rootPath = path.replace(file.name, ''); 81 | } 82 | }); 83 | 84 | if (!rootFile) { 85 | this.onError('No .fbx asset found.'); 86 | } 87 | 88 | this.view(rootFile, rootPath, fileMap); 89 | } 90 | 91 | /** 92 | * Passes a model to the viewer, given file and resources. 93 | * @param {File|string} rootFile 94 | * @param {string} rootPath 95 | * @param {Map} fileMap 96 | */ 97 | view(rootFile, rootPath, fileMap) { 98 | 99 | if (this.viewer) this.viewer.clear(); 100 | 101 | const viewer = this.viewer || this.createViewer(); 102 | 103 | const fileURL = typeof rootFile === 'string' 104 | ? rootFile 105 | : URL.createObjectURL(rootFile); 106 | this.showSpinner(); 107 | viewer 108 | .load(fileURL, rootPath, fileMap) 109 | .catch((e) => this.onError(e)) 110 | .then(() => { 111 | if (typeof rootFile === 'object') URL.revokeObjectURL(fileURL); 112 | }); 113 | } 114 | 115 | /** 116 | * @param {Error} error 117 | */ 118 | onError(error) { 119 | let message = (error || {}).message || error.toString(); 120 | if (message.match(/ProgressEvent/)) { 121 | message = 'Unable to retrieve this file. Check JS console and browser network tab.'; 122 | } else if (message.match(/Unexpected token/)) { 123 | message = `Unable to parse file content. Verify that this file is valid. Error: "${message}"`; 124 | } else if (error && error.target && error.target instanceof Image) { 125 | message = 'Missing texture: ' + error.target.src.split('/').pop(); 126 | } 127 | window.alert(message); 128 | console.error(error); 129 | } 130 | 131 | showSpinner() { 132 | this.spinnerEl.style.display = ''; 133 | } 134 | 135 | hideSpinner() { 136 | this.spinnerEl.style.display = 'none'; 137 | } 138 | } 139 | 140 | document.addEventListener('DOMContentLoaded', () => { 141 | 142 | const app = new App(document.body, location); 143 | 144 | }); 145 | -------------------------------------------------------------------------------- /src/viewer.js: -------------------------------------------------------------------------------- 1 | /*global 2 | URL,navigator,THREE,Stats,dat,environments,createVignetteBackground,DEFAULT_CAMERA,IS_IOS,MAP_NAMES,Preset 3 | */ 4 | const THREE = window.THREE = require('three'); 5 | const Stats = require('../lib/stats.min'); 6 | const dat = require('dat.gui'); 7 | const environments = require('../assets/environment/index'); 8 | const createVignetteBackground = require('three-vignette-background'); 9 | 10 | require('three/examples/js/loaders/FBXLoader'); 11 | require('three/examples/js/loaders/DDSLoader'); 12 | require('three/examples/js/controls/OrbitControls'); 13 | require('three/examples/js/loaders/RGBELoader'); 14 | require('three/examples/js/loaders/HDRCubeTextureLoader'); 15 | require('three/examples/js/pmrem/PMREMGenerator'); 16 | require('three/examples/js/pmrem/PMREMCubeUVPacker'); 17 | 18 | const DEFAULT_CAMERA = '[default]'; 19 | 20 | const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; 21 | 22 | // glTF texture types. `envMap` is deliberately omitted, as it's used internally 23 | // by the loader but not part of the glTF format. 24 | const MAP_NAMES = [ 25 | 'map', 26 | 'aoMap', 27 | 'emissiveMap', 28 | 'glossinessMap', 29 | 'metalnessMap', 30 | 'normalMap', 31 | 'roughnessMap', 32 | 'specularMap', 33 | ]; 34 | const Preset = {ASSET_GENERATOR: 'assetgenerator'}; 35 | 36 | module.exports = class Viewer { 37 | 38 | constructor(el, options) { 39 | this.el = el; 40 | this.options = options; 41 | 42 | this.lights = []; 43 | this.content = null; 44 | this.mixer = null; 45 | this.clips = []; 46 | this.gui = null; 47 | 48 | this.state = { 49 | environment: options.preset === Preset.ASSET_GENERATOR 50 | ? 'Footprint Court (HDR)' 51 | : environments[1].name, 52 | background: false, 53 | playbackSpeed: 1.0, 54 | actionStates: {}, 55 | camera: DEFAULT_CAMERA, 56 | wireframe: false, 57 | skeleton: false, 58 | grid: false, 59 | 60 | // Lights 61 | addLights: true, 62 | exposure: 1.0, 63 | textureEncoding: 'sRGB', 64 | ambientIntensity: 0.3, 65 | ambientColor: 0xFFFFFF, 66 | directIntensity: 0.8 * Math.PI, // TODO(#116) 67 | directColor: 0xFFFFFF, 68 | bgColor1: '#ffffff', 69 | bgColor2: '#353535' 70 | }; 71 | 72 | this.prevTime = 0; 73 | 74 | this.stats = new Stats(); 75 | this.stats.dom.height = '48px'; 76 | [].forEach.call(this.stats.dom.children, (child) => (child.style.display = '')); 77 | 78 | this.scene = new THREE.Scene(); 79 | 80 | const fov = options.preset === Preset.ASSET_GENERATOR 81 | ? 0.8 * 180 / Math.PI 82 | : 60; 83 | this.defaultCamera = new THREE.PerspectiveCamera(fov, el.clientWidth / el.clientHeight, 0.01, 1000); 84 | this.activeCamera = this.defaultCamera; 85 | this.scene.add(this.defaultCamera); 86 | 87 | this.renderer = window.renderer = new THREE.WebGLRenderer({antialias: true}); 88 | this.renderer.physicallyCorrectLights = true; 89 | this.renderer.gammaOutput = true; 90 | this.renderer.gammaFactor = 2.2; 91 | this.renderer.setClearColor(0xcccccc); 92 | this.renderer.setPixelRatio(window.devicePixelRatio); 93 | this.renderer.setSize(el.clientWidth, el.clientHeight); 94 | 95 | this.controls = new THREE.OrbitControls(this.defaultCamera, this.renderer.domElement); 96 | this.controls.autoRotate = false; 97 | this.controls.autoRotateSpeed = -10; 98 | this.controls.screenSpacePanning = true; 99 | 100 | this.background = createVignetteBackground({ 101 | aspect: this.defaultCamera.aspect, 102 | grainScale: IS_IOS ? 0 : 0.001, // mattdesl/three-vignette-background#1 103 | colors: [this.state.bgColor1, this.state.bgColor2] 104 | }); 105 | 106 | this.el.appendChild(this.renderer.domElement); 107 | 108 | this.cameraCtrl = null; 109 | this.cameraFolder = null; 110 | this.animFolder = null; 111 | this.animCtrls = []; 112 | this.morphFolder = null; 113 | this.morphCtrls = []; 114 | this.skeletonHelpers = []; 115 | this.gridHelper = null; 116 | this.axesHelper = null; 117 | 118 | this.addGUI(); 119 | 120 | this.animate = this.animate.bind(this); 121 | requestAnimationFrame(this.animate); 122 | window.addEventListener('resize', this.resize.bind(this), false); 123 | } 124 | 125 | animate(time) { 126 | 127 | requestAnimationFrame(this.animate); 128 | 129 | const dt = (time - this.prevTime) / 1000; 130 | 131 | this.controls.update(); 132 | this.stats.update(); 133 | this.mixer && this.mixer.update(dt); 134 | this.render(); 135 | 136 | this.prevTime = time; 137 | 138 | } 139 | 140 | render() { 141 | 142 | this.renderer.render(this.scene, this.activeCamera); 143 | 144 | } 145 | 146 | resize() { 147 | 148 | const {clientHeight, clientWidth} = this.el.parentElement; 149 | 150 | this.defaultCamera.aspect = clientWidth / clientHeight; 151 | this.defaultCamera.updateProjectionMatrix(); 152 | this.background.style({aspect: this.defaultCamera.aspect}); 153 | this.renderer.setSize(clientWidth, clientHeight); 154 | 155 | } 156 | 157 | load(url, rootPath, assetMap) { 158 | 159 | const baseURL = THREE.LoaderUtils.extractUrlBase(url); 160 | 161 | // Load. 162 | return new Promise((resolve, reject) => { 163 | const manager = new THREE.LoadingManager(); 164 | const blobURLs = []; 165 | 166 | manager.onError = (url) => { 167 | const message = 'Error loading url: ' + url; 168 | alert(message); 169 | console.error('[FBX Viewer] ' + message); 170 | }; 171 | 172 | // Intercept and override relative URLs. 173 | manager.setURLModifier((url, path) => { 174 | 175 | const normalizedURL = rootPath + url 176 | .replace(baseURL, '') 177 | .replace(/^(\.?\/)/, ''); 178 | 179 | if (assetMap.has(normalizedURL)) { 180 | const blob = assetMap.get(normalizedURL); 181 | const blobURL = URL.createObjectURL(blob); 182 | blobURLs.push(blobURL); 183 | return blobURL; 184 | } 185 | 186 | return (path || '') + url; 187 | 188 | }); 189 | 190 | const loader = new THREE.FBXLoader(manager); 191 | loader.setCrossOrigin('anonymous'); 192 | 193 | loader.load(url, (file) => { 194 | 195 | const scene = file; 196 | const clips = file.animations || []; 197 | this.setContent(scene, clips); 198 | 199 | blobURLs.forEach(URL.revokeObjectURL); 200 | 201 | resolve(file); 202 | 203 | }, undefined, reject); 204 | 205 | }); 206 | 207 | } 208 | 209 | /** 210 | * @param {THREE.Object3D} object 211 | * @param {Array { 255 | if (node.isLight) { 256 | this.state.addLights = false; 257 | } 258 | }); 259 | 260 | this.setClips(clips); 261 | 262 | this.updateLights(); 263 | this.updateGUI(); 264 | this.updateEnvironment(); 265 | this.updateTextureEncoding(); 266 | this.updateDisplay(); 267 | this.options.spinner.style.display = 'none' 268 | window.scene = this.content; 269 | console.info('[FBX Viewer] THREE.Scene exported as `window.scene`.'); 270 | this.printGraph(this.content); 271 | 272 | } 273 | 274 | printGraph(node) { 275 | 276 | console.group(' <' + node.type + '> ' + node.name); 277 | node.children.forEach((child) => this.printGraph(child)); 278 | console.groupEnd(); 279 | 280 | } 281 | 282 | /** 283 | * @param {Array { 302 | this.mixer.clipAction(clip).reset().play(); 303 | this.state.actionStates[clip.name] = true; 304 | }); 305 | } 306 | 307 | /** 308 | * @param {string} name 309 | */ 310 | setCamera(name) { 311 | if (name === DEFAULT_CAMERA) { 312 | this.controls.enabled = true; 313 | this.activeCamera = this.defaultCamera; 314 | } else { 315 | this.controls.enabled = false; 316 | this.content.traverse((node) => { 317 | if (node.isCamera && node.name === name) { 318 | this.activeCamera = node; 319 | } 320 | }); 321 | } 322 | } 323 | 324 | updateTextureEncoding() { 325 | const encoding = this.state.textureEncoding === 'sRGB' 326 | ? THREE.sRGBEncoding 327 | : THREE.LinearEncoding; 328 | traverseMaterials(this.content, (material) => { 329 | if (material.map) { 330 | material.map.encoding = encoding; 331 | } 332 | if (material.emissiveMap) { 333 | material.emissiveMap.encoding = encoding; 334 | } 335 | if (material.map || material.emissiveMap) { 336 | material.needsUpdate = true; 337 | } 338 | }); 339 | } 340 | 341 | updateLights() { 342 | const state = this.state; 343 | const lights = this.lights; 344 | 345 | if (state.addLights && !lights.length) { 346 | this.addLights(); 347 | } else if (!state.addLights && lights.length) { 348 | this.removeLights(); 349 | } 350 | 351 | this.renderer.toneMappingExposure = state.exposure; 352 | 353 | if (lights.length === 2) { 354 | lights[0].intensity = state.ambientIntensity; 355 | lights[0].color.setHex(state.ambientColor); 356 | lights[1].intensity = state.directIntensity; 357 | lights[1].color.setHex(state.directColor); 358 | } 359 | } 360 | 361 | addLights() { 362 | const state = this.state; 363 | 364 | if (this.options.preset === Preset.ASSET_GENERATOR) { 365 | const hemiLight = new THREE.HemisphereLight(); 366 | hemiLight.name = 'hemi_light'; 367 | this.scene.add(hemiLight); 368 | this.lights.push(hemiLight); 369 | return; 370 | } 371 | 372 | const light1 = new THREE.AmbientLight(state.ambientColor, state.ambientIntensity); 373 | light1.name = 'ambient_light'; 374 | this.defaultCamera.add(light1); 375 | 376 | const light2 = new THREE.DirectionalLight(state.directColor, state.directIntensity); 377 | light2.position.set(0.5, 0, 0.866); // ~60º 378 | light2.name = 'main_light'; 379 | this.defaultCamera.add(light2); 380 | 381 | this.lights.push(light1, light2); 382 | } 383 | 384 | removeLights() { 385 | 386 | this.lights.forEach((light) => light.parent.remove(light)); 387 | this.lights.length = 0; 388 | 389 | } 390 | 391 | updateEnvironment() { 392 | 393 | const environment = environments.filter((entry) => entry.name === this.state.environment)[0]; 394 | 395 | this.getCubeMapTexture(environment).then(({envMap, cubeMap}) => { 396 | 397 | if ((!envMap || !this.state.background) && this.activeCamera === this.defaultCamera) { 398 | this.scene.add(this.background); 399 | } else { 400 | this.scene.remove(this.background); 401 | } 402 | 403 | traverseMaterials(this.content, (material) => { 404 | if (material.isMeshStandardMaterial) { 405 | material.envMap = envMap; 406 | material.needsUpdate = true; 407 | } 408 | }); 409 | 410 | this.scene.background = this.state.background ? cubeMap : null; 411 | 412 | }); 413 | 414 | } 415 | 416 | getCubeMapTexture(environment) { 417 | const {path, format} = environment; 418 | 419 | // no envmap 420 | if (!path) { 421 | return Promise.resolve({envMap: null, cubeMap: null}); 422 | } 423 | 424 | const cubeMapURLs = [ 425 | path + 'posx' + format, path + 'negx' + format, 426 | path + 'posy' + format, path + 'negy' + format, 427 | path + 'posz' + format, path + 'negz' + format 428 | ]; 429 | 430 | // hdr 431 | if (format === '.hdr') { 432 | 433 | return new Promise((resolve) => { 434 | 435 | new THREE.HDRCubeTextureLoader().load(THREE.UnsignedByteType, cubeMapURLs, (hdrCubeMap) => { 436 | 437 | const pmremGenerator = new THREE.PMREMGenerator(hdrCubeMap); 438 | pmremGenerator.update(this.renderer); 439 | 440 | const pmremCubeUVPacker = new THREE.PMREMCubeUVPacker(pmremGenerator.cubeLods); 441 | pmremCubeUVPacker.update(this.renderer); 442 | 443 | resolve({ 444 | envMap: pmremCubeUVPacker.CubeUVRenderTarget.texture, 445 | cubeMap: hdrCubeMap 446 | }); 447 | 448 | }); 449 | 450 | }); 451 | 452 | } 453 | 454 | // standard 455 | const envMap = new THREE.CubeTextureLoader().load(cubeMapURLs); 456 | envMap.format = THREE.RGBFormat; 457 | return Promise.resolve({envMap, cubeMap: envMap}); 458 | 459 | } 460 | 461 | updateDisplay() { 462 | if (this.skeletonHelpers.length) { 463 | this.skeletonHelpers.forEach((helper) => this.scene.remove(helper)); 464 | } 465 | 466 | traverseMaterials(this.content, (material) => { 467 | material.wireframe = this.state.wireframe; 468 | }); 469 | 470 | this.content.traverse((node) => { 471 | if (node.isMesh && node.skeleton && this.state.skeleton) { 472 | const helper = new THREE.SkeletonHelper(node.skeleton.bones[0].parent); 473 | helper.material.linewidth = 3; 474 | this.scene.add(helper); 475 | this.skeletonHelpers.push(helper); 476 | } 477 | }); 478 | 479 | if (this.state.grid !== Boolean(this.gridHelper)) { 480 | if (this.state.grid) { 481 | this.gridHelper = new THREE.GridHelper(); 482 | this.axesHelper = new THREE.AxesHelper(); 483 | this.axesHelper.renderOrder = 999; 484 | this.axesHelper.onBeforeRender = (renderer) => renderer.clearDepth(); 485 | this.scene.add(this.gridHelper); 486 | this.scene.add(this.axesHelper); 487 | } else { 488 | this.scene.remove(this.gridHelper); 489 | this.scene.remove(this.axesHelper); 490 | this.gridHelper = null; 491 | this.axesHelper = null; 492 | } 493 | } 494 | } 495 | 496 | updateBackground() { 497 | this.background.style({colors: [this.state.bgColor1, this.state.bgColor2]}); 498 | } 499 | 500 | addGUI() { 501 | 502 | const gui = this.gui = new dat.GUI({autoPlace: false, width: 260, hideable: true}); 503 | 504 | // Display controls. 505 | const dispFolder = gui.addFolder('Display'); 506 | const envBackgroundCtrl = dispFolder.add(this.state, 'background'); 507 | envBackgroundCtrl.onChange(() => this.updateEnvironment()); 508 | const wireframeCtrl = dispFolder.add(this.state, 'wireframe'); 509 | wireframeCtrl.onChange(() => this.updateDisplay()); 510 | const skeletonCtrl = dispFolder.add(this.state, 'skeleton'); 511 | skeletonCtrl.onChange(() => this.updateDisplay()); 512 | const gridCtrl = dispFolder.add(this.state, 'grid'); 513 | gridCtrl.onChange(() => this.updateDisplay()); 514 | dispFolder.add(this.controls, 'autoRotate'); 515 | dispFolder.add(this.controls, 'screenSpacePanning'); 516 | const bgColor1Ctrl = dispFolder.addColor(this.state, 'bgColor1'); 517 | const bgColor2Ctrl = dispFolder.addColor(this.state, 'bgColor2'); 518 | bgColor1Ctrl.onChange(() => this.updateBackground()); 519 | bgColor2Ctrl.onChange(() => this.updateBackground()); 520 | 521 | // Lighting controls. 522 | const lightFolder = gui.addFolder('Lighting'); 523 | const encodingCtrl = lightFolder.add(this.state, 'textureEncoding', ['sRGB', 'Linear']); 524 | encodingCtrl.onChange(() => this.updateTextureEncoding()); 525 | lightFolder.add(this.renderer, 'gammaOutput').onChange(() => { 526 | traverseMaterials(this.content, (material) => { 527 | material.needsUpdate = true; 528 | }); 529 | }); 530 | const envMapCtrl = lightFolder.add(this.state, 'environment', environments.map((env) => env.name)); 531 | envMapCtrl.onChange(() => this.updateEnvironment()); 532 | [ 533 | lightFolder.add(this.state, 'exposure', 0, 2), 534 | lightFolder.add(this.state, 'addLights').listen(), 535 | lightFolder.add(this.state, 'ambientIntensity', 0, 2), 536 | lightFolder.addColor(this.state, 'ambientColor'), 537 | lightFolder.add(this.state, 'directIntensity', 0, 4), // TODO(#116) 538 | lightFolder.addColor(this.state, 'directColor') 539 | ].forEach((ctrl) => ctrl.onChange(() => this.updateLights())); 540 | 541 | // Animation controls. 542 | this.animFolder = gui.addFolder('Animation'); 543 | this.animFolder.domElement.style.display = 'none'; 544 | const playbackSpeedCtrl = this.animFolder.add(this.state, 'playbackSpeed', 0, 1); 545 | playbackSpeedCtrl.onChange((speed) => { 546 | if (this.mixer) { 547 | this.mixer.timeScale = speed; 548 | } 549 | }); 550 | this.animFolder.add({playAll: () => this.playAllClips()}, 'playAll'); 551 | 552 | // Morph target controls. 553 | this.morphFolder = gui.addFolder('Morph Targets'); 554 | this.morphFolder.domElement.style.display = 'none'; 555 | 556 | // Camera controls. 557 | this.cameraFolder = gui.addFolder('Cameras'); 558 | this.cameraFolder.domElement.style.display = 'none'; 559 | 560 | // Stats. 561 | const perfFolder = gui.addFolder('Performance'); 562 | const perfLi = document.createElement('li'); 563 | this.stats.dom.style.position = 'static'; 564 | perfLi.appendChild(this.stats.dom); 565 | perfLi.classList.add('gui-stats'); 566 | perfFolder.__ul.appendChild(perfLi); 567 | 568 | const guiWrap = document.createElement('div'); 569 | this.el.appendChild(guiWrap); 570 | guiWrap.classList.add('gui-wrap'); 571 | guiWrap.appendChild(gui.domElement); 572 | gui.open(); 573 | 574 | } 575 | 576 | updateGUI() { 577 | this.cameraFolder.domElement.style.display = 'none'; 578 | 579 | this.morphCtrls.forEach((ctrl) => ctrl.remove()); 580 | this.morphCtrls.length = 0; 581 | this.morphFolder.domElement.style.display = 'none'; 582 | 583 | this.animCtrls.forEach((ctrl) => ctrl.remove()); 584 | this.animCtrls.length = 0; 585 | this.animFolder.domElement.style.display = 'none'; 586 | 587 | const cameraNames = []; 588 | const morphMeshes = []; 589 | this.content.traverse((node) => { 590 | if (node.isMesh && node.morphTargetInfluences) { 591 | morphMeshes.push(node); 592 | } 593 | if (node.isCamera) { 594 | node.name = node.name || `VIEWER__camera_${cameraNames.length + 1}`; 595 | cameraNames.push(node.name); 596 | } 597 | }); 598 | 599 | if (cameraNames.length) { 600 | this.cameraFolder.domElement.style.display = ''; 601 | if (this.cameraCtrl) { 602 | this.cameraCtrl.remove(); 603 | } 604 | const cameraOptions = [DEFAULT_CAMERA].concat(cameraNames); 605 | this.cameraCtrl = this.cameraFolder.add(this.state, 'camera', cameraOptions); 606 | this.cameraCtrl.onChange((name) => this.setCamera(name)); 607 | } 608 | 609 | if (morphMeshes.length) { 610 | this.morphFolder.domElement.style.display = ''; 611 | morphMeshes.forEach((mesh) => { 612 | if (mesh.morphTargetInfluences.length) { 613 | const nameCtrl = this.morphFolder.add({name: mesh.name || 'Untitled'}, 'name'); 614 | this.morphCtrls.push(nameCtrl); 615 | } 616 | for (let i = 0; i < mesh.morphTargetInfluences.length; i++) { 617 | const ctrl = this.morphFolder.add(mesh.morphTargetInfluences, i, 0, 1, 0.01).listen(); 618 | Object.keys(mesh.morphTargetDictionary).forEach((key) => { 619 | if (key && mesh.morphTargetDictionary[key] === i) { 620 | ctrl.name(key); 621 | } 622 | }); 623 | this.morphCtrls.push(ctrl); 624 | } 625 | }); 626 | } 627 | 628 | if (this.clips.length) { 629 | this.animFolder.domElement.style.display = ''; 630 | const actionStates = this.state.actionStates = {}; 631 | this.clips.forEach((clip, clipIndex) => { 632 | // Autoplay the first clip. 633 | let action; 634 | if (clipIndex === 0) { 635 | actionStates[clip.name] = true; 636 | action = this.mixer.clipAction(clip); 637 | action.play(); 638 | } else { 639 | actionStates[clip.name] = false; 640 | } 641 | 642 | // Play other clips when enabled. 643 | const ctrl = this.animFolder.add(actionStates, clip.name).listen(); 644 | ctrl.onChange((playAnimation) => { 645 | action = action || this.mixer.clipAction(clip); 646 | action.setEffectiveTimeScale(1); 647 | playAnimation ? action.play() : action.stop(); 648 | }); 649 | this.animCtrls.push(ctrl); 650 | }); 651 | } 652 | } 653 | 654 | clear() { 655 | 656 | if (!this.content) { 657 | return; 658 | } 659 | 660 | this.scene.remove(this.content); 661 | 662 | // dispose geometry 663 | this.content.traverse((node) => { 664 | 665 | if (!node.isMesh) { 666 | return; 667 | } 668 | 669 | node.geometry.dispose(); 670 | 671 | }); 672 | 673 | // dispose textures 674 | traverseMaterials(this.content, (material) => { 675 | 676 | MAP_NAMES.forEach((map) => { 677 | 678 | if (material[map]) { 679 | material[map].dispose(); 680 | } 681 | 682 | }); 683 | 684 | }); 685 | 686 | } 687 | 688 | }; 689 | 690 | function traverseMaterials(object, callback) { 691 | object.traverse((node) => { 692 | if (!node.isMesh) { 693 | return; 694 | } 695 | const materials = Array.isArray(node.material) 696 | ? node.material 697 | : [node.material]; 698 | materials.forEach(callback); 699 | }); 700 | } 701 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: 'Raleway', sans-serif; 5 | background: #F5F5F5; 6 | height: 100%; 7 | overflow: hidden; 8 | } 9 | 10 | * { 11 | box-sizing: border-box; 12 | } 13 | 14 | body { 15 | display: flex; 16 | flex-direction: column; 17 | } 18 | 19 | .wrap { 20 | display: flex; 21 | width: 100vw; 22 | flex-grow: 1; 23 | position: relative; 24 | } 25 | 26 | .dropzone { 27 | display: flex; 28 | flex-grow: 1; 29 | flex-direction: column; 30 | justify-content: center; 31 | align-items: center; 32 | } 33 | 34 | .placeholder { 35 | width: 100%; 36 | max-width: 500px; 37 | border-radius: 0.5em; 38 | background: #EEE; 39 | padding: 2em; 40 | text-align: center; 41 | } 42 | 43 | .placeholder p { 44 | font-size: 1.2rem; 45 | color: #999; 46 | } 47 | 48 | .viewer { 49 | width: 100%; 50 | height: 100%; 51 | flex-grow: 1; 52 | flex-shrink: 1; 53 | position: absolute; 54 | top: 0; 55 | } 56 | 57 | /****************************************************************************** 58 | * Header 59 | */ 60 | 61 | header { 62 | display: flex; 63 | background: #353535; 64 | padding: 0 2em; 65 | height: 2rem; 66 | line-height: 2rem; 67 | align-items: center; 68 | overflow-x: auto; 69 | overflow-y: hidden; 70 | white-space: nowrap; 71 | box-shadow: 0px 0px 8px 2px rgba(0, 0, 0, 0.3); 72 | z-index: 1; 73 | 74 | -webkit-app-region: drag; 75 | } 76 | 77 | header h1, 78 | header .item, 79 | header .separator { 80 | color: #F5F5F5; 81 | font-weight: 300; 82 | line-height: 4rem; 83 | margin: 0; 84 | } 85 | 86 | header h1 { 87 | font-size: 1.4rem; 88 | } 89 | 90 | header h1 > a { 91 | color: inherit; 92 | font-size: inherit; 93 | text-decoration: inherit; 94 | } 95 | 96 | header .item { 97 | padding: 0 1em; 98 | font-size: 0.8rem; 99 | text-decoration: none; 100 | transition: background ease 0.2s; 101 | 102 | -webkit-app-region: no-drag; 103 | } 104 | 105 | header .item:hover { 106 | background: #444; 107 | } 108 | 109 | header button.item { 110 | height: 34px; 111 | line-height: 35px; 112 | padding: 0 1em; 113 | border: 0; 114 | background: #ffc107; 115 | color: #333; 116 | font-weight: 500; 117 | border-radius: 2px; 118 | cursor: pointer; 119 | } 120 | 121 | header button.item:hover { 122 | color: #000; 123 | } 124 | 125 | header .separator { 126 | margin: 0 0.2em; 127 | opacity: 0.2; 128 | } 129 | 130 | header h1 + .separator { 131 | margin-left: 1em; 132 | } 133 | 134 | .flex-grow { 135 | flex-grow: 1; 136 | } 137 | 138 | .gui-wrap { 139 | position: absolute; 140 | top: 0; 141 | right: 0; 142 | bottom: 0; 143 | overflow: auto; 144 | pointer-events: all; 145 | } 146 | 147 | .dg li.gui-stats:not(.folder) { 148 | height: auto; 149 | } 150 | 151 | @media screen and (max-width: 700px) { 152 | header h1 { 153 | font-size: 1em; 154 | } 155 | 156 | .layout-md { 157 | display: none; 158 | } 159 | } 160 | 161 | /****************************************************************************** 162 | * Upload Button 163 | * 164 | * https://tympanus.net/Tutorials/CustomFileInputs/ 165 | */ 166 | 167 | .upload-btn { 168 | margin-top: 2em; 169 | } 170 | 171 | .upload-btn input { 172 | width: 0.1px; 173 | height: 0.1px; 174 | opacity: 0; 175 | overflow: hidden; 176 | position: absolute; 177 | z-index: -1; 178 | } 179 | 180 | .upload-btn label { 181 | color: #353535; 182 | border: 0; 183 | border-radius: 3px; 184 | transition: background ease 0.2s; 185 | font-size: 1rem; 186 | font-weight: 700; 187 | text-overflow: ellipsis; 188 | white-space: nowrap; 189 | cursor: pointer; 190 | display: inline-block; 191 | overflow: hidden; 192 | padding: 0.625rem 1.25rem; 193 | } 194 | 195 | .upload-btn label:hover { 196 | background: #DDD; 197 | } 198 | 199 | .upload-btn svg { 200 | width: 1em; 201 | height: 1em; 202 | vertical-align: middle; 203 | fill: currentColor; 204 | margin-top: -0.25em; 205 | margin-right: 0.25em; 206 | } 207 | 208 | /****************************************************************************** 209 | * CSS Spinner 210 | * 211 | * http://tobiasahlin.com/spinkit/ 212 | */ 213 | 214 | .spinner { 215 | width: 40px; 216 | height: 40px; 217 | position: absolute; 218 | left: 50%; 219 | top: 50%; 220 | margin: -20px; 221 | 222 | background-color: #333; 223 | 224 | border-radius: 100%; 225 | -webkit-animation: sk-scaleout 1.0s infinite ease-in-out; 226 | animation: sk-scaleout 1.0s infinite ease-in-out; 227 | } 228 | 229 | @-webkit-keyframes sk-scaleout { 230 | 0% { 231 | -webkit-transform: scale(0) 232 | } 233 | 100% { 234 | -webkit-transform: scale(1.0); 235 | opacity: 0; 236 | } 237 | } 238 | 239 | @keyframes sk-scaleout { 240 | 0% { 241 | -webkit-transform: scale(0); 242 | transform: scale(0); 243 | } 244 | 100% { 245 | -webkit-transform: scale(1.0); 246 | transform: scale(1.0); 247 | opacity: 0; 248 | } 249 | } 250 | --------------------------------------------------------------------------------