├── .gitignore ├── .npmignore ├── test ├── modules │ ├── import.js │ ├── es5.js │ ├── simple.js │ └── GLTFLoader.js ├── generate.js ├── run-benchmark.js └── deps.js ├── lib ├── detect-global-deps.js ├── bundle-three.js ├── detect-deps.js └── generate-three-entry.js ├── examples ├── es5.js ├── es6.js ├── globals.js └── import-with-gltf.js ├── LICENSE.md ├── package.json ├── plugin.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | test/benchmarks -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | test 7 | test.js 8 | demo/ 9 | .npmignore 10 | LICENSE.md 11 | test/benchmarks -------------------------------------------------------------------------------- /test/modules/import.js: -------------------------------------------------------------------------------- 1 | import { 2 | Scene, 3 | Object3D 4 | } from 'three'; 5 | 6 | import THREEDefault, { WebGLRenderer as Renderer, LinearFilter } from 'three'; 7 | import * as THREE from 'three'; 8 | 9 | const a = THREE.RGBFormat; 10 | 11 | module.exports = function () { 12 | return THREEDefault.RGBAFormat; 13 | }; 14 | -------------------------------------------------------------------------------- /lib/detect-global-deps.js: -------------------------------------------------------------------------------- 1 | const stripComments = require('strip-comments'); 2 | 3 | module.exports = function (code, opts = {}) { 4 | code = stripComments(code); 5 | 6 | // Which globals to look for 7 | const globalName = opts.globalName || 'THREE'; 8 | 9 | const regExp = new RegExp(globalName + '\.([a-zA-Z0-9_-]+)', 'g'); 10 | let match; 11 | const dependencies = []; 12 | while (match = regExp.exec(code)) { 13 | const dep = match[1]; 14 | if (!dependencies.includes(dep)) dependencies.push(dep); 15 | } 16 | return dependencies; 17 | }; 18 | -------------------------------------------------------------------------------- /examples/es5.js: -------------------------------------------------------------------------------- 1 | var THREE = require('three'); 2 | 3 | var renderer = new THREE.WebGLRenderer(); 4 | renderer.setPixelRatio(window.devicePixelRatio); 5 | renderer.setSize(256, 256); 6 | 7 | var scene = new THREE.Scene(); 8 | var camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100); 9 | camera.position.z = -4; 10 | camera.lookAt(new THREE.Vector3()); 11 | 12 | var geometry = new THREE.SphereGeometry(1, 32, 32); 13 | var mesh = new THREE.Mesh(geometry, new THREE.MeshNormalMaterial()); 14 | scene.add(mesh); 15 | 16 | renderer.render(scene, camera); 17 | 18 | document.body.appendChild(renderer.domElement); 19 | -------------------------------------------------------------------------------- /test/modules/es5.js: -------------------------------------------------------------------------------- 1 | const { 2 | WebGLRenderer: Renderer, 3 | RGBFormat 4 | } = require('three'); 5 | 6 | const linearFilter = require('three').LinearFilter; 7 | 8 | const _webgl = require('three'), 9 | format2 = _webgl.RGBAFormat; 10 | 11 | const _other = require('three'); 12 | 13 | const filter2 = _webgl.NearestFilter; 14 | const feature = new _other.SphereGeometry(); 15 | const featureOther = new _other.SphereGeometry(); 16 | 17 | module.exports = function () { 18 | const featureOther2 = new _other.BufferGeometry(); 19 | let _webgl = 'foobar'; 20 | // return { RGBFormat, Renderer }; 21 | }; 22 | -------------------------------------------------------------------------------- /test/modules/simple.js: -------------------------------------------------------------------------------- 1 | import { 2 | WebGLRenderer, 3 | Scene, 4 | PerspectiveCamera, 5 | Mesh, 6 | MeshNormalMaterial, 7 | SphereGeometry, 8 | Vector3 9 | } from 'three'; 10 | 11 | const renderer = new WebGLRenderer(); 12 | renderer.setPixelRatio(window.devicePixelRatio); 13 | renderer.setSize(256, 256); 14 | 15 | const scene = new Scene(); 16 | const camera = new PerspectiveCamera(45, 1, 0.01, 100); 17 | camera.position.z = -4; 18 | camera.lookAt(new Vector3()); 19 | 20 | const geometry = new SphereGeometry(1, 32, 32); 21 | const mesh = new Mesh(geometry, new MeshNormalMaterial()); 22 | scene.add(mesh); 23 | 24 | renderer.render(scene, camera); 25 | 26 | document.body.appendChild(renderer.domElement); 27 | -------------------------------------------------------------------------------- /examples/es6.js: -------------------------------------------------------------------------------- 1 | // can also use wildcard: 2 | // import * as THREE from 'three'; 3 | 4 | import { 5 | WebGLRenderer, 6 | Scene, 7 | PerspectiveCamera, 8 | Vector3, 9 | SphereGeometry, 10 | Mesh, 11 | MeshNormalMaterial 12 | } from 'three'; 13 | 14 | var renderer = new WebGLRenderer(); 15 | renderer.setPixelRatio(window.devicePixelRatio); 16 | renderer.setSize(256, 256); 17 | 18 | var scene = new Scene(); 19 | var camera = new PerspectiveCamera(45, 1, 0.01, 100); 20 | camera.position.z = -4; 21 | camera.lookAt(new Vector3()); 22 | 23 | var geometry = new SphereGeometry(1, 32, 32); 24 | var mesh = new Mesh(geometry, new MeshNormalMaterial()); 25 | scene.add(mesh); 26 | 27 | renderer.render(scene, camera); 28 | 29 | document.body.appendChild(renderer.domElement); 30 | -------------------------------------------------------------------------------- /examples/globals.js: -------------------------------------------------------------------------------- 1 | // Somewhere at the root of your app 2 | global.THREE = require('three'); 3 | 4 | // Now the rest of your modules/files can use ThreeJS 5 | // without having to import/require it all the time. 6 | const renderer = new THREE.WebGLRenderer(); 7 | renderer.setPixelRatio(window.devicePixelRatio); 8 | renderer.setSize(256, 256); 9 | 10 | const scene = new THREE.Scene(); 11 | const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100); 12 | camera.position.z = -4; 13 | camera.lookAt(new THREE.Vector3()); 14 | 15 | const geometry = new THREE.SphereGeometry(1, 32, 32); 16 | const mesh = new THREE.Mesh(geometry, new THREE.MeshNormalMaterial()); 17 | scene.add(mesh); 18 | 19 | renderer.render(scene, camera); 20 | 21 | document.body.appendChild(renderer.domElement); 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2017 Matt DesLauriers 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /examples/import-with-gltf.js: -------------------------------------------------------------------------------- 1 | // To use 3rd party ThreeJS libraries, 2 | // you will need to require (not import) 3 | // it and assign it to global THREE 4 | // at the top of your app. 5 | global.THREE = require('three'); 6 | 7 | // Then you can require your vendor libs here... 8 | require('three/examples/js/loaders/GLTFLoader.js'); 9 | 10 | // It will end up on the global THREE object: 11 | console.log(THREE.GLTFLoader); 12 | 13 | // Now you can use CommonJS or ES6 import/export as usual 14 | // for tree shaking... 15 | import { 16 | WebGLRenderer, 17 | Scene, 18 | PerspectiveCamera, 19 | Mesh, 20 | MeshNormalMaterial, 21 | SphereGeometry, 22 | Vector3 23 | } from 'three'; 24 | 25 | const renderer = new WebGLRenderer(); 26 | renderer.setPixelRatio(window.devicePixelRatio); 27 | renderer.setSize(256, 256); 28 | 29 | const scene = new Scene(); 30 | const camera = new PerspectiveCamera(45, 1, 0.01, 100); 31 | camera.position.z = -4; 32 | camera.lookAt(new Vector3()); 33 | 34 | const geometry = new SphereGeometry(1, 32, 32); 35 | const mesh = new Mesh(geometry, new MeshNormalMaterial()); 36 | scene.add(mesh); 37 | 38 | renderer.render(scene, camera); 39 | 40 | document.body.appendChild(renderer.domElement); 41 | -------------------------------------------------------------------------------- /test/generate.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape'); 2 | 3 | const generate = require('../lib/generate-three-entry'); 4 | 5 | tape('generates ThreeJS entry', t => { 6 | const result = generate([ 'RGBFormat', 'RGBAFormat', 'MeshNormalMaterial', 'SphereGeometry', 'QuadraticBezierCurve3', 'WebGLRenderer' ]); 7 | t.equals(result.trim(), `import './polyfills.js'; 8 | export { WebGLRenderer } from './renderers/WebGLRenderer.js'; 9 | export { SphereGeometry } from './geometries/SphereGeometry.js'; 10 | export { MeshNormalMaterial } from './materials/MeshNormalMaterial.js'; 11 | export { QuadraticBezierCurve3 } from './extras/curves/QuadraticBezierCurve3.js'; 12 | export { RGBFormat, RGBAFormat } from './constants.js';`); 13 | t.end(); 14 | }); 15 | 16 | tape('generates ThreeJS entry', t => { 17 | const result = generate([ 'SphereBufferGeometry', 'SphereGeometry', 'QuadraticBezierCurve3', 'WebGLRenderer' ], { 18 | legacy: true, 19 | polyfills: false 20 | }); 21 | t.equals(result.trim(), `export { WebGLRenderer } from './renderers/WebGLRenderer.js'; 22 | export { SphereGeometry, SphereBufferGeometry } from './geometries/SphereGeometry.js'; 23 | export { QuadraticBezierCurve3 } from './extras/curves/QuadraticBezierCurve3.js'; 24 | export * from './Three.Legacy.js';`); 25 | t.end(); 26 | }); 27 | 28 | tape('handles BufferAttribute', t => { 29 | const result = generate([ 'Float32BufferAttribute', 'Uint8ClampedBufferAttribute' ], { 30 | polyfills: false 31 | }); 32 | t.equals(result.trim(), `export * from './core/BufferAttribute.js';`); 33 | t.end(); 34 | }); 35 | -------------------------------------------------------------------------------- /test/run-benchmark.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const browserify = require('browserify'); 5 | const mapLimit = require('map-limit'); 6 | const bytes = require('pretty-bytes'); 7 | const uglify = require('uglify-js'); 8 | const ms = require('pretty-ms'); 9 | 10 | const plugin = require('../plugin'); 11 | 12 | const babelify = require('babelify').configure({ 13 | presets: [ 'es2015' ], 14 | sourceMaps: false 15 | }); 16 | 17 | const files = [ 18 | 'es5.js', 19 | 'globals.js', 20 | 'import-with-gltf.js' 21 | ].map(p => path.resolve(__dirname, '../examples/', p)); 22 | 23 | mapLimit(files, 1, bench, () => console.log('Done.')); 24 | 25 | function bench (file, cb) { 26 | doBundle(false, file, (err) => { 27 | if (err) return cb(err); 28 | doBundle(true, file, cb); 29 | }); 30 | } 31 | 32 | function doBundle (withOptimize, file, cb) { 33 | const opts = { 34 | loose: file.includes('globals') 35 | }; 36 | 37 | const start = Date.now(); 38 | const b = browserify(file, { 39 | debug: false, 40 | transform: file.includes('es5') ? undefined : [ 41 | babelify 42 | ], 43 | plugin: withOptimize ? [ 44 | [ plugin, opts ] 45 | ] : undefined 46 | }); 47 | b.bundle((err, src) => { 48 | if (err) return cb(err); 49 | src = src.toString(); 50 | try { 51 | const min = uglify.minify(src, { mangle: true, compress: { dead_code: true, evaluate: true } }); 52 | src = min.code; 53 | } catch (err) { 54 | return cb(err); 55 | } 56 | 57 | const then = Date.now(); 58 | const time = ms(then - start); 59 | const name = path.basename(file); 60 | console.log(`${name} — ${withOptimize ? 'with' : 'without'} optimization: ${bytes(src.length)} (${time})`); 61 | cb(null); 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "threejs-tree-shake", 3 | "version": "1.0.0", 4 | "description": "Tree-shakes and optimizes ThreeJS apps", 5 | "main": "./plugin.js", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Matt DesLauriers", 9 | "email": "dave.des@gmail.com", 10 | "url": "https://github.com/mattdesl" 11 | }, 12 | "dependencies": { 13 | "babel-core": "^6.26.0", 14 | "concat-stream": "^1.6.0", 15 | "duplexer2": "^0.1.4", 16 | "from2-string": "^1.1.0", 17 | "pretty-bytes": "^4.0.2", 18 | "require-path-relative": "^1.0.1", 19 | "resolve": "^1.5.0", 20 | "rollup": "^0.54.0", 21 | "rollup-plugin-memory": "^2.0.0", 22 | "strip-comments": "^0.4.4", 23 | "through2": "^2.0.3" 24 | }, 25 | "devDependencies": { 26 | "babel-preset-es2015": "^6.24.1", 27 | "babel-traverse": "^6.26.0", 28 | "babelify": "^8.0.0", 29 | "babylon": "^6.18.0", 30 | "browserify": "^15.1.0", 31 | "budo": "^10.0.4", 32 | "map-limit": "0.0.1", 33 | "pretty-ms": "^3.1.0", 34 | "tape": "^4.8.0", 35 | "three": "^0.89.0", 36 | "uglify-js": "^3.3.7" 37 | }, 38 | "scripts": { 39 | "example-globals": "budo examples/globals.js -l -- -t [ babelify --presets es2015 ] -p [ ./ --loose ]", 40 | "example-inspect": "budo examples/es5.js -l -- -p [ ./ --debug --inspect ]", 41 | "example-gltf": "budo examples/import-with-gltf.js -l -- -t [ babelify --presets es2015 ] -p ./", 42 | "example-es6": "budo examples/es6.js -l -- -t [ babelify --presets es2015 ] -p ./", 43 | "test": "tape test/deps.js test/generate.js" 44 | }, 45 | "keywords": [ 46 | "three", 47 | "js", 48 | "threejs", 49 | "tree", 50 | "shake", 51 | "roll", 52 | "rollup", 53 | "bundle", 54 | "browserify", 55 | "browserified", 56 | "transform", 57 | "plugin", 58 | "bundler", 59 | "es6", 60 | "import", 61 | "export", 62 | "webgl" 63 | ], 64 | "repository": { 65 | "type": "git", 66 | "url": "git://github.com/mattdesl/threejs-tree-shake.git" 67 | }, 68 | "homepage": "https://github.com/mattdesl/threejs-tree-shake", 69 | "bugs": { 70 | "url": "https://github.com/mattdesl/threejs-tree-shake/issues" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/bundle-three.js: -------------------------------------------------------------------------------- 1 | const { rollup } = require('rollup'); 2 | const memory = require('rollup-plugin-memory'); 3 | const path = require('path'); 4 | const generateEntry = require('./generate-three-entry'); 5 | const prettyBytes = require('pretty-bytes'); 6 | 7 | function glsl () { 8 | return { 9 | // same as ThreeJS GLSL plugin 10 | transform (code, id) { 11 | if (/\.glsl$/.test(id) === false) return; 12 | var transformedCode = 'export default ' + JSON.stringify( 13 | code 14 | .replace(/[ \t]*\/\/.*\n/g, '') // remove // 15 | .replace(/[ \t]*\/\*[\s\S]*?\*\//g, '') // remove /* */ 16 | .replace(/\n{2,}/g, '\n') // # \n+ to \n 17 | ) + ';'; 18 | return { 19 | code: transformedCode, 20 | map: { mappings: '' } 21 | }; 22 | } 23 | }; 24 | } 25 | 26 | function sizes (params) { 27 | const files = []; 28 | const max = typeof params.inspect === 'number' ? params.inspect : 50; 29 | return { 30 | ongenerate: (opt, rendered) => { 31 | const bundle = opt.bundle; 32 | bundle.modules.forEach(mod => { 33 | let file = mod.id; 34 | file = params.threePath ? path.relative(path.resolve(params.threePath, 'src'), file) : file; 35 | const length = Buffer.byteLength(mod.code, 'utf8'); 36 | files.push({ file, length }); 37 | }); 38 | const total = files.reduce((sum, item) => sum + item.length, 0); 39 | files.forEach(item => { 40 | item.percent = `${Math.floor(100 * item.length / total)}%`; 41 | }); 42 | files.sort((a, b) => b.length - a.length); 43 | const lines = files.slice(0, max).map(item => ` ${item.file}: ${prettyBytes(item.length)} (${item.percent})`); 44 | console.error('Total ThreeJS files', files.length); 45 | console.error(`ThreeJS Rollup Analyzer:\n${lines.join('\n')}`); 46 | } 47 | }; 48 | } 49 | 50 | module.exports = function (entry, dependencies, opt = {}) { 51 | const inspect = opt.inspect; 52 | const contents = generateEntry(dependencies || [], opt); 53 | return rollup({ 54 | input: entry, 55 | plugins: [ 56 | // Generated entry code 57 | memory({ 58 | path: entry, 59 | contents 60 | }), 61 | // ThreeJS GLSL stuff 62 | glsl(), 63 | inspect ? sizes(opt) : undefined 64 | ].filter(Boolean) 65 | }).then(bundle => { 66 | return bundle.generate({ 67 | sourcemap: opt.sourcemap || 'inline', 68 | file: opt.generatedFile || 'three.js', 69 | name: opt.globalName || 'THREE', 70 | format: opt.outputFormat || 'umd' 71 | }); 72 | }); 73 | }; 74 | 75 | if (!module.parent) { 76 | module.exports().then(result => console.log(result.code)); 77 | } 78 | -------------------------------------------------------------------------------- /lib/detect-deps.js: -------------------------------------------------------------------------------- 1 | const babel = require('babel-core'); 2 | 3 | module.exports = function (code, opts = {}) { 4 | if (Buffer.isBuffer(code)) code = code.toString(); 5 | if (typeof code !== 'string') throw new TypeError('code must be Buffer or string'); 6 | 7 | const moduleName = opts.moduleName || 'three'; 8 | let dependencies = []; 9 | let bindings = []; 10 | 11 | // add our own plugin to options 12 | const babelOpts = Object.assign({}, opts.babel); 13 | if (Array.isArray(babelOpts.plugins)) { 14 | babelOpts.plugins.push(detectPlugin); 15 | } else if (babelOpts.plugins) { 16 | babelOpts.plugins = [ babelOpts.plugins, detectPlugin ]; 17 | } else { 18 | babelOpts.plugins = [ detectPlugin ]; 19 | } 20 | 21 | babel.transform(code, babelOpts); 22 | 23 | bindings.forEach(binding => { 24 | const referencedNames = binding.referencePaths 25 | .filter(ref => ref.parent.type === 'MemberExpression' && !ref.parent.computed) 26 | .map(ref => ref.parent.property.name); 27 | pushAll(referencedNames); 28 | }); 29 | 30 | return dependencies; 31 | 32 | function detectPlugin () { 33 | return { 34 | visitor: { 35 | ImportDeclaration: { 36 | enter: handleImport 37 | }, 38 | CallExpression: { 39 | enter: handleRequire 40 | } 41 | } 42 | }; 43 | } 44 | 45 | function filter (name) { 46 | return name === moduleName; 47 | } 48 | 49 | function push (p) { 50 | if (!dependencies.includes(p)) dependencies.push(p); 51 | } 52 | 53 | function pushAll (list) { 54 | list.forEach(dep => push(dep)); 55 | } 56 | 57 | function handleImport (nodePath) { 58 | const specifiers = nodePath.get('specifiers'); 59 | if (specifiers && specifiers.length) { 60 | specifiers.forEach(spec => { 61 | if (spec.isImportNamespaceSpecifier() || spec.isImportDefaultSpecifier()) { 62 | const name = spec.node.local.name; 63 | bindings.push(spec.scope.getBinding(name)); 64 | } else { 65 | push(spec.node.imported.name); 66 | } 67 | }); 68 | } 69 | } 70 | 71 | function handleRequire (nodePath) { 72 | const callee = nodePath.get('callee'); 73 | if (callee.isIdentifier() && callee.equals('name', 'require')) { 74 | const arg = nodePath.get('arguments')[0]; 75 | if (arg && arg.isStringLiteral() && filter(arg.node.value)) { 76 | const parent = nodePath.parentPath; 77 | if (parent && parent.isVariableDeclarator()) { 78 | const id = parent.get('id'); 79 | if (id && id.isObjectPattern()) { 80 | // Object destructuring 81 | const props = id.get('properties') || []; 82 | const deps = props.map(p => p.node.key.name); 83 | pushAll(deps); 84 | } else if (id && id.node.name) { 85 | // User required 'three' and referenced it later 86 | bindings.push(parent.scope.getBinding(id.node.name)); 87 | } 88 | } else if (parent.isMemberExpression()) { 89 | // e.g. require('three').LinearFilter 90 | const dep = parent.get('property').node.name; 91 | push(dep); 92 | } 93 | } 94 | } 95 | } 96 | }; 97 | -------------------------------------------------------------------------------- /lib/generate-three-entry.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const requireRelative = require('require-path-relative'); 4 | const stripComments = require('strip-comments'); 5 | const resolve = require('resolve'); 6 | 7 | module.exports = function (dependencies = [], opt = {}) { 8 | const basedir = opt.basedir || process.cwd(); 9 | 10 | const threePath = opt.threePath || path.dirname(resolve.sync('three/package.json', { basedir })); 11 | const threeSrc = path.resolve(threePath, 'src'); 12 | const threeIndex = path.resolve(threeSrc, 'Three.js'); 13 | 14 | const ignoreRecurse = [ 15 | 'polyfills.js', 16 | 'BufferAttribute.js', 17 | 'constants.js', 18 | 'Three.Legacy.js' 19 | ]; 20 | 21 | const alwaysImport = []; 22 | const polyfills = typeof opt.polyfills === 'boolean' ? opt.polyfills : true; 23 | 24 | const constants = gatherConstants(); 25 | const imports = parse(threeIndex); 26 | 27 | const strings = []; 28 | imports.forEach(block => { 29 | if (block.fileName === 'BufferAttribute.js') { 30 | const hasBufferAttrib = dependencies.some(dep => dep.includes('BufferAttribute')); 31 | if (hasBufferAttrib) strings.push(block.code); 32 | } else if (block.fileName === 'constants.js') { 33 | const variables = dependencies.filter(dep => constants.includes(dep)); 34 | if (variables.length > 0) { 35 | strings.push( 36 | `export { ${variables.join(', ')} } from '${block.file}';` 37 | ); 38 | } 39 | } else { 40 | const push = block.required || block.modifiedVariables.some(name => dependencies.includes(name)); 41 | if (block.fileName === 'Three.Legacy.js' && !opt.legacy) { 42 | return; 43 | } 44 | if (block.fileName === 'polyfills.js' && !polyfills) { 45 | return; 46 | } 47 | if (push) { 48 | strings.push(block.code); 49 | } 50 | } 51 | }); 52 | return strings.join('\n'); 53 | 54 | function gatherConstants () { 55 | const file = path.resolve(threeSrc, 'constants.js'); 56 | const exportStrings = stripComments(fs.readFileSync(file, 'utf-8')) 57 | .split('\n') 58 | .filter(n => n.trim().length > 0); 59 | return exportStrings.map(str => { 60 | const match = /export (?:var|let|const) (.*)\s+\=/.exec(str); 61 | if (!match) return null; 62 | return match[1]; 63 | }).filter(Boolean); 64 | } 65 | 66 | function parse (file, base) { 67 | const importStrings = stripComments(fs.readFileSync(file, 'utf-8')) 68 | .split('\n') 69 | .filter(n => n.trim().length > 0); 70 | return importStrings.map(str => { 71 | const match = /\{\s*(.*)\s*\}/.exec(str); 72 | let variables = []; 73 | if (match) { 74 | variables = match[1].trim().split(',').map(n => n.trim()); 75 | } 76 | 77 | let fileName; 78 | let file; 79 | let nameMatch = /from\s*["'](.*)["']/.exec(str); 80 | if (nameMatch) { 81 | file = nameMatch[1].trim(); 82 | fileName = path.basename(file); 83 | } else { 84 | const importMatch = /import\s*["'](.*)["']/.exec(str); 85 | if (importMatch) { 86 | file = importMatch[1].trim(); 87 | fileName = path.basename(file); 88 | } 89 | } 90 | 91 | const shouldRecurse = str.includes('export *') && variables.length === 0; 92 | if (shouldRecurse && !ignoreRecurse.includes(fileName)) { 93 | const absPath = path.resolve(threeSrc, file); 94 | const children = parse(absPath, path.dirname(absPath)); 95 | return children; 96 | } 97 | 98 | if (base) { 99 | file = requireRelative(threeSrc, path.dirname(path.resolve(base, file)), fileName); 100 | } 101 | 102 | const isAlwaysImport = fileName 103 | ? alwaysImport.includes(fileName) 104 | : false; 105 | const modifiedVariables = variables.filter(dep => dependencies.includes(dep)); 106 | const isModify = !isAlwaysImport && file && variables.length >= 0 && str.includes('export') && !str.includes('*'); 107 | const required = fileName !== 'BufferAttribute.js' && !isModify; 108 | const block = { 109 | variables, 110 | modifiedVariables, 111 | file, 112 | fileName, 113 | required, 114 | isModify, 115 | code: isModify ? `export { ${modifiedVariables.join(', ')} } from '${file}';` : str 116 | }; 117 | return [ block ]; 118 | }).reduce((array, other) => { 119 | return array.concat(other); 120 | }, []); 121 | } 122 | }; 123 | -------------------------------------------------------------------------------- /test/deps.js: -------------------------------------------------------------------------------- 1 | const tape = require('tape'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const browserify = require('browserify'); 5 | const through = require('through2'); 6 | const babelify = require('babelify'); 7 | 8 | const detect = require('../lib/detect-deps'); 9 | const detectGlobals = require('../lib/detect-global-deps'); 10 | 11 | tape('test dependency finder on ES5 commonJS', t => { 12 | const filename = path.resolve(__dirname, 'modules/es5.js'); 13 | const str = fs.readFileSync(filename, 'utf-8'); 14 | 15 | const results = detect(str, { filename }); 16 | t.deepEqual(results, [ 'WebGLRenderer', 17 | 'RGBFormat', 18 | 'LinearFilter', 19 | 'RGBAFormat', 20 | 'NearestFilter', 21 | 'SphereGeometry', 22 | 'BufferGeometry' ]); 23 | t.end(); 24 | }); 25 | 26 | tape('test dependency finder on ES6 import', t => { 27 | const filename = path.resolve(__dirname, 'modules/import.js'); 28 | const str = fs.readFileSync(filename, 'utf-8'); 29 | 30 | const results = detect(str, { filename }); 31 | t.deepEqual(results, [ 'Scene', 32 | 'Object3D', 33 | 'WebGLRenderer', 34 | 'LinearFilter', 35 | 'RGBAFormat', 36 | 'RGBFormat' ]); 37 | t.end(); 38 | }); 39 | 40 | tape('test dependency finder on common ThreeJS example code', t => { 41 | const filename = path.resolve(__dirname, 'modules/simple.js'); 42 | const str = fs.readFileSync(filename, 'utf-8'); 43 | 44 | const results = detect(str, { filename }); 45 | t.deepEqual(results, [ 'WebGLRenderer', 'Scene', 'PerspectiveCamera', 'Mesh', 'MeshNormalMaterial', 'SphereGeometry', 'Vector3' ]); 46 | t.end(); 47 | }); 48 | 49 | tape('test dependency finder on common ThreeJS example code after babel', t => { 50 | t.plan(1); 51 | 52 | const filename = path.resolve(__dirname, 'modules/simple.js'); 53 | const babelTransform = babelify.configure({ 54 | presets: [ 'babel-preset-es2015' ], 55 | sourceMaps: false 56 | }); 57 | const b = browserify(filename, { transform: [ babelTransform, transform ] }); 58 | b.exclude('three'); // just avoid bundling ThreeJS for speed 59 | 60 | b.bundle((err, src) => { 61 | if (err) return t.fail(err); 62 | }); 63 | 64 | function transform (file) { 65 | let code = ''; 66 | return through((chunk, enc, next) => { 67 | code += chunk; 68 | next(null, chunk); 69 | }, (cb) => { 70 | const results = detect(code, { filename: file }); 71 | t.deepEqual(results, [ 'WebGLRenderer', 72 | 'Scene', 73 | 'PerspectiveCamera', 74 | 'Vector3', 75 | 'SphereGeometry', 76 | 'Mesh', 77 | 'MeshNormalMaterial' ]); 78 | cb(); 79 | }); 80 | } 81 | }); 82 | 83 | tape('test dependency finder on ThreeJS examples', t => { 84 | const filename = path.resolve(__dirname, 'modules/GLTFLoader.js'); 85 | const str = fs.readFileSync(filename, 'utf-8'); 86 | 87 | const deps = ['GLTFLoader', 'DefaultLoadingManager', 'LoaderUtils', 'FileLoader', 'Color', 'DirectionalLight', 'PointLight', 'SpotLight', 'AmbientLight', 'MeshPhongMaterial', 'MeshLambertMaterial', 'MeshBasicMaterial', 'ShaderMaterial', 'ShaderLib', 'UniformsUtils', 'Interpolant', 'Matrix3', 'Matrix4', 'Vector2', 'Vector3', 'Vector4', 'Texture', 'NearestFilter', 'LinearFilter', 'NearestMipMapNearestFilter', 'LinearMipMapNearestFilter', 'NearestMipMapLinearFilter', 'LinearMipMapLinearFilter', 'ClampToEdgeWrapping', 'MirroredRepeatWrapping', 'RepeatWrapping', 'AlphaFormat', 'RGBFormat', 'RGBAFormat', 'LuminanceFormat', 'LuminanceAlphaFormat', 'UnsignedByteType', 'UnsignedShort4444Type', 'UnsignedShort5551Type', 'UnsignedShort565Type', 'BackSide', 'FrontSide', 'NeverDepth', 'LessDepth', 'EqualDepth', 'LessEqualDepth', 'GreaterEqualDepth', 'NotEqualDepth', 'AlwaysDepth', 'AddEquation', 'SubtractEquation', 'ReverseSubtractEquation', 'ZeroFactor', 'OneFactor', 'SrcColorFactor', 'OneMinusSrcColorFactor', 'SrcAlphaFactor', 'OneMinusSrcAlphaFactor', 'DstAlphaFactor', 'OneMinusDstAlphaFactor', 'DstColorFactor', 'OneMinusDstColorFactor', 'SrcAlphaSaturateFactor', 'InterpolateSmooth', 'InterpolateLinear', 'InterpolateDiscrete', 'MeshStandardMaterial', 'BufferAttribute', 'TextureLoader', 'InterleavedBuffer', 'InterleavedBufferAttribute', 'Loader', 'DoubleSide', 'sRGBEncoding', 'BufferGeometry', 'Group', 'VertexColors', 'SkinnedMesh', 'Mesh', 'TriangleStripDrawMode', 'TriangleFanDrawMode', 'LineBasicMaterial', 'Material', 'LineSegments', 'Line', 'LineLoop', 'PointsMaterial', 'Points', 'PerspectiveCamera', 'Math', 'OrthographicCamera', 'NumberKeyframeTrack', 'QuaternionKeyframeTrack', 'VectorKeyframeTrack', 'AnimationUtils', 'AnimationClip', 'Bone', 'Object3D', 'PropertyBinding', 'Skeleton', 'Scene']; 88 | t.deepEqual(detectGlobals(str), deps); 89 | t.end(); 90 | }); 91 | -------------------------------------------------------------------------------- /plugin.js: -------------------------------------------------------------------------------- 1 | const fromString = require('from2-string'); 2 | const path = require('path'); 3 | const through = require('through2'); 4 | const resolve = require('resolve'); 5 | const duplexer = require('duplexer2'); 6 | const concatStream = require('concat-stream'); 7 | 8 | const detectImports = require('./lib/detect-deps'); 9 | const detectGlobals = require('./lib/detect-global-deps'); 10 | const bundleThreeJS = require('./lib/bundle-three'); 11 | 12 | module.exports = function (bundler, opts = {}) { 13 | const basedir = opts.basedir || process.cwd(); 14 | const moduleName = opts.moduleName || 'three'; 15 | const globalName = opts.globalName || 'THREE'; 16 | 17 | // Whether we are importing each module, or just relying on THREE globally 18 | const isGlobalUsage = opts.l || opts.loose; 19 | const isInsertFront = typeof opts.isInsertFront === 'boolean' ? opts.isInsertFront : isGlobalUsage; 20 | 21 | // Probably a better way... meh 22 | const MAGIC_STRING = isInsertFront 23 | ? `module.exports = window.${globalName};` 24 | : '__$$MAGIC_THREEJS_TREE_SHAKE_REPLACEMENT_STRING$$__'; 25 | 26 | // Pretend the file comes from node_modules so things like babelify 27 | // won't try to transform it if non-global 28 | const generatedFile = 'node_modules/three/bin/three.js'; 29 | 30 | const threePath = opts.threePath || path.dirname(resolve.sync('three/package.json', { basedir })); 31 | const threeExamples = path.resolve(threePath, 'examples'); 32 | const baseNodeModules = path.resolve(basedir, 'node_modules'); 33 | 34 | // how far into node_modules we should look 35 | const isDeepSearch = opts.global || opts.g; // by default, this may be overkill 36 | const isExamplesSearch = opts.examples !== false; 37 | const global = isDeepSearch || isExamplesSearch; 38 | 39 | const ignoreDependencies = [].concat(opts.ignoreDependencies || []).filter(Boolean); 40 | const includeDependencies = [].concat(opts.includeDependencies || []).filter(Boolean); 41 | let allDependencies = []; 42 | const threeInput = fromString(MAGIC_STRING); 43 | bundler.exclude(moduleName); 44 | bundler.require(threeInput, { expose: moduleName, file: generatedFile }); 45 | bundler.transform(transform, { global }); 46 | 47 | bundler.on('reset', addHooks); 48 | addHooks(); 49 | 50 | function addHooks () { 51 | const fn = isInsertFront ? inserter : replacer; 52 | bundler.pipeline.get('wrap').push(fn()); 53 | } 54 | 55 | function transform (file, opt) { 56 | // Generated index 57 | if (file === generatedFile) { 58 | // Ignore generated 'three' module 59 | return through(); 60 | } 61 | // ignore JSON 62 | if (/\.json$/i.test(file)) { 63 | return through(); 64 | } 65 | 66 | const isExample = file.startsWith(threeExamples); 67 | const isDeep = file.startsWith(baseNodeModules); 68 | 69 | if (isExample) { 70 | // only detect in example if desired... 71 | return isExamplesSearch ? detect(file, true) : through(); 72 | } else if (isDeep) { 73 | // only detect in deep modules if desired, for perf 74 | return isDeepSearch ? detect(file, true) : through(); 75 | } else { 76 | // any other local modules, let's search them... 77 | return detect(file, isGlobalUsage); 78 | } 79 | } 80 | 81 | function replacer () { 82 | const output = through(); 83 | const input = concatStream(function (buf) { 84 | let bundleSrc = buf.toString(); 85 | getThreeSrc().then(threeSrc => { 86 | bundleSrc = bundleSrc.replace(MAGIC_STRING, () => threeSrc); 87 | output.push(bundleSrc); 88 | output.push(null); 89 | }); 90 | }); 91 | return duplexer(input, output); 92 | } 93 | 94 | function inserter () { 95 | let first = true; 96 | return through(function (chunk, enc, next) { 97 | if (first) { 98 | first = false; 99 | getThreeSrc().then(code => { 100 | this.push(Buffer.from(code)); 101 | this.push(chunk); 102 | next(null); 103 | }); 104 | } else { 105 | next(null, chunk); 106 | } 107 | }); 108 | } 109 | 110 | function getThreeSrc () { 111 | const entry = path.resolve(threePath, 'src/Index.js'); 112 | const generateOpts = Object.assign({}, opts, { 113 | basedir, 114 | threePath, 115 | generatedFile, 116 | sourcemap: false, 117 | outputFormat: 'umd', 118 | globalName 119 | }); 120 | // filter out unwanted deps 121 | allDependencies = allDependencies.filter(dep => { 122 | return !ignoreDependencies.includes(dep); 123 | }); 124 | // add additional deps 125 | includeDependencies.forEach(dep => { 126 | if (!allDependencies.includes(dep)) allDependencies.push(dep); 127 | }); 128 | if (opts.debug) { 129 | console.error('All ThreeJS dependencies:\n' + allDependencies.join('\n')); 130 | } 131 | return bundleThreeJS(entry, allDependencies, generateOpts) 132 | .then(result => { 133 | return result.code; 134 | }); 135 | } 136 | 137 | function detect (file, searchGlobalUsage) { 138 | var code = ''; 139 | return through((chunk, enc, next) => { 140 | code += chunk.toString(); 141 | next(null, chunk); 142 | }, next => { 143 | // Only run on code that has 'three' in it somewhere 144 | if (code && (code.includes(globalName) || code.includes(moduleName))) { 145 | gatherImports(file, code, searchGlobalUsage); 146 | } 147 | next(); 148 | }); 149 | } 150 | 151 | function gatherImports (file, code, searchGlobalUsage) { 152 | const opts = { 153 | filename: file, 154 | globalName, 155 | moduleName 156 | }; 157 | 158 | let dependencies; 159 | if (searchGlobalUsage) { 160 | dependencies = detectGlobals(code, opts); 161 | } else { 162 | dependencies = detectImports(code, opts); 163 | } 164 | 165 | dependencies.forEach(dep => { 166 | if (!allDependencies.includes(dep)) allDependencies.push(dep); 167 | }); 168 | } 169 | }; 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # threejs-tree-shake 2 | 3 | [![experimental](http://badges.github.io/stability-badges/dist/experimental.svg)](http://github.com/badges/stability-badges) 4 | 5 | A browserify plugin to tree-shake and optimizes a ThreeJS application. 6 | 7 | > :rotating_light: Still highly experimental and unstable, but feel free to try it out. Tested with browserify@15 and Three r89. 8 | 9 | This parses your source AST to find which ThreeJS modules your app actually uses. Then it runs [rollup](https://github.com/rollup/rollup) on the fly to generate a much smaller ThreeJS module. It's not ideal, and may break with future ThreeJS changes or in certain applications. 10 | 11 | After minification on a simple example app, the bundle size goes from 533 kB to 320 kB. Other apps may have more or less savings depending on how many modules you require. 12 | 13 | ## Quick Start 14 | 15 | This works with CommonJS `require` or relying on `THREE` as a global namespace. It also works with `import` statements, although typically you will transpile them with the `babelify` transform. 16 | 17 | Here is an example with CommonJS: 18 | 19 | ```js 20 | var THREE = require('three'); 21 | 22 | var renderer = new THREE.WebGLRenderer(); 23 | renderer.setPixelRatio(window.devicePixelRatio); 24 | renderer.setSize(256, 256); 25 | 26 | var scene = new THREE.Scene(); 27 | var camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100); 28 | camera.position.z = -4; 29 | camera.lookAt(new THREE.Vector3()); 30 | 31 | var geometry = new THREE.SphereGeometry(1, 32, 32); 32 | var mesh = new THREE.Mesh(geometry, new THREE.MeshNormalMaterial()); 33 | scene.add(mesh); 34 | 35 | renderer.render(scene, camera); 36 | 37 | document.body.appendChild(renderer.domElement); 38 | ``` 39 | 40 | Now, you will need to install the tool in your local repo. 41 | 42 | ```sh 43 | # make sure three is installed as a local dependency 44 | # this way, you can call require('three') 45 | npm install three --save 46 | 47 | # install the necessary tooling 48 | npm install browserify threejs-tree-shake --save-dev 49 | 50 | # run browserify to generate a final bundle 51 | npx browserify myApp.js -p threejs-tree-shake > bundle.js 52 | ``` 53 | 54 | > :bulb: In this case the `npx` command will run our locally-installed tools (i.e. within node_modules folder). 55 | 56 | The final bundle will be much smaller than usual since many ThreeJS modules will get discarded (e.g. various materials, constants, geometries, helpers, and legacy functions you won't need). 57 | 58 | ## Loose Search 59 | 60 | In many cases you will have a ThreeJS app that doesn't `import` or `require` ThreeJS in every file (e.g. if you are using a `