├── .babelrc ├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.yaml ├── .stylelintrc.json ├── .travis.yml ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── __mocks__ ├── fileMock.js └── styleMock.js ├── __tests__ └── application.js ├── config ├── README.md ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ └── application │ │ └── app.spec.js ├── plugins │ └── index.js ├── support │ ├── commands.js │ └── index.js └── utils.js ├── demo.gif ├── glslTransformer.js ├── jest.config.js ├── package.json ├── renovate.json ├── setupJestDomTests.js ├── src ├── css │ ├── README.md │ ├── components │ │ ├── README.md │ │ ├── footer.css │ │ ├── index.css │ │ ├── messages.css │ │ ├── navbar.css │ │ ├── tooltip.css │ │ └── transfer-demo-grid.css │ ├── debug-stack.css │ ├── debug.css │ ├── defaults.css │ ├── font-face-rules.css │ ├── helpers.css │ ├── index.css │ ├── layouts │ │ ├── README.md │ │ ├── box.css │ │ ├── cluster.css │ │ ├── index.css │ │ └── stack.css │ ├── media-queries.css │ ├── typography.css │ └── variables.css ├── fonts │ └── Syne │ │ ├── README.md │ │ ├── Syne-Extra.ttf │ │ ├── Syne-Extra.woff │ │ ├── Syne-Extra.woff2 │ │ ├── Syne-Regular.ttf │ │ ├── Syne-Regular.woff │ │ └── Syne-Regular.woff2 ├── glsl │ ├── README.md │ ├── fragmentShader.glsl │ └── vertexShader.glsl ├── html │ ├── documents │ │ ├── 404.html │ │ ├── README.md │ │ ├── about.html │ │ ├── index.html │ │ ├── offscreen-bitmaprenderer.html │ │ └── offscreen-transfer.html │ └── fragments │ │ ├── README.md │ │ ├── footer.html │ │ ├── head.html │ │ └── navbar.html ├── js │ ├── 404.js │ ├── README.md │ ├── about.js │ ├── application.js │ ├── bitmap-demo.js │ ├── components │ │ └── navbar.js │ ├── constants.js │ ├── helpers.js │ ├── index.js │ ├── transfer-demo.js │ ├── vendor │ │ ├── Detector.js │ │ ├── OBJLoader2.js │ │ ├── dat.gui.min.js │ │ └── obj2 │ │ │ ├── shared │ │ │ ├── MaterialHandler.js │ │ │ └── MeshReceiver.js │ │ │ └── worker │ │ │ └── parallel │ │ │ └── OBJLoader2Parser.js │ ├── worker-actions.js │ └── workers │ │ ├── bitmap-worker.js │ │ └── transfer-worker.js ├── models │ ├── README.md │ └── male02.obj └── textures │ ├── checkerboard.jpg │ └── star.png └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "corejs": "3.0.0", 7 | "useBuiltIns": "entry" 8 | } 9 | ] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | not dead -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{css,html,js}] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/js/vendor -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:prettier/recommended"], 3 | "parserOptions": { 4 | "ecmaVersion": 6, 5 | "sourceType": "module" 6 | }, 7 | "rules": { 8 | "require-jsdoc": "warn" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/* 2 | # unignore the favicon 3 | !/build/favicon.ico 4 | 5 | # dependencies 6 | /node_modules/ 7 | 8 | # tests results 9 | /coverage/ 10 | /cypress/videos/ 11 | 12 | *.log 13 | 14 | # environment variables 15 | .envrc 16 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 11.10.1 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | node_modules/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | printWidth: 80 2 | semi: true 3 | singleQuote: false 4 | trailingComma: "es5" 5 | tabWidth: 2 6 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard"], 3 | "rules": { 4 | "unit-whitelist": ["em", "rem", "vh", "vw", "%"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - "lts/*" 5 | - "stable" 6 | cache: yarn 7 | install: 8 | - yarn install 9 | - yarn global add codecov 10 | script: 11 | - yarn run ci 12 | after_success: 13 | - codecov 14 | notifications: 15 | email: 16 | on_success: change 17 | on_failure: always 18 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "christian-kohler.npm-intellisense", 4 | "davidanson.vscode-markdownlint", 5 | "EditorConfig.EditorConfig", 6 | "eg2.vscode-npm-script", 7 | "esbenp.prettier-vscode", 8 | "joelday.docthis", 9 | "mgmcdermott.vscode-language-babel", 10 | "slevesque.shader", 11 | "shinnn.stylelint" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.rulers": [ 4 | 80, 5 | 120 6 | ], 7 | "files.exclude": { 8 | "node_modules/": true 9 | } 10 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 jackaljack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # three.js-es6-webpack-starter 2 | 3 | [![Build Status](https://travis-ci.org/jackdbd/threejs-es6-webpack-starter.svg?branch=master)](https://travis-ci.org/jackdbd/threejs-es6-webpack-starter) [![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://renovateapp.com/) [![Code style prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 4 | 5 | Three.js ES6 starter project with a sane webpack configuration. 6 | 7 | ![A GIF file showing a demo of the starter project](https://github.com/jackdbd/threejs-es6-webpack-starter/blob/main/demo.gif?raw=true "A scene with a spotlight, a directional light, an ambient light, a particle system, a custom material and several helpers.") 8 | 9 | ## Features 10 | 11 | - ES6 support with [babel-loader](https://github.com/babel/babel-loader) 12 | - JS linting + code formatting with [eslint](https://eslint.org/) and [prettier](https://github.com/prettier/prettier) 13 | - Offscreen canvas rendering in a web worker with [OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas) 14 | - CSS support with [style-loader](https://github.com/webpack-contrib/style-loader) 15 | and [css-loader](https://github.com/webpack-contrib/css-loader) 16 | - CSS linting with [stylelint](https://stylelint.io/) 17 | - Controls with [orbit-controls-es6](https://www.npmjs.com/package/orbit-controls-es6) 18 | - GUI with [dat.GUI](https://github.com/dataarts/dat.gui) 19 | - Tests with [jest](https://jestjs.io/en/) 20 | - Webpack configuration with: 21 | - [@packtracker/webpack-plugin](https://github.com/packtracker/webpack-plugin) (bundle sizes [here](https://app.packtracker.io/organizations/129/projects/110)) 22 | - [circular-dependency-plugin](https://github.com/aackerman/circular-dependency-plugin) 23 | - [clean-webpack-plugin](https://github.com/johnagan/clean-webpack-plugin) 24 | - [compression-webpack-plugin](https://github.com/webpack-contrib/compression-webpack-plugin) 25 | - [duplicate-package-checker-webpack-plugin](https://github.com/darrenscerri/duplicate-package-checker-webpack-plugin) 26 | - [favicons-webpack-plugin](https://github.com/jantimon/favicons-webpack-plugin) 27 | - [html-webpack-plugin](https://github.com/jantimon/html-webpack-plugin) 28 | - [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) 29 | - [speed-measure-webpack-plugin](https://github.com/stephencookdev/speed-measure-webpack-plugin) 30 | - [terser-webpack-plugin](https://github.com/webpack-contrib/terser-webpack-plugin/) 31 | - [webpack-bundle-analyzer](https://github.com/th0r/webpack-bundle-analyzer) 32 | - [webpack-glsl-loader](https://github.com/grieve/webpack-glsl-loader) 33 | - [worker-loader](https://github.com/webpack-contrib/worker-loader) 34 | 35 | ## Installation 36 | 37 | ```shell 38 | git clone git@github.com:jackdbd/threejs-es6-webpack-starter.git 39 | cd threejs-es6-webpack-starter 40 | yarn 41 | ``` 42 | 43 | ## Usage (development) 44 | 45 | Run `webpack-dev-server` (js/css bundles will be served from memory) 46 | 47 | ```shell 48 | yarn start 49 | ``` 50 | 51 | Go to `localhost:8080` to see your project live! 52 | 53 | ## Usage (production) 54 | 55 | Generate all js/css bundles 56 | 57 | ```shell 58 | yarn build 59 | ``` 60 | 61 | ## Other 62 | 63 | Analyze webpack bundles offline: 64 | 65 | ```shell 66 | yarn build # to generate build/stats.json 67 | yarn stats # uses webpack-bundle-analyzer as CLI 68 | ``` 69 | 70 | or push to a CI (e.g. [Travis CI](https://travis-ci.com/)), let it build your project and analyze your bundles online at [packtracker.io](https://packtracker.io/). 71 | 72 | Check outdated dependencies with [npm-check-updates](https://github.com/tjunnone/npm-check-updates): 73 | 74 | ```shell 75 | yarn ncu 76 | ``` 77 | 78 | Update all outdated dependencies at once: 79 | 80 | ```shell 81 | yarn ncuu 82 | ``` 83 | 84 | Or let [updtr](https://github.com/peerigon/updtr) update all your dependencies for you: 85 | 86 | ```shell 87 | yarn updtr 88 | ``` 89 | 90 | ## Credits 91 | 92 | The setup of this starter project was inspired by two snippets on Codepen: [this one](http://codepen.io/mo4_9/pen/VjqRQX) and [this one](https://codepen.io/iamphill/pen/jPYorE). 93 | 94 | I understood how to work with lights and camera helpers thanks to 95 | [this snippet](http://jsfiddle.net/f17Lz5ux/5131/) on JSFiddle. 96 | 97 | The code for `vertexShader.glsl` and `fragmentShader.glsl` is taken from 98 | [this blog post](http://blog.cjgammon.com/threejs-custom-shader-material). 99 | 100 | The star used in the particle system is the PNG preview of [this image](https://commons.wikimedia.org/wiki/File:Star_icon-72a7cf.svg) by Offnfopt 101 | (Public domain or CC0, via Wikimedia Commons). 102 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = "test-file-stub"; 2 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /__tests__/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from "../src/js/application"; 2 | 3 | const prepareDOM = () => { 4 | const main = document.createElement("main"); 5 | const figure = document.createElement("figure"); 6 | const outerDiv = document.createElement("div"); 7 | outerDiv.classList.add("canvas-container-outer"); 8 | const innerDiv = document.createElement("div"); 9 | innerDiv.setAttribute("class", "canvas-container-inner"); 10 | outerDiv.appendChild(innerDiv); 11 | figure.appendChild(outerDiv); 12 | main.appendChild(figure); 13 | document.body.appendChild(main); 14 | }; 15 | 16 | describe("Three.js application", () => { 17 | let windowAlert; 18 | 19 | beforeAll(() => { 20 | // alert is not available in Jest DOM, so we provide a mock implementation. 21 | windowAlert = jest.spyOn(window, "alert"); 22 | windowAlert.mockImplementation(() => {}); 23 | }); 24 | 25 | beforeEach(() => { 26 | prepareDOM(); 27 | }); 28 | 29 | afterEach(() => { 30 | windowAlert.mockReset(); 31 | // Remove all body's children to make sure the tests are independent 32 | const body = document.querySelector("body"); 33 | while (body.firstChild) { 34 | body.removeChild(body.firstChild); 35 | } 36 | }); 37 | 38 | it("shows an error message when WebGL is not supported", () => { 39 | new Application(); 40 | const container = document.querySelector("main .canvas-container-inner"); 41 | const el = container.firstChild; 42 | expect(el.id).toBe("webgl-error-message"); 43 | const message = 44 | "Your browser does not seem to support WebGL. Find out how to get it here."; 45 | expect(el).toHaveTextContent(message); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /config/README.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | Webpack configuration files for `development` and `production` mode. 4 | -------------------------------------------------------------------------------- /config/webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const CircularDependencyPlugin = require("circular-dependency-plugin"); 3 | const { DefinePlugin } = require("webpack"); 4 | const DuplicatePackageCheckerPlugin = require("duplicate-package-checker-webpack-plugin"); 5 | const FaviconsWebpackPlugin = require("favicons-webpack-plugin"); 6 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 7 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 8 | const SpeedMeasurePlugin = require("speed-measure-webpack-plugin"); 9 | const WorkerPlugin = require("worker-plugin"); 10 | 11 | // The path used in each rule is resolved starting from `context`. 12 | // GOTCHA: use path.resolve for the path to the source files, and path.join for 13 | // the output files. 14 | const rules = [ 15 | // Rule for html documents and document fragments 16 | { 17 | test: /\.html$/, 18 | include: [path.resolve("src", "html")], 19 | loader: "html-loader", 20 | options: { 21 | // Required to use expressions in HTML documents and fragments. 22 | // https://webpack.js.org/loaders/html-loader/#interpolate 23 | interpolate: true, 24 | minimize: { 25 | removeComments: true, 26 | }, 27 | }, 28 | }, 29 | // Rule for JS files. Web workers are bundled by worker-plugin. 30 | { 31 | test: /\.(js|jsx)$/, 32 | include: [path.resolve("src", "js")], 33 | exclude: [ 34 | path.resolve("node_modules"), 35 | path.resolve("src", "js", "workers"), 36 | ], 37 | use: { 38 | loader: "babel-loader", 39 | }, 40 | }, 41 | // Rule for stylesheets (.sass, .scss, .css) 42 | { 43 | test: /\.(sa|sc|c)ss$/, 44 | include: [path.resolve("src", "css")], 45 | use: [ 46 | { 47 | loader: MiniCssExtractPlugin.loader, 48 | options: { 49 | // avoid using CSS modules 50 | modules: false, 51 | sourceMap: true, 52 | }, 53 | }, 54 | "css-loader", 55 | "sass-loader", 56 | ], 57 | }, 58 | // Rule for shaders 59 | { 60 | test: /\.glsl$/, 61 | use: [ 62 | { 63 | loader: "webpack-glsl-loader", 64 | }, 65 | ], 66 | }, 67 | // Rule for font files 68 | { 69 | test: /\.(ttf|woff|woff2)(\?v=[0-9]\.[0-9]\.[0-9])?$/, 70 | include: [path.resolve("src", "fonts")], 71 | use: { 72 | loader: "file-loader", 73 | options: { 74 | name: path.join("fonts", "[name].[ext]"), 75 | }, 76 | }, 77 | }, 78 | // Rule for 3D model files 79 | { 80 | test: /\.(obj)$/, 81 | include: [path.resolve("src", "models")], 82 | use: { 83 | loader: "file-loader", 84 | options: { 85 | name: path.join("models", "[name].[ext]"), 86 | }, 87 | }, 88 | }, 89 | // Rule for textures (images) 90 | { 91 | test: /.(jpe?g|png)$/i, 92 | include: path.resolve("src", "textures"), 93 | loaders: [ 94 | { 95 | loader: "file-loader", 96 | options: { 97 | name: path.join("textures", "[name].[hash].[ext]"), 98 | }, 99 | }, 100 | { 101 | loader: "image-webpack-loader", 102 | query: { 103 | gifsicle: { 104 | interlaced: false, 105 | }, 106 | mozjpeg: { 107 | progressive: true, 108 | quality: 85, 109 | }, 110 | pngquant: { 111 | quality: [0.65, 0.9], 112 | speed: 4, 113 | }, 114 | }, 115 | }, 116 | ], 117 | }, 118 | ]; 119 | 120 | /** 121 | * Webpack config shared between development and production environments. 122 | */ 123 | const commonConfigFn = (env = {}, argv = {}) => { 124 | if (!env.hasOwnProperty("publicUrl")) { 125 | throw new Error("env must have the `publicUrl` property"); 126 | } 127 | if (!argv.hasOwnProperty("mode")) { 128 | throw new Error( 129 | "argv must have the `mode` property ('development' or 'production')" 130 | ); 131 | } 132 | console.log(`Prepare ${argv.mode.toUpperCase()} build`); 133 | 134 | const APP_NAME = `Three.js ES6 Webpack 4 Project Starter (${argv.mode})`; 135 | const PUBLIC_URL = env.publicUrl; 136 | 137 | // In production html-webpack-plugin should automatically minify HTML 138 | // documents with html-minifier-terser and these parameters, but it doesn't. 139 | // Setting minify to true also does not work for me; only setting minify as an 140 | // object does. 141 | // https://github.com/jantimon/html-webpack-plugin#minification 142 | const minify = 143 | argv.mode === "production" 144 | ? { 145 | collapseWhitespace: true, 146 | removeComments: true, 147 | removeRedundantAttributes: true, 148 | removeScriptTypeAttributes: true, 149 | removeStyleLinkTypeAttributes: true, 150 | useShortDoctype: true, 151 | } 152 | : false; 153 | 154 | const plugins = [ 155 | new CircularDependencyPlugin({ 156 | exclude: /node_modules/, 157 | failOnError: true, 158 | }), 159 | new DefinePlugin({ 160 | APP_NAME: JSON.stringify(APP_NAME), 161 | AUTHOR: JSON.stringify("Giacomo Debidda"), 162 | PUBLIC_URL: JSON.stringify(PUBLIC_URL), 163 | }), 164 | new DuplicatePackageCheckerPlugin({ 165 | emitError: false, 166 | showHelp: true, 167 | strict: false, 168 | verbose: true, 169 | }), 170 | new HtmlWebpackPlugin({ 171 | chunks: ["commons", "home", "runtime", "styles", "vendor"], 172 | filename: "index.html", 173 | hash: false, 174 | minify, 175 | template: path.resolve("src", "html", "documents", "index.html"), 176 | }), 177 | new HtmlWebpackPlugin({ 178 | chunks: ["bitmap-demo", "commons", "runtime", "styles", "vendor"], 179 | filename: "offscreen-bitmaprenderer.html", 180 | hash: false, 181 | minify, 182 | template: path.resolve( 183 | "src", 184 | "html", 185 | "documents", 186 | "offscreen-bitmaprenderer.html" 187 | ), 188 | }), 189 | new HtmlWebpackPlugin({ 190 | chunks: ["commons", "runtime", "styles", "transfer-demo", "vendor"], 191 | filename: "offscreen-transfer.html", 192 | hash: false, 193 | minify, 194 | template: path.resolve( 195 | "src", 196 | "html", 197 | "documents", 198 | "offscreen-transfer.html" 199 | ), 200 | }), 201 | new HtmlWebpackPlugin({ 202 | chunks: ["about", "commons", "runtime", "styles"], 203 | filename: "about.html", 204 | hash: false, 205 | minify, 206 | template: path.resolve("src", "html", "documents", "about.html"), 207 | }), 208 | new HtmlWebpackPlugin({ 209 | chunks: ["404", "commons", "runtime", "styles"], 210 | filename: "404.html", 211 | hash: false, 212 | minify, 213 | template: path.resolve("src", "html", "documents", "404.html"), 214 | }), 215 | // html-webpack-plugin must come BEFORE favicons-webpack-plugin in the 216 | // plugins array. 217 | // https://github.com/jantimon/favicons-webpack-plugin#html-injection 218 | new FaviconsWebpackPlugin({ 219 | // `inject: true` seems not working, so I have a document fragment 220 | // where I manually reference the public path to the favicons. 221 | inject: false, 222 | logo: path.resolve("src", "textures", "star.png"), 223 | prefix: "favicons/", 224 | title: APP_NAME, 225 | }), 226 | new MiniCssExtractPlugin({ 227 | chunkFilename: "[name].[contenthash].css", 228 | filename: "[name].[contenthash].css", 229 | }), 230 | // Create a bundle for each web worker. This is cool, but at the moment it 231 | // seems not possible to use a fixed name for this bundle. It would be 232 | // useful because with a fixed filename we could inject a resource hint like 233 | // 234 | // in the template . Keep in mind that if the name has a hash (for 235 | // cache busting) we need something like WebpackManifestPlugin to find the 236 | // generated filename. 237 | // https://github.com/GoogleChromeLabs/worker-plugin/issues/19 238 | // https://web.dev/module-workers/#preload-workers-with-modulepreload 239 | new WorkerPlugin(), 240 | ]; 241 | 242 | const config = { 243 | context: path.resolve(__dirname, ".."), 244 | 245 | // The path to each entry point is resolved starting from `context`. 246 | entry: { 247 | "404": path.resolve("src", "js", "404.js"), 248 | about: path.resolve("src", "js", "about.js"), 249 | "bitmap-demo": path.resolve("src", "js", "bitmap-demo.js"), 250 | home: path.resolve("src", "js", "index.js"), 251 | "transfer-demo": path.resolve("src", "js", "transfer-demo.js"), 252 | }, 253 | mode: argv.mode, 254 | module: { 255 | rules, 256 | }, 257 | output: { 258 | // For HTTP cache busting we want an hash to appear in the asset filename. 259 | // We could use html-webpack-plugin `hash: true` to have the hash added as 260 | // a query string. However, `some-file.some-hash.js` is a better idea than 261 | // `some-file.js?some-hash`. Here is why: 262 | // http://www.stevesouders.com/blog/2008/08/23/revving-filenames-dont-use-querystring/ 263 | // So we add the hash in the filename here and use `hash: false` with 264 | // html-webpack-plugin. 265 | filename: "[name].[hash].js", 266 | // The output path is resolved starting from `context` 267 | path: path.resolve("build"), 268 | // publicPath: "/", 269 | sourceMapFilename: "[file].map", 270 | }, 271 | plugins, 272 | performance: { 273 | assetFilter: assetFilename => { 274 | // Silence warnings for big source maps (default) and font files. 275 | // To reduce .ttf file size, check the link below. 276 | // https://www.cnx-software.com/2010/02/19/reducing-truetype-font-file-size-for-embedded-systems/ 277 | return !/\.map$/.test(assetFilename) && !assetFilename.endsWith(".ttf"); 278 | }, 279 | hints: "warning", 280 | }, 281 | resolve: { 282 | alias: { 283 | // orbit-controls-es6 declares a version of three different from the one 284 | // used by this application. This would cause three to be duplicated in 285 | // the bundle. One way to avoid this issue is to use resolve.alias. 286 | // With resolve.alias we are telling Webpack to route any package 287 | // references to a single specified path. 288 | // Note: Aliasing packages with different major versions may break your 289 | // app. Use only if you're sure that all required versions are 290 | // compatible, at least in the context of your app 291 | // https://github.com/darrenscerri/duplicate-package-checker-webpack-plugin#resolving-duplicate-packages-in-your-bundle 292 | three: path.resolve("node_modules", "three"), 293 | }, 294 | extensions: [".js"], 295 | }, 296 | target: "web", 297 | }; 298 | 299 | // console.log("=== Webpack config ===", config); 300 | const smp = new SpeedMeasurePlugin({ 301 | // granularLoaderData: true, 302 | }); 303 | 304 | return smp.wrap(config); 305 | }; 306 | 307 | module.exports = { 308 | commonConfigFn, 309 | }; 310 | -------------------------------------------------------------------------------- /config/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { HotModuleReplacementPlugin } = require("webpack"); 2 | const merge = require("webpack-merge"); 3 | 4 | const { commonConfigFn } = require("./webpack.common"); 5 | 6 | const devServer = { 7 | compress: true, 8 | host: "localhost", 9 | inline: true, 10 | open: true, 11 | // openPage: ["bitmap-canvas-demo.html", "transfer-canvas-demo.html"], 12 | openPage: "index.html", 13 | port: 8080, 14 | stats: { 15 | chunks: false, 16 | colors: true, 17 | modules: false, 18 | reasons: true, 19 | }, 20 | // writeToDisk: true, 21 | }; 22 | 23 | module.exports = (env = {}, argv = {}) => { 24 | const devEnv = Object.assign(env, { publicUrl: "" }); 25 | const devArgv = Object.assign(argv, { mode: "development" }); 26 | 27 | const plugins = [new HotModuleReplacementPlugin()]; 28 | 29 | const finalWebpackConfig = merge(commonConfigFn(devEnv, argv), { 30 | devServer, 31 | devtool: "cheap-source-map", 32 | mode: devArgv.mode, 33 | plugins, 34 | }); 35 | // console.log("=== finalWebpackConfig ===", finalWebpackConfig); 36 | return finalWebpackConfig; 37 | }; 38 | -------------------------------------------------------------------------------- /config/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const merge = require("webpack-merge"); 3 | const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); 4 | const { CleanWebpackPlugin } = require("clean-webpack-plugin"); 5 | const CompressionPlugin = require("compression-webpack-plugin"); 6 | const ManifestPlugin = require("webpack-manifest-plugin"); 7 | const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); 8 | const PacktrackerPlugin = require("@packtracker/webpack-plugin"); 9 | const { ProgressPlugin } = require("webpack"); 10 | const TerserPlugin = require("terser-webpack-plugin"); 11 | 12 | const { commonConfigFn } = require("./webpack.common"); 13 | 14 | const optimization = { 15 | minimizer: [ 16 | // Minify JS 17 | new TerserPlugin({ 18 | // Enable file caching (doesn't work with webpack 5) 19 | cache: true, 20 | extractComments: true, 21 | // Use multi-process parallel running (speeds up the build) 22 | parallel: true, 23 | // Use source maps to map error message locations to modules (slows down the build) 24 | sourceMap: true, 25 | }), 26 | // Minify CSS. Strangely enough, I need to re-instantiate this plugin in the 27 | // plugins section. 28 | // optimize-css-assets-webpack-plugin uses cssnano as css processor 29 | new OptimizeCSSAssetsPlugin({ 30 | cssProcessor: require("cssnano"), 31 | // https://cssnano.co/optimisations/ 32 | cssProcessorPluginOptions: { 33 | preset: ["default", { discardComments: { removeAll: true } }], 34 | }, 35 | }), 36 | ], 37 | // https://webpack.js.org/configuration/optimization/#optimizationmoduleids 38 | // moduleIds: "hashed", 39 | // Creates a single chunk that contains the Webpack's runtime and it is shared 40 | // among all generated chunks. 41 | // https://webpack.js.org/configuration/optimization/#optimizationruntimechunk 42 | runtimeChunk: "single", 43 | // Configuration for SplitChunksPlugin (webpack internal plugin). 44 | // https://webpack.js.org/plugins/split-chunks-plugin/#split-chunks-example-2 45 | splitChunks: { 46 | cacheGroups: { 47 | // Do not enforce the creation of the chunks belonging to the `commons` 48 | // cacheGroup, but create a chunk for this cachGroup only if it is shared 49 | // by 2+ chunks, no matter how small this chunk is. 50 | // https://webpack.js.org/plugins/split-chunks-plugin/#splitchunksminsize 51 | commons: { 52 | minChunks: 2, 53 | minSize: 0, 54 | // https://webpack.js.org/plugins/split-chunks-plugin/#splitchunksname 55 | name: "commons", 56 | priority: 0, 57 | // It would be cool to indicate in this chunk's name the name of the 58 | // chunks where it is used, but this would make the configuration of 59 | // HtmlWebpackPlugin more tedious and hard to maintain. 60 | // name(module, chunks, cacheGroupKey) { 61 | // const moduleFileName = module 62 | // .identifier() 63 | // .split("/") 64 | // .reduceRight(item => item); 65 | // const allChunksNames = chunks.map(item => item.name).join("~"); 66 | // return `${cacheGroupKey}-[${allChunksNames}]-${moduleFileName}`; 67 | // }, 68 | test: /\.js$/, 69 | }, 70 | // Most of the pages use the same stylesheets, so enforce the creation of 71 | // a single chunk for all CSS files. 72 | // https://webpack.js.org/plugins/mini-css-extract-plugin/#extracting-all-css-in-a-single-file 73 | styles: { 74 | enforce: true, 75 | name: "styles", 76 | priority: 10, 77 | test: /\.css$/, 78 | }, 79 | // Enforce the creation of a single chunk for vendor code (vendor), so 80 | // it's easier to visually inspect how big our dependencies are (e.g. by 81 | // using webpack-bundle-analyzer). 82 | vendor: { 83 | enforce: true, 84 | name: "vendor", 85 | priority: 20, 86 | // test: /[\\/]node_modules[\\/]/, 87 | test(module, chunks) { 88 | // `module.resource` contains the absolute path of the file on disk. 89 | // Note the usage of `path.sep` instead of / or \, for cross-platform 90 | // compatibility. 91 | // console.log("=== module.resource ===", module.resource); 92 | return ( 93 | module.resource && 94 | module.resource.endsWith(".js") && 95 | (module.resource.includes(`${path.sep}node_modules${path.sep}`) || 96 | module.resource.includes("vendor")) 97 | ); 98 | }, 99 | }, 100 | }, 101 | // Include all types of chunks (async and non-async chunks). 102 | // https://webpack.js.org/plugins/split-chunks-plugin/#splitchunkschunks 103 | chunks: "all", 104 | // It is recommended to set splitChunks.name to false for production builds 105 | // so that it doesn't change names unnecessarily. 106 | name: false, 107 | }, 108 | }; 109 | 110 | module.exports = (env = {}, argv = {}) => { 111 | const prodEnv = Object.assign(env, { 112 | publicUrl: "https://jackdbd.github.io/threejs-es6-webpack-starter", 113 | }); 114 | const prodArgv = Object.assign(argv, { mode: "production" }); 115 | 116 | const plugins = [ 117 | new CleanWebpackPlugin({ 118 | cleanStaleWebpackAssets: true, 119 | verbose: true, 120 | }), 121 | new CompressionPlugin({ 122 | algorithm: "gzip", 123 | filename: "[path].gz[query]", 124 | test: /\.(css|html|js|svg|ttf)$/, 125 | threshold: 10240, 126 | minRatio: 0.8, 127 | }), 128 | new CompressionPlugin({ 129 | algorithm: "brotliCompress", 130 | compressionOptions: { level: 11 }, 131 | filename: "[path].br[query]", 132 | test: /\.(css|html|js|svg|ttf)$/, 133 | threshold: 10240, 134 | minRatio: 0.8, 135 | }), 136 | new ProgressPlugin({ 137 | activeModules: true, 138 | profile: true, 139 | }), 140 | new OptimizeCSSAssetsPlugin({ 141 | cssProcessor: require("cssnano"), 142 | cssProcessorPluginOptions: { 143 | preset: ["default", { discardComments: { removeAll: true } }], 144 | }, 145 | }), 146 | new BundleAnalyzerPlugin({ 147 | analyzerMode: "disabled", 148 | generateStatsFile: true, 149 | statsFilename: "stats.json", 150 | }), 151 | new PacktrackerPlugin({ 152 | // https://docs.packtracker.io/faq#why-cant-the-plugin-determine-my-branch-name 153 | branch: process.env.TRAVIS_BRANCH, 154 | fail_build: true, 155 | project_token: "2464bed1-d810-4af6-a615-877420f902b2", 156 | // upload stats.json only in CI 157 | upload: process.env.CI === "true", 158 | }), 159 | new ManifestPlugin(), 160 | ]; 161 | 162 | const finalWebpackConfig = merge(commonConfigFn(prodEnv, prodArgv), { 163 | devtool: "source-map", 164 | mode: prodArgv.mode, 165 | optimization, 166 | plugins, 167 | }); 168 | // console.log("=== finalWebpackConfig ===", finalWebpackConfig.plugins[8]); 169 | return finalWebpackConfig; 170 | }; 171 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:8080", 3 | "env": { 4 | "test-environment-variable": "123" 5 | }, 6 | "projectId": "7trrtp" 7 | } 8 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/integration/application/app.spec.js: -------------------------------------------------------------------------------- 1 | import { dragFromTo, zoomInAt } from "../../utils"; 2 | 3 | describe("Three.js application", () => { 4 | const viewportWidth = 800; 5 | const viewportHeight = 600; 6 | 7 | beforeEach(() => { 8 | cy.visit("/"); 9 | cy.viewport(viewportWidth, viewportHeight); 10 | // console.log( 11 | // "test-environment-variable", 12 | // Cypress.env("test-environment-variable") 13 | // ); 14 | // console.log("browser", Cypress.browser); 15 | }); 16 | 17 | context("Link to About page", () => { 18 | const selector = "[data-cy=link-to-about-page]"; 19 | it("is in the DOM", () => { 20 | cy.get(selector) 21 | .invoke("attr", "href") 22 | .should("contain", "about"); 23 | }); 24 | 25 | it("navigates to about.html if clicked ", () => { 26 | cy.get(selector).click(); 27 | cy.url().should("include", "about.html"); 28 | }); 29 | }); 30 | 31 | context("Tooltip", () => { 32 | const selector = "[data-cy=tooltip]"; 33 | it("exists but it is invisible at the startup", () => { 34 | cy.get(selector) 35 | .should("exist") 36 | .and("be.hidden"); 37 | }); 38 | }); 39 | 40 | context("WebGL canvas' container", () => { 41 | const selector = "[data-cy=canvas-container]"; 42 | 43 | it("exists and it is visible", () => { 44 | cy.get(selector) 45 | .should("exist") 46 | .and("be.visible"); 47 | }); 48 | 49 | it("renders the view from the top after a mouse drag towards the bottom", () => { 50 | dragFromTo(selector, [100, 100], [100, 400]); 51 | }); 52 | 53 | it("zooms in the cube in the center", () => { 54 | const x = viewportWidth / 2; 55 | const y = viewportHeight / 2; 56 | 57 | const mousemoveOptions = { 58 | clientX: x, 59 | clientY: y, 60 | }; 61 | 62 | const wheelOptions = { 63 | clientX: x, 64 | clientY: y, 65 | deltaX: 0, 66 | deltaY: -53, 67 | deltaZ: 0, 68 | deltaMode: 0, 69 | }; 70 | 71 | // TODO: I tried to assert that when mousemove-ing on the cube, the 72 | // tooltip becomes visible 73 | cy.get(selector) 74 | .trigger("mousemove", mousemoveOptions) 75 | .trigger("wheel", wheelOptions) 76 | .trigger("mousemove", mousemoveOptions); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/utils.js: -------------------------------------------------------------------------------- 1 | export const dragFromTo = (selector, fromPos, toPos) => { 2 | const [x0, y0] = fromPos; 3 | const [x1, y1] = toPos; 4 | cy.get(selector) 5 | .trigger("mousedown", { button: 0, buttons: 1, clientX: x0, clientY: y0 }) 6 | .trigger("mousemove", { button: 0, buttons: 1, clientX: x1, clientY: y1 }) 7 | .trigger("mouseup", { button: 0, buttons: 0 }); 8 | }; 9 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackdbd/threejs-es6-webpack-starter/e4b00bbbfdb93016fbd486ec4a4b3456587b61fd/demo.gif -------------------------------------------------------------------------------- /glslTransformer.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | /** 4 | * Transformer for `.glsl` files. 5 | * 6 | * A jest transformer is a module that provides a synchronous function for 7 | * transforming source files. 8 | * In jest tests we don't use the original `.glsl` files used in the app (like 9 | * we don't any other static asset such as `css` stylesheets and `png` images). 10 | * 11 | * To recap, in order to handle any static asset in jest we have two options: 12 | * 13 | * 1) mock the original file by providing a file stub (specified in the 14 | * `moduleNameMapper` config option); 15 | * 2) mock the original file by transforming it using a trasformer (specified in 16 | * the `transform` config option). 17 | * 18 | * @see https://jestjs.io/docs/en/configuration#transform-object-string-pathtotransformer-pathtotransformer-object 19 | */ 20 | const transformer = { 21 | process(src, filename, jestConfig, options) { 22 | const baseFilename = JSON.stringify(path.basename(filename)); 23 | const msg = `${baseFilename} was transformed because it matched a file pattern specified in jest config transform and jest automock was set to ${jestConfig.automock}.`; 24 | console.log(msg); 25 | return `module.exports = ${baseFilename};`; 26 | }, 27 | }; 28 | 29 | module.exports = transformer; 30 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | automock: false, 3 | collectCoverage: true, 4 | collectCoverageFrom: ["/src/js/*.{js,ts}"], 5 | coverageDirectory: "/coverage/", 6 | /* 7 | * Configure Jest to provide stubs for static assets such as stylesheets and 8 | * images. Usually, these files aren't particularly useful in tests so we can 9 | * mock them out. 10 | */ 11 | moduleNameMapper: { 12 | "\\.(jpg|jpeg|png)$": "/__mocks__/fileMock.js", 13 | "\\.(css)$": "/__mocks__/styleMock.js", 14 | }, 15 | modulePathIgnorePatterns: [ 16 | "/build/", 17 | "/coverage/", 18 | "/node_modules/", 19 | ], 20 | runner: "jest-runner", 21 | // Setup files to run immediately before executing the test code. 22 | setupFiles: [], 23 | /** 24 | * Setup files to run immediately after the test framework has been installed 25 | * in the environment. 26 | */ 27 | setupFilesAfterEnv: ["/setupJestDomTests.js"], 28 | testEnvironment: "jsdom", 29 | testURL: "http://localhost", 30 | testRegex: "__tests__/.*\\.(js|ts)$", 31 | transform: { 32 | "^.+\\.[t|j]sx?$": "babel-jest", 33 | "\\.(glsl)$": "/glslTransformer.js", 34 | }, 35 | transformIgnorePatterns: ["[/\\\\]node_modules[/\\\\].+\\.(js|ts)$"], 36 | watchPlugins: [], 37 | }; 38 | 39 | module.exports = config; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "threejs-es6-webpack-starter", 3 | "description": "A starter project for Three.js with support for ES6 and Webpack 4", 4 | "author": "jackdbd (http://www.giacomodebidda.com/)", 5 | "homepage": "https://jackdbd.github.io/threejs-es6-webpack-starter", 6 | "repository": "git@github.com:jackdbd/threejs-es6-webpack-starter.git", 7 | "version": "5.1.0", 8 | "main": "./src/js/index.js", 9 | "license": "MIT", 10 | "private": true, 11 | "engines": { 12 | "node": ">=11.10.1" 13 | }, 14 | "scripts": { 15 | "build": "yarn prebuild && webpack --config ./config/webpack.prod.js", 16 | "ci": "yarn test && yarn coverage && yarn build", 17 | "coverage": "codecov", 18 | "deploy": "gh-pages -d build", 19 | "lint": "yarn lint:css && yarn lint:js", 20 | "lint:css": "stylelint --config .stylelintrc.json --formatter verbose './src/**/*.css'", 21 | "lint:js": "eslint 'src/**/*.{js,ts}' --fix --debug", 22 | "ncu": "ncu", 23 | "ncuu": "ncu --upgrade", 24 | "nuke": "rimraf node_modules && rm yarn.lock", 25 | "prebuild": "yarn lint", 26 | "predeploy": "yarn test && yarn build", 27 | "start": "webpack-dev-server --config ./config/webpack.dev.js", 28 | "static": "chromium-browser build/index.html", 29 | "stats": "webpack-bundle-analyzer build/stats.json --port 8888", 30 | "test": "yarn test:unit", 31 | "test:e2e": "concurrently \"yarn start\" \"yarn test:e2e:cli\" --kill-others", 32 | "test:unit": "jest --verbose --no-cache", 33 | "test:e2e:cli": "cypress run --browser chromium --key da1be047-979c-4b0f-bacf-fef804c7c6bb", 34 | "test:e2e:dashboard": "cypress open", 35 | "updtr": "updtr --use yarn --test-stdout", 36 | "webhint": "hint https://jackdbd.github.io/threejs-es6-webpack-starter/", 37 | "webhint:report": "python -m webbrowser ~/repos/threejs-es6-webpack-starter/hint-report/https-jackdbd-github-io-threejs-es6-webpack-starter/index.html" 38 | }, 39 | "dependencies": { 40 | "gltf-loader-ts": "0.3.1", 41 | "orbit-controls-es6": "2.0.1", 42 | "three": "0.113.2", 43 | "three.interaction": "0.2.2" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "7.8.6", 47 | "@babel/preset-env": "7.8.6", 48 | "@hint/configuration-web-recommended": "8.0.2", 49 | "@packtracker/webpack-plugin": "2.2.0", 50 | "@testing-library/jest-dom": "5.1.1", 51 | "babel-jest": "25.1.0", 52 | "babel-loader": "8.0.6", 53 | "circular-dependency-plugin": "5.2.0", 54 | "clean-webpack-plugin": "3.0.0", 55 | "compression-webpack-plugin": "3.1.0", 56 | "concurrently": "5.1.0", 57 | "css-loader": "3.4.2", 58 | "cypress": "4.1.0", 59 | "duplicate-package-checker-webpack-plugin": "3.0.0", 60 | "eslint": "6.8.0", 61 | "eslint-config-prettier": "6.10.0", 62 | "eslint-plugin-prettier": "3.1.2", 63 | "favicons-webpack-plugin": "2.1.0", 64 | "file-loader": "4.2.0", 65 | "gh-pages": "2.2.0", 66 | "hint": "6.0.3", 67 | "html-loader": "0.5.5", 68 | "html-webpack-plugin": "3.2.0", 69 | "image-webpack-loader": "6.0.0", 70 | "jest": "25.1.0", 71 | "jest-cli": "25.1.0", 72 | "jest-dom": "4.0.0", 73 | "mini-css-extract-plugin": "0.9.0", 74 | "node-sass": "4.13.1", 75 | "npm-check-updates": "4.0.2", 76 | "optimize-css-assets-webpack-plugin": "5.0.3", 77 | "prettier": "1.19.1", 78 | "rimraf": "3.0.2", 79 | "sass-loader": "8.0.2", 80 | "speed-measure-webpack-plugin": "1.3.1", 81 | "style-loader": "1.1.3", 82 | "stylelint": "13.2.0", 83 | "stylelint-config-standard": "20.0.0", 84 | "terser-webpack-plugin": "2.3.5", 85 | "updtr": "3.1.0", 86 | "webpack": "4.41.6", 87 | "webpack-bundle-analyzer": "3.6.0", 88 | "webpack-cli": "3.3.11", 89 | "webpack-dev-server": "3.10.3", 90 | "webpack-glsl-loader": "1.0.1", 91 | "webpack-manifest-plugin": "2.2.0", 92 | "webpack-merge": "4.2.2", 93 | "worker-plugin": "3.2.0" 94 | }, 95 | "keywords": [ 96 | "babel", 97 | "boilerplate", 98 | "es6", 99 | "glsl", 100 | "threejs", 101 | "three.js", 102 | "webpack", 103 | "webgl" 104 | ], 105 | "bugs": { 106 | "url": "https://github.com/jackdbd/threejs-es6-webpack-starter/issues" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", "schedule:monthly"] 3 | } 4 | -------------------------------------------------------------------------------- /setupJestDomTests.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/extend-expect"; 2 | -------------------------------------------------------------------------------- /src/css/README.md: -------------------------------------------------------------------------------- 1 | # CSS 2 | 3 | These files are organized in this way: 4 | 5 | - [layouts/](./layouts/README.md): layout primitives whose only concern is the page/component layout. 6 | - [components/](./components/README.md): visual primitives. 7 | - [debug.css](./debug.css): file to inspect the page without all of its styles (i.e. aestetics) but with no change to its layout. 8 | -------------------------------------------------------------------------------- /src/css/components/README.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | Set of visual primitives. 4 | -------------------------------------------------------------------------------- /src/css/components/footer.css: -------------------------------------------------------------------------------- 1 | footer { 2 | display: flex; 3 | justify-content: center; 4 | } 5 | -------------------------------------------------------------------------------- /src/css/components/index.css: -------------------------------------------------------------------------------- 1 | @import "./footer.css"; 2 | @import "./messages.css"; 3 | @import "./navbar.css"; 4 | @import "./tooltip.css"; 5 | @import "./transfer-demo-grid.css"; 6 | -------------------------------------------------------------------------------- /src/css/components/messages.css: -------------------------------------------------------------------------------- 1 | .messages { 2 | grid-area: messages; 3 | } 4 | 5 | .messages ol { 6 | background-color: white; 7 | margin: 0; 8 | max-height: 25vh; 9 | overflow: auto; 10 | } 11 | -------------------------------------------------------------------------------- /src/css/components/navbar.css: -------------------------------------------------------------------------------- 1 | .nav-grid { 2 | display: grid; 3 | grid-template-areas: "home hamburger"; 4 | } 5 | 6 | .nav-grid-pages { 7 | display: none; 8 | grid-area: pages; 9 | } 10 | 11 | .nav-grid--expanded { 12 | grid-template-areas: 13 | "home hamburger" 14 | "pages pages"; 15 | } 16 | 17 | .nav-grid-home { 18 | align-items: center; 19 | display: flex; 20 | grid-area: home; 21 | margin-right: auto; 22 | } 23 | 24 | .nav-grid-home a, 25 | .nav-grid-pages li { 26 | line-height: normal; 27 | } 28 | 29 | .nav-grid-hamburger { 30 | grid-area: hamburger; 31 | margin-left: auto; 32 | } 33 | 34 | .nav-grid-hamburger button { 35 | background: none; 36 | border: none; 37 | color: var(--color-font-primary); 38 | outline: 0.1rem solid var(--color-highlighted); 39 | } 40 | 41 | .nav-grid--expanded .nav-grid-pages { 42 | display: flex; 43 | flex-direction: column; 44 | } 45 | 46 | .nav-grid--expanded .nav-grid-pages a { 47 | display: block; 48 | } 49 | 50 | .nav-grid--expanded .nav-grid-pages > li + li { 51 | margin-top: var(--geometry-space-small); 52 | } 53 | -------------------------------------------------------------------------------- /src/css/components/tooltip.css: -------------------------------------------------------------------------------- 1 | .tooltip { 2 | background: var(--color-background-secondary); 3 | border: 0; 4 | border-radius: 1em; 5 | font-size: 1rem; 6 | font-weight: bold; 7 | height: auto; 8 | padding: 0.5rem; 9 | position: absolute; 10 | text-align: center; 11 | visibility: hidden; 12 | width: auto; 13 | } 14 | 15 | .tooltip:hover { 16 | opacity: 0.8; 17 | } 18 | -------------------------------------------------------------------------------- /src/css/components/transfer-demo-grid.css: -------------------------------------------------------------------------------- 1 | .transfer-demo-grid { 2 | display: grid; 3 | grid-row-gap: var(--geometry-space-medium); 4 | grid-template-areas: 5 | "buttons" 6 | "scene" 7 | "messages"; 8 | } 9 | 10 | .transfer-demo-grid h3 { 11 | color: var(--color-font-primary); 12 | } 13 | -------------------------------------------------------------------------------- /src/css/debug-stack.css: -------------------------------------------------------------------------------- 1 | .stack { 2 | background: hsla(210, 100%, 50%, 0.5) !important; 3 | box-shadow: none !important; 4 | color: hsla(210, 100%, 100%, 0.9) !important; 5 | outline: 0.1rem solid hsla(30, 100%, 50%, 0.5) !important; 6 | } 7 | 8 | .stack > * { 9 | background: hsla(220, 100%, 50%, 0.5) !important; 10 | outline: 0.25rem solid hsla(40, 100%, 50%, 0.5) !important; 11 | } 12 | -------------------------------------------------------------------------------- /src/css/debug.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Import this file to see the structure of a page. 3 | * 4 | * https://www.freecodecamp.org/news/heres-my-favorite-weird-trick-to-debug-css-88529aa5a6a3/ 5 | */ 6 | *:not(path):not(g) { 7 | background: hsla(210, 100%, 50%, 0.5) !important; 8 | box-shadow: none !important; 9 | color: hsla(210, 100%, 100%, 0.9) !important; 10 | outline: 0.25rem solid hsla(210, 100%, 100%, 0.5) !important; 11 | } 12 | 13 | /* div { 14 | outline: 0.1rem solid red !important; 15 | } */ 16 | 17 | /* footer { 18 | outline: 0.1rem solid fuchsia !important; 19 | } */ 20 | 21 | /* p { 22 | outline: 0.1rem solid purple !important; 23 | } */ 24 | 25 | /* ul { 26 | outline: 0.1rem solid lawngreen !important; 27 | } */ 28 | -------------------------------------------------------------------------------- /src/css/defaults.css: -------------------------------------------------------------------------------- 1 | canvas { 2 | outline: 0.1rem solid var(--color-highlighted); 3 | } 4 | 5 | button { 6 | background: none; 7 | border-color: var(--color-highlighted); 8 | border-style: solid; 9 | } 10 | -------------------------------------------------------------------------------- /src/css/font-face-rules.css: -------------------------------------------------------------------------------- 1 | /** 2 | * @font-face rules 3 | * 4 | * If you have multiple font formats, put most modern formats first: 5 | * .woff2 -> .woff -> .ttf 6 | * .woff2 and .woff are compressed, so use them over .ttf 7 | * If you can't find woff2/woff, convert ttf fonts with an online converter: 8 | * https://font-converter.net/en 9 | * https://transfonter.org/ 10 | * 11 | * A single font-family can have multiple fonts (e.g. for bold, italic). 12 | */ 13 | @font-face { 14 | font-family: "Syne-Extra"; 15 | font-style: normal; 16 | font-weight: normal; 17 | /* stylelint-disable declaration-colon-newline-after */ 18 | src: url("../fonts/Syne/Syne-Extra.woff2") format("woff2"), 19 | url("../fonts/Syne/Syne-Extra.woff") format("woff"), 20 | url("../fonts/Syne/Syne-Extra.ttf") format("truetype"); 21 | /* stylelint-enable declaration-colon-newline-after */ 22 | } 23 | 24 | @font-face { 25 | font-family: "Syne-Regular"; 26 | font-style: normal; 27 | font-weight: normal; 28 | /* stylelint-disable declaration-colon-newline-after */ 29 | src: url("../fonts/Syne/Syne-Regular.woff2") format("woff2"), 30 | url("../fonts/Syne/Syne-Regular.woff") format("woff"), 31 | url("../fonts/Syne/Syne-Regular.ttf") format("truetype"); 32 | /* stylelint-enable declaration-colon-newline-after */ 33 | } 34 | 35 | /* other cool fonts Syncopate, Quicksand, Aclonica */ 36 | -------------------------------------------------------------------------------- /src/css/helpers.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Grid element that keep placing on the same row its children, then it places 3 | * on subsequent rows the children that would otherwise overflow. 4 | * Set the --child-min-width CSS custom property to control how wide each child 5 | * should AT LEAST be. 6 | * 7 | * @example 8 | *
9 | *
Child 0
10 | *
Child 1
11 | *
Child 2
12 | * 13 | * 14 | * .my-class .grid-template-columns\:auto-fit { 15 | * --child-min-width: 500px; 16 | * } 17 | * 18 | * @see 19 | * https://css-tricks.com/auto-sizing-columns-css-grid-auto-fill-vs-auto-fit/ 20 | */ 21 | .grid-template-columns\:auto-fit { 22 | /* stylelint-disable-next-line unit-whitelist */ 23 | --child-min-width: 321px; 24 | 25 | display: grid; 26 | grid-column-gap: 1rem; 27 | grid-row-gap: 1rem; 28 | /* stylelint-disable-next-line unit-whitelist */ 29 | grid-template-columns: repeat(auto-fit, minmax(var(--child-min-width), 1fr)); 30 | } 31 | 32 | /** 33 | * @see 34 | * http://wellcaffeinated.net/articles/2012/12/10/very-simple-css-only-proportional-resizing-of-elements 35 | */ 36 | .preserve-aspect-ratio { 37 | --aspect: calc(4 / 3); 38 | 39 | padding-top: calc(100% / var(--aspect)); 40 | position: relative; 41 | width: calc(100% - var(--grid-column-gap)); 42 | } 43 | 44 | .preserve-aspect-ratio > * { 45 | bottom: 0; 46 | left: 0; 47 | position: absolute; 48 | right: 0; 49 | top: 0; 50 | } 51 | 52 | .preserve-aspect-ratio > * > * { 53 | height: 100%; 54 | width: 100%; 55 | } 56 | 57 | /** 58 | * Lobotomized owl to set margin-left for all children but the last one. 59 | * 60 | * This will probably no longer be necessary when browsers will implement `gap` 61 | * for flex containers. 62 | * https://caniuse.com/#search=gap 63 | * 64 | * @example 65 | *
    66 | *
  • Child 0
  • 67 | *
  • Child 1
  • 68 | *
  • Child 2 (this one has no margin-left)
  • 69 | *
70 | * 71 | * ul.lobotomized-owl\:margin-left { 72 | * --space: 1.5rem; 73 | * } 74 | * 75 | * @see 76 | * https://alistapart.com/article/axiomatic-css-and-lobotomized-owls/ 77 | */ 78 | .lobotomized-owl\:margin-left > * + * { 79 | margin-left: var(--space); 80 | } 81 | 82 | .site { 83 | background-color: var(--color-background-primary); 84 | display: flex; 85 | flex-direction: column; 86 | min-height: 100vh; 87 | } 88 | 89 | .site > main { 90 | background-color: var(--color-background-secondary); 91 | flex: 1 0 auto; 92 | } 93 | 94 | .ghost-button { 95 | color: var(--color-font-primary); 96 | outline: 0.1rem solid var(--color-highlighted); 97 | padding: 0 var(--geometry-space-small); 98 | text-decoration: none; 99 | } 100 | 101 | .ghost-button:hover { 102 | color: var(--color-highlighted); 103 | text-decoration: underline; 104 | } 105 | 106 | .no-margins { 107 | margin: 0; 108 | } 109 | 110 | .single-responsive-element { 111 | left: 0; 112 | position: relative; 113 | width: 100%; 114 | } 115 | 116 | .display\:none { 117 | display: none; 118 | } 119 | -------------------------------------------------------------------------------- /src/css/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files for the application. 3 | * 4 | * @font-face rules should be defined first in your main CSS file 5 | * 6 | * @see 7 | * https://alligator.io/css/font-face/ 8 | */ 9 | @import "../css/font-face-rules.css"; 10 | @import "../css/variables.css"; 11 | @import "../css/typography.css"; 12 | @import "../css/layouts/index.css"; 13 | @import "../css/components/index.css"; 14 | @import "../css/helpers.css"; 15 | @import "../css/defaults.css"; 16 | @import "../css/media-queries.css"; 17 | -------------------------------------------------------------------------------- /src/css/layouts/README.md: -------------------------------------------------------------------------------- 1 | # Layouts 2 | 3 | Set of [layout primitives](https://absolutely.every-layout.dev/layouts/). 4 | 5 | These files' only concern is layout (arrangement of boxes). 6 | 7 | They should not be used for branding/aesthetic (fonts, colors, shadows, etc). 8 | -------------------------------------------------------------------------------- /src/css/layouts/box.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Box layout 3 | * 4 | * A box element applies a padding on all sides. Set the --space CSS custom 5 | * property to set the amount of this padding. 6 | * 7 | * @example 8 | *
9 | *
Child (there is padding around me)
10 | *
11 | * 12 | * div.box { 13 | * --space: 3rem; 14 | * } 15 | * 16 | * @see 17 | * https://absolutely.every-layout.dev/layouts/box/ 18 | */ 19 | 20 | .box { 21 | --space: var(--geometry-space-small); 22 | 23 | padding: var(--space); 24 | } 25 | -------------------------------------------------------------------------------- /src/css/layouts/cluster.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Cluster layout 3 | * 4 | * A cluster element is a container that lets its grandchildren wrap on the next 5 | * row when they would otherwise overflow. Set the --space CSS custom property 6 | * to control the space between its grandchildren. 7 | * 8 | * @example 9 | * 15 | * 16 | * nav.cluster { 17 | * --space: 3rem; 18 | * } 19 | * 20 | * @see 21 | * https://absolutely.every-layout.dev/layouts/cluster/ 22 | */ 23 | 24 | .cluster { 25 | --space: 1rem; 26 | 27 | overflow: hidden; 28 | } 29 | 30 | .cluster > * { 31 | align-items: center; 32 | display: flex; 33 | flex-wrap: wrap; 34 | justify-content: flex-start; 35 | margin: calc(var(--space) / 2 * -1); 36 | } 37 | 38 | .cluster > * > * { 39 | margin: calc(var(--space) / 2); 40 | } 41 | -------------------------------------------------------------------------------- /src/css/layouts/index.css: -------------------------------------------------------------------------------- 1 | @import "./box.css"; 2 | @import "./cluster.css"; 3 | @import "./stack.css"; 4 | -------------------------------------------------------------------------------- /src/css/layouts/stack.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Stack layout 3 | * 4 | * A stack element is a container that controls the vertical margins of its 5 | * children. Set the --space CSS custom property to control the vertical space 6 | * between its children. 7 | * 8 | * @example 9 | *
10 | *
Child 0
11 | *
Child 1
12 | *
Child 2
13 | *
14 | * 15 | * div.stack { 16 | * --space: 3rem; 17 | * } 18 | * 19 | * @see 20 | * https://absolutely.every-layout.dev/layouts/stack/ 21 | */ 22 | .stack { 23 | --space: 1rem; 24 | 25 | display: flex; 26 | flex-direction: column; 27 | justify-content: flex-start; 28 | } 29 | 30 | .stack > * { 31 | margin-bottom: 0; 32 | margin-top: 0; 33 | } 34 | 35 | .stack > * + * { 36 | margin-top: var(--space); 37 | } 38 | -------------------------------------------------------------------------------- /src/css/media-queries.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable unit-whitelist */ 2 | @media only screen and (min-width: 321px) { 3 | .single-responsive-element { 4 | left: 5%; 5 | width: 90%; 6 | } 7 | } 8 | 9 | @media only screen and (min-width: 1300px) { 10 | .single-responsive-element { 11 | left: 10%; 12 | width: 80%; 13 | } 14 | } 15 | 16 | @media only screen and (min-width: 1500px) { 17 | .single-responsive-element { 18 | left: 12.5%; 19 | width: 75%; 20 | } 21 | } 22 | 23 | @media only screen and (min-width: 1700px) { 24 | .single-responsive-element { 25 | left: 15%; 26 | width: 70%; 27 | } 28 | } 29 | 30 | @media only screen and (min-width: 1900px) { 31 | .single-responsive-element { 32 | left: 17.5%; 33 | width: 65%; 34 | } 35 | } 36 | 37 | @media only screen and (min-width: 2200px) { 38 | .single-responsive-element { 39 | left: 22.5%; 40 | width: 55%; 41 | } 42 | } 43 | 44 | @media only screen and (min-width: 600px) { 45 | .nav-grid { 46 | grid-template-areas: "home pages"; 47 | } 48 | 49 | .nav-grid-hamburger { 50 | display: none; 51 | } 52 | 53 | .nav-grid-pages { 54 | display: flex; 55 | justify-self: flex-end; 56 | } 57 | } 58 | 59 | @media only screen and (min-width: 800px) { 60 | .transfer-demo-grid { 61 | grid-template-areas: 62 | "buttons buttons" 63 | "scene messages"; 64 | grid-template-columns: 1fr 1fr; 65 | } 66 | 67 | .transfer-demo-grid .messages { 68 | max-height: 50vh; 69 | } 70 | } 71 | 72 | /* stylelint-enable unit-whitelist */ 73 | -------------------------------------------------------------------------------- /src/css/typography.css: -------------------------------------------------------------------------------- 1 | h1, 2 | h2, 3 | h3, 4 | h4, 5 | h5, 6 | h6 { 7 | font-family: var(--typography-font-family-headline); 8 | } 9 | 10 | a, 11 | button, 12 | div, 13 | figcaption, 14 | li, 15 | main, 16 | p, 17 | span { 18 | font-family: var(--typography-font-family-body); 19 | } 20 | 21 | button, 22 | figcaption, 23 | h1, 24 | h2, 25 | h3, 26 | h4, 27 | h5, 28 | h6, 29 | p, 30 | span { 31 | color: var(--color-font-primary); 32 | } 33 | -------------------------------------------------------------------------------- /src/css/variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* COLORS */ 3 | --color-background-primary: #374047; 4 | --color-background-secondary: #5f6e78; 5 | --color-font-primary: white; 6 | --color-highlighted: orange; 7 | 8 | /* GEOMETRY */ 9 | --geometry-space-small: 1rem; 10 | --geometry-space-medium: 2rem; 11 | --geometry-space-big: 3rem; 12 | 13 | /* TYPOGRAPHY */ 14 | --typography-font-family-headline: "Syne-Extra", sans-serif; 15 | --typography-font-family-body: "Syne-Regular", sans-serif; 16 | 17 | /* --typography-font-family-body: "Raleway", sans-serif; */ 18 | } 19 | -------------------------------------------------------------------------------- /src/fonts/Syne/README.md: -------------------------------------------------------------------------------- 1 | # Syne 2 | 3 | See [here](https://gitlab.com/bonjour-monde/fonderie/syne-typeface). 4 | -------------------------------------------------------------------------------- /src/fonts/Syne/Syne-Extra.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackdbd/threejs-es6-webpack-starter/e4b00bbbfdb93016fbd486ec4a4b3456587b61fd/src/fonts/Syne/Syne-Extra.ttf -------------------------------------------------------------------------------- /src/fonts/Syne/Syne-Extra.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackdbd/threejs-es6-webpack-starter/e4b00bbbfdb93016fbd486ec4a4b3456587b61fd/src/fonts/Syne/Syne-Extra.woff -------------------------------------------------------------------------------- /src/fonts/Syne/Syne-Extra.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackdbd/threejs-es6-webpack-starter/e4b00bbbfdb93016fbd486ec4a4b3456587b61fd/src/fonts/Syne/Syne-Extra.woff2 -------------------------------------------------------------------------------- /src/fonts/Syne/Syne-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackdbd/threejs-es6-webpack-starter/e4b00bbbfdb93016fbd486ec4a4b3456587b61fd/src/fonts/Syne/Syne-Regular.ttf -------------------------------------------------------------------------------- /src/fonts/Syne/Syne-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackdbd/threejs-es6-webpack-starter/e4b00bbbfdb93016fbd486ec4a4b3456587b61fd/src/fonts/Syne/Syne-Regular.woff -------------------------------------------------------------------------------- /src/fonts/Syne/Syne-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackdbd/threejs-es6-webpack-starter/e4b00bbbfdb93016fbd486ec4a4b3456587b61fd/src/fonts/Syne/Syne-Regular.woff2 -------------------------------------------------------------------------------- /src/glsl/README.md: -------------------------------------------------------------------------------- 1 | # GLSL 2 | 3 | This directory contains glsl code for vertex shaders and fragment shaders. 4 | -------------------------------------------------------------------------------- /src/glsl/fragmentShader.glsl: -------------------------------------------------------------------------------- 1 | uniform float delta; 2 | varying float vOpacity; 3 | varying vec3 vUv; 4 | 5 | void main() { 6 | float r = 1.0 + cos(vUv.x * delta); 7 | float g = 0.5 + sin(delta) * 0.5; 8 | float b = 0.0; 9 | vec3 rgb = vec3(r, g, b); 10 | 11 | gl_FragColor = vec4(rgb, vOpacity); 12 | } -------------------------------------------------------------------------------- /src/glsl/vertexShader.glsl: -------------------------------------------------------------------------------- 1 | attribute float vertexDisplacement; 2 | uniform float delta; 3 | varying float vOpacity; 4 | varying vec3 vUv; 5 | 6 | void main() { 7 | vUv = position; 8 | vOpacity = vertexDisplacement; 9 | 10 | vec3 p = position; 11 | 12 | p.x += sin(vertexDisplacement) * 50.0; 13 | p.y += cos(vertexDisplacement) * 50.0; 14 | 15 | vec4 modelViewPosition = modelViewMatrix * vec4(p, 1.0); 16 | gl_Position = projectionMatrix * modelViewPosition; 17 | } 18 | -------------------------------------------------------------------------------- /src/html/documents/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ${require('../fragments/head.html')} 4 | 5 | ${require('../fragments/navbar.html')} 6 |
7 |

Ops. We could not find that page.

8 | Back to homepage 9 |
10 | ${require('../fragments/footer.html')} 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/html/documents/README.md: -------------------------------------------------------------------------------- 1 | # HTML documents 2 | 3 | Each file in this directory represents a page in the application. 4 | 5 | This is a multi page aplication, so [each Webpack entry point](https://webpack.js.org/concepts/entry-points/#multi-page-application) associated to a page uses one of these files as a template. 6 | 7 | [html-webpack-plugin](https://webpack.js.org/plugins/html-webpack-plugin/) picks the HTML document associated to the entry point and injects the [chunks](https://github.com/jantimon/html-webpack-plugin#filtering-chunks) required for the page. 8 | -------------------------------------------------------------------------------- /src/html/documents/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ${require('../fragments/head.html')} 4 | 5 | ${require('../fragments/navbar.html')} 6 |
7 |

About

8 |

9 | Examples with Three.js running in the main thread or in web workers. 10 |

11 |
12 | ${require('../fragments/footer.html')} 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/html/documents/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ${require('../fragments/head.html')} 4 | 5 | ${require('../fragments/navbar.html')} 6 |
7 |
8 |
9 |
10 | 11 |
12 |
13 |
14 |
15 | A scene with a spotlight, a directional light, an ambient light, a 16 | particle system, a custom material and several helpers. 17 |
18 |
19 | ${require('../fragments/footer.html')} 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/html/documents/offscreen-bitmaprenderer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ${require('../fragments/head.html')} 4 | 5 | ${require('../fragments/navbar.html')} 6 |
7 |
8 |
9 |
10 |
11 |
12 | 15 |
16 |
17 |
Low Res
18 |
19 |
20 |
21 |
22 | 25 |
26 |
27 |
Medium Res
28 |
29 |
30 |
31 |
32 | 35 |
36 |
37 |
High Res
38 |
39 |
40 |
41 |

Messages between main thread and web worker

42 |
    43 |
    44 |
    45 |
    46 | ${require('../fragments/footer.html')} 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/html/documents/offscreen-transfer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ${require('../fragments/head.html')} 4 | 5 | ${require('../fragments/navbar.html')} 6 |
    7 |
    8 |
    9 |
    10 |
    11 |
    12 | 18 | 24 | 30 | 36 |
    37 |
    38 |
    39 |
    40 |
    41 |
    42 |
    43 | 46 |
    47 |
    48 |
    Canvas Transfer
    49 |
    50 |
    51 |
    52 |

    Messages between main thread and web worker

    53 |
      54 |
      55 |
      56 |
      57 |
      58 | ${require('../fragments/footer.html')} 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/html/fragments/README.md: -------------------------------------------------------------------------------- 1 | # HTML document fragments 2 | 3 | Each file in this directory defines a HTML [DocumentFragment](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment). 4 | 5 | These fragments are reused across multiple HTML documents (i.e. pages in the application) to avoid duplicate code. 6 | 7 | The fragments are injected in a HTML document thanks to [html-loader's interpolate](https://webpack.js.org/loaders/html-loader/#interpolate) option. 8 | -------------------------------------------------------------------------------- /src/html/fragments/footer.html: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /src/html/fragments/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ${APP_NAME} 4 | 5 | 9 | 10 | 11 | 15 | 16 | 22 | 28 | 34 | 35 | 39 | 40 | -------------------------------------------------------------------------------- /src/html/fragments/navbar.html: -------------------------------------------------------------------------------- 1 | 36 | -------------------------------------------------------------------------------- /src/js/404.js: -------------------------------------------------------------------------------- 1 | import "../css/index.css"; 2 | 3 | import { toggleMobileNav } from "./components/navbar"; 4 | 5 | window.toggleMobileNav = toggleMobileNav; 6 | -------------------------------------------------------------------------------- /src/js/README.md: -------------------------------------------------------------------------------- 1 | # JS 2 | 3 | These files are organized in this way: 4 | 5 | - vendor: vendor code that this application relies on 6 | - [workers/](./workers/README.md): scripts that run in background threads. There are [dedicated web workers](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker) and [shared web workers](https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker). -------------------------------------------------------------------------------- /src/js/about.js: -------------------------------------------------------------------------------- 1 | import "../css/index.css"; 2 | 3 | import { toggleMobileNav } from "./components/navbar"; 4 | 5 | window.toggleMobileNav = toggleMobileNav; 6 | -------------------------------------------------------------------------------- /src/js/application.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | // TODO: OrbitControls import three.js on its own, so the webpack bundle includes three.js twice! 3 | import OrbitControls from "orbit-controls-es6"; 4 | import { Interaction } from "three.interaction"; 5 | 6 | import * as Detector from "../js/vendor/Detector"; 7 | import * as DAT from "../js/vendor/dat.gui.min"; 8 | 9 | const checkerboard = require("../textures/checkerboard.jpg"); 10 | const star = require("../textures/star.png"); 11 | const vertexShader = require("../glsl/vertexShader.glsl"); 12 | const fragmentShader = require("../glsl/fragmentShader.glsl"); 13 | 14 | const CAMERA_NAME = "Perspective Camera"; 15 | const DIRECTIONAL_LIGHT_NAME = "Directional Light"; 16 | const SPOT_LIGHT_NAME = "Spotlight"; 17 | const CUSTOM_MESH_NAME = "Custom Mesh"; 18 | 19 | export class Application { 20 | constructor(opts) { 21 | this.showHelpers = 22 | opts && opts.showHelpers !== undefined ? opts.showHelpers : true; 23 | this.canvas = document.getElementById("application-canvas"); 24 | this.container = document.querySelector("main .canvas-container-inner"); 25 | this.createTooltip(); 26 | this.textureLoader = new THREE.TextureLoader(); 27 | 28 | if (Detector.webgl) { 29 | this.bindEventHandlers(); 30 | this.init(this.canvas); 31 | this.render(); 32 | } else { 33 | // console.warn("WebGL NOT supported in your browser!"); 34 | const warning = Detector.getWebGLErrorMessage(); 35 | this.container.appendChild(warning); 36 | } 37 | } 38 | 39 | /** 40 | * Bind event handlers to the Application instance. 41 | */ 42 | bindEventHandlers() { 43 | this.handleClick = this.handleClick.bind(this); 44 | this.handleMouseMove = this.handleMouseMove.bind(this); 45 | this.handleResize = this.handleResize.bind(this); 46 | this.showTooltip = this.showTooltip.bind(this); 47 | this.hideTooltip = this.hideTooltip.bind(this); 48 | } 49 | 50 | init(canvas) { 51 | const showGUI = false; 52 | window.addEventListener("resize", this.handleResize); 53 | this.setupScene(); 54 | this.setupRenderer(canvas); 55 | this.setupCamera(); 56 | const interaction = new Interaction(this.renderer, this.scene, this.camera); 57 | this.setupLights(); 58 | if (this.showHelpers) { 59 | this.setupHelpers(); 60 | } 61 | this.setupRay(); 62 | this.setupControls(); 63 | 64 | if (showGUI) { 65 | this.setupGUI(); 66 | } 67 | 68 | this.addFloor(100, 100); 69 | this.addCube(20); 70 | this.addCustomMesh(); 71 | 72 | const particleSpecs = { spread: { x: 50, y: 100, z: 50 } }; 73 | this.addParticleSystem(300, 5, particleSpecs); 74 | 75 | const boxSpecs = { 76 | depth: 20, 77 | height: 10, 78 | spread: { x: 20, y: 20, z: 50 }, 79 | width: 5, 80 | }; 81 | this.addGroupObject(10, boxSpecs); 82 | } 83 | 84 | render() { 85 | this.controls.update(); 86 | this.updateCustomMesh(); 87 | this.renderer.render(this.scene, this.camera); 88 | // when render is invoked via requestAnimationFrame(this.render) there is 89 | // no 'this', so either we bind it explicitly or use an es6 arrow function. 90 | // requestAnimationFrame(this.render.bind(this)); 91 | requestAnimationFrame(() => this.render()); 92 | } 93 | 94 | createTooltip() { 95 | const main = document.querySelector("main"); 96 | if (!main) { 97 | alert(`You have no '
      ' tag on ythe HTML page. You need exactly ONE`); 98 | } 99 | const div = document.createElement("div"); 100 | div.setAttribute("class", "tooltip"); 101 | div.setAttribute("data-cy", "tooltip"); 102 | main.appendChild(div); 103 | this.tooltip = div; 104 | } 105 | 106 | handleClick(event) { 107 | const [x, y] = this.getNDCCoordinates(event, true); 108 | this.raycaster.setFromCamera({ x, y }, this.camera); 109 | const intersects = this.raycaster.intersectObjects(this.scene.children); 110 | 111 | if (intersects.length > 0) { 112 | const hexColor = Math.random() * 0xffffff; 113 | const intersection = intersects[0]; 114 | intersection.object.material.color.setHex(hexColor); 115 | 116 | const { direction, origin } = this.raycaster.ray; 117 | const arrow = new THREE.ArrowHelper(direction, origin, 100, hexColor); 118 | this.scene.add(arrow); 119 | } 120 | } 121 | 122 | handleMouseMove(event) { 123 | const [x, y] = this.getNDCCoordinates(event); 124 | } 125 | 126 | handleResize(event) { 127 | // console.warn(event); 128 | const { clientWidth, clientHeight } = this.container; 129 | this.camera.aspect = clientWidth / clientHeight; 130 | this.camera.updateProjectionMatrix(); 131 | this.renderer.setSize(clientWidth, clientHeight); 132 | } 133 | 134 | showTooltip(interactionEvent) { 135 | const { name, uuid, type } = interactionEvent.target; 136 | const { x, y } = interactionEvent.data.global; 137 | const [xScreen, yScreen] = this.getScreenCoordinates(x, y); 138 | this.tooltip.innerHTML = `

      ${name} (${type})

      Click to cast a ray`; 139 | const style = `left: ${xScreen}px; top: ${yScreen}px; visibility: visible; opacity: 0.8`; 140 | this.tooltip.style = style; 141 | } 142 | 143 | hideTooltip(interactionEvent) { 144 | this.tooltip.style = "visibility: hidden"; 145 | } 146 | 147 | /** 148 | * Setup a Three.js scene. 149 | * Setting the scene is the first Three.js-specific code to perform. 150 | */ 151 | setupScene() { 152 | this.scene = new THREE.Scene(); 153 | this.scene.autoUpdate = true; 154 | // Let's say we want to define the background color only once throughout the 155 | // application. This can be done in CSS. So here we use JS to get a property 156 | // defined in a CSS. 157 | const style = window.getComputedStyle(this.container); 158 | const color = new THREE.Color(style.getPropertyValue("background-color")); 159 | this.scene.background = color; 160 | this.scene.fog = null; 161 | // Any Three.js object in the scene (and the scene itself) can have a name. 162 | this.scene.name = "My Three.js Scene"; 163 | } 164 | 165 | /** 166 | * Create a Three.js renderer. 167 | * We let the renderer create a canvas element where to draw its output, then 168 | * we set the canvas size, we add the canvas to the DOM and we bind event 169 | * listeners to it. 170 | */ 171 | setupRenderer(canvas) { 172 | this.renderer = new THREE.WebGLRenderer({ 173 | antialias: true, 174 | canvas, 175 | }); 176 | // this.renderer.setClearColor(0xd3d3d3); // it's a light gray 177 | this.renderer.setClearColor(0x222222); // it's a dark gray 178 | this.renderer.setPixelRatio(window.devicePixelRatio || 1); 179 | const { clientWidth, clientHeight } = this.container; 180 | this.renderer.setSize(clientWidth, clientHeight); 181 | this.renderer.shadowMap.enabled = true; 182 | this.container.appendChild(this.renderer.domElement); 183 | this.renderer.domElement.addEventListener("click", this.handleClick); 184 | this.renderer.domElement.addEventListener( 185 | "mousemove", 186 | this.handleMouseMove 187 | ); 188 | } 189 | 190 | setupCamera() { 191 | const fov = 75; 192 | const { clientWidth, clientHeight } = this.container; 193 | const aspect = clientWidth / clientHeight; 194 | const near = 0.1; 195 | const far = 10000; 196 | this.camera = new THREE.PerspectiveCamera(fov, aspect, near, far); 197 | this.camera.name = CAMERA_NAME; 198 | this.camera.position.set(100, 100, 100); 199 | this.camera.lookAt(this.scene.position); 200 | } 201 | 202 | setupLights() { 203 | const dirLight = new THREE.DirectionalLight(0x4682b4, 1); // steelblue 204 | dirLight.name = DIRECTIONAL_LIGHT_NAME; 205 | dirLight.position.set(120, 30, -200); 206 | dirLight.castShadow = true; 207 | dirLight.shadow.camera.near = 10; 208 | this.scene.add(dirLight); 209 | 210 | const spotLight = new THREE.SpotLight(0xffaa55); 211 | spotLight.name = SPOT_LIGHT_NAME; 212 | spotLight.position.set(120, 30, 0); 213 | spotLight.castShadow = true; 214 | dirLight.shadow.camera.near = 10; 215 | this.scene.add(spotLight); 216 | 217 | const ambientLight = new THREE.AmbientLight(0xffaa55); 218 | this.scene.add(ambientLight); 219 | } 220 | 221 | setupHelpers() { 222 | const gridHelper = new THREE.GridHelper(200, 16); 223 | gridHelper.name = "Floor GridHelper"; 224 | this.scene.add(gridHelper); 225 | 226 | // XYZ axes helper (XYZ axes are RGB colors, respectively) 227 | const axesHelper = new THREE.AxesHelper(75); 228 | axesHelper.name = "XYZ AzesHelper"; 229 | this.scene.add(axesHelper); 230 | 231 | const dirLight = this.scene.getObjectByName(DIRECTIONAL_LIGHT_NAME); 232 | 233 | const dirLightHelper = new THREE.DirectionalLightHelper(dirLight, 10); 234 | dirLightHelper.name = `${DIRECTIONAL_LIGHT_NAME} Helper`; 235 | this.scene.add(dirLightHelper); 236 | 237 | const dirLightCameraHelper = new THREE.CameraHelper(dirLight.shadow.camera); 238 | dirLightCameraHelper.name = `${DIRECTIONAL_LIGHT_NAME} Shadow Camera Helper`; 239 | this.scene.add(dirLightCameraHelper); 240 | 241 | const spotLight = this.scene.getObjectByName(SPOT_LIGHT_NAME); 242 | 243 | const spotLightHelper = new THREE.SpotLightHelper(spotLight); 244 | spotLightHelper.name = `${SPOT_LIGHT_NAME} Helper`; 245 | this.scene.add(spotLightHelper); 246 | 247 | const spotLightCameraHelper = new THREE.CameraHelper( 248 | spotLight.shadow.camera 249 | ); 250 | spotLightCameraHelper.name = `${SPOT_LIGHT_NAME} Shadow Camera Helper`; 251 | this.scene.add(spotLightCameraHelper); 252 | } 253 | 254 | setupRay() { 255 | this.raycaster = new THREE.Raycaster(); 256 | } 257 | 258 | /** 259 | * Add a floor object to the scene. 260 | * Note: Three.js's TextureLoader does not support progress events. 261 | * @see https://threejs.org/docs/#api/en/loaders/TextureLoader 262 | */ 263 | addFloor(width, height) { 264 | const geometry = new THREE.PlaneGeometry(width, height, 1, 1); 265 | const onLoad = texture => { 266 | texture.wrapS = THREE.RepeatWrapping; 267 | texture.wrapT = THREE.RepeatWrapping; 268 | texture.repeat.set(4, 4); 269 | const material = new THREE.MeshBasicMaterial({ 270 | map: texture, 271 | side: THREE.DoubleSide, 272 | }); 273 | const floor = new THREE.Mesh(geometry, material); 274 | floor.name = "Floor"; 275 | floor.position.y = -0.5; 276 | floor.rotation.x = Math.PI / 2; 277 | this.scene.add(floor); 278 | 279 | floor.cursor = "pointer"; 280 | floor.on("mouseover", this.showTooltip); 281 | floor.on("mouseout", this.hideTooltip); 282 | }; 283 | 284 | const onProgress = undefined; 285 | 286 | const onError = event => { 287 | alert(`Impossible to load the texture ${checkerboard}`); 288 | }; 289 | this.textureLoader.load(checkerboard, onLoad, onProgress, onError); 290 | } 291 | 292 | setupControls() { 293 | this.controls = new OrbitControls(this.camera, this.renderer.domElement); 294 | this.controls.enabled = true; 295 | this.controls.maxDistance = 1500; 296 | this.controls.minDistance = 0; 297 | this.controls.autoRotate = true; 298 | } 299 | 300 | setupGUI() { 301 | const gui = new DAT.GUI(); 302 | gui 303 | .add(this.camera.position, "x") 304 | .name("Camera X") 305 | .min(0) 306 | .max(100); 307 | gui 308 | .add(this.camera.position, "y") 309 | .name("Camera Y") 310 | .min(0) 311 | .max(100); 312 | gui 313 | .add(this.camera.position, "z") 314 | .name("Camera Z") 315 | .min(0) 316 | .max(100); 317 | } 318 | 319 | /** 320 | * Create an object that uses custom shaders. 321 | */ 322 | addCustomMesh() { 323 | this.delta = 0; 324 | const customUniforms = { 325 | delta: { value: 0 }, 326 | }; 327 | 328 | const material = new THREE.ShaderMaterial({ 329 | vertexShader, 330 | fragmentShader, 331 | uniforms: customUniforms, 332 | }); 333 | 334 | const geometry = new THREE.SphereBufferGeometry(5, 32, 32); 335 | 336 | this.vertexDisplacement = new Float32Array( 337 | geometry.attributes.position.count 338 | ); 339 | for (let i = 0; i < this.vertexDisplacement.length; i += 1) { 340 | this.vertexDisplacement[i] = Math.sin(i); 341 | } 342 | 343 | geometry.setAttribute( 344 | "vertexDisplacement", 345 | new THREE.BufferAttribute(this.vertexDisplacement, 1) 346 | ); 347 | 348 | const customMesh = new THREE.Mesh(geometry, material); 349 | customMesh.name = CUSTOM_MESH_NAME; 350 | customMesh.position.set(5, 5, 5); 351 | this.scene.add(customMesh); 352 | } 353 | 354 | updateCustomMesh() { 355 | this.delta += 0.1; 356 | const customMesh = this.scene.getObjectByName(CUSTOM_MESH_NAME); 357 | customMesh.material.uniforms.delta.value = 0.5 + Math.sin(this.delta) * 0.5; 358 | for (let i = 0; i < this.vertexDisplacement.length; i += 1) { 359 | this.vertexDisplacement[i] = 0.5 + Math.sin(i + this.delta) * 0.25; 360 | } 361 | // attribute buffers are not refreshed automatically. To update custom 362 | // attributes we need to set the needsUpdate flag to true 363 | customMesh.geometry.attributes.vertexDisplacement.needsUpdate = true; 364 | } 365 | 366 | addCube(side) { 367 | const geometry = new THREE.CubeGeometry(side, side, side); 368 | const material = new THREE.MeshLambertMaterial({ color: 0xfbbc05 }); 369 | const cube = new THREE.Mesh(geometry, material); 370 | cube.name = "Cube"; 371 | cube.position.set(0, side / 2, 0); 372 | this.scene.add(cube); 373 | 374 | cube.cursor = "pointer"; 375 | cube.on("mouseover", this.showTooltip); 376 | cube.on("mouseout", this.hideTooltip); 377 | } 378 | 379 | /** 380 | * Add a particle system that uses the same texture for each particle. 381 | * The texture is asynchronously loaded. 382 | * Note: Three.js's TextureLoader does not support progress events. 383 | * @see https://threejs.org/docs/#api/en/loaders/TextureLoader 384 | */ 385 | addParticleSystem(numParticles, particleSize, particleSpecs) { 386 | const geometry = new THREE.Geometry(); 387 | const particles = Array(numParticles) 388 | .fill(particleSpecs) 389 | .map(makeParticle); 390 | geometry.vertices = particles; 391 | 392 | const onLoad = texture => { 393 | const material = new THREE.PointsMaterial({ 394 | // alphaTest's default is 0 and the particles overlap. Any value > 0 395 | // prevents the particles from overlapping. 396 | alphaTest: 0.5, 397 | map: texture, 398 | size: particleSize, 399 | transparent: true, 400 | }); 401 | 402 | const particleSystem = new THREE.Points(geometry, material); 403 | particleSystem.name = "Stars"; 404 | particleSystem.position.set(-50, 50, -50); 405 | this.scene.add(particleSystem); 406 | 407 | particleSystem.cursor = "pointer"; 408 | particleSystem.on("mouseover", this.showTooltip); 409 | particleSystem.on("mouseout", this.hideTooltip); 410 | }; 411 | 412 | const onProgress = undefined; 413 | 414 | const onError = event => { 415 | alert(`Impossible to load the texture ${star}`); 416 | }; 417 | 418 | this.textureLoader.load(star, onLoad, onProgress, onError); 419 | } 420 | 421 | /** 422 | * Add a Three.js Group object to the scene. 423 | */ 424 | addGroupObject(numBoxes, boxSpecs) { 425 | const group = new THREE.Group(); 426 | group.name = "Group of Boxes"; 427 | const { depth, height, spread, width } = boxSpecs; 428 | const geometry = new THREE.BoxGeometry(width, height, depth); 429 | 430 | const meshes = Array(numBoxes) 431 | .fill({ geometry, spread }) 432 | .map(makeMesh); 433 | for (const mesh of meshes) { 434 | group.add(mesh); 435 | } 436 | group.position.set(50, 20, 50); 437 | this.scene.add(group); 438 | 439 | group.cursor = "pointer"; 440 | group.on("mouseover", this.showTooltip); 441 | group.on("mouseout", this.hideTooltip); 442 | } 443 | 444 | /** 445 | * Convert screen coordinates into Normalized Device Coordinates [-1, +1]. 446 | * @see https://learnopengl.com/Getting-started/Coordinate-Systems 447 | */ 448 | getNDCCoordinates(event, debug) { 449 | const { 450 | clientHeight, 451 | clientWidth, 452 | offsetLeft, 453 | offsetTop, 454 | } = this.renderer.domElement; 455 | 456 | const xRelativePx = event.clientX - offsetLeft; 457 | const x = (xRelativePx / clientWidth) * 2 - 1; 458 | 459 | const yRelativePx = event.clientY - offsetTop; 460 | const y = -(yRelativePx / clientHeight) * 2 + 1; 461 | 462 | if (debug) { 463 | const data = { 464 | "Screen Coords (px)": { x: event.screenX, y: event.screenY }, 465 | "Canvas-Relative Coords (px)": { x: xRelativePx, y: yRelativePx }, 466 | "NDC (adimensional)": { x, y }, 467 | }; 468 | console.table(data, ["x", "y"]); 469 | } 470 | return [x, y]; 471 | } 472 | 473 | getScreenCoordinates(xNDC, yNDC) { 474 | // const { 475 | // clientHeight, 476 | // clientWidth, 477 | // offsetLeft, 478 | // offsetTop, 479 | // } = this.renderer.domElement; 480 | 481 | // TODO: save this.main at instantiation 482 | const main = document.querySelector(".single-responsive-element"); 483 | 484 | const canvasDomRect = this.canvas.getBoundingClientRect(); 485 | const mainDomRect = main.getBoundingClientRect(); 486 | // console.log("canvasDomRect", canvasDomRect, "mainDomRect", mainDomRect); 487 | const x = canvasDomRect.x - mainDomRect.x; 488 | const y = canvasDomRect.y - mainDomRect.y; 489 | 490 | // const xRelativePx = ((xNDC + 1) / 2) * clientWidth; 491 | // const yRelativePx = -0.5 * (yNDC - 1) * clientHeight; 492 | // const xScreen = xRelativePx + offsetLeft; 493 | // const yScreen = yRelativePx + offsetTop; 494 | // TODO: this is not exactly right, so the ray will not be correct 495 | const xRelativePx = ((xNDC + 1) / 2) * canvasDomRect.width; 496 | const yRelativePx = -0.5 * (yNDC - 1) * canvasDomRect.height; 497 | const xScreen = xRelativePx + x; 498 | const yScreen = yRelativePx + y; 499 | return [xScreen, yScreen]; 500 | } 501 | } 502 | 503 | /** 504 | * Create a particle for the particle system. 505 | */ 506 | function makeParticle(d, i) { 507 | const particle = new THREE.Vector3(); 508 | particle.x = THREE.Math.randFloatSpread(d.spread.x); 509 | particle.y = THREE.Math.randFloatSpread(d.spread.y); 510 | particle.z = THREE.Math.randFloatSpread(d.spread.z); 511 | return particle; 512 | } 513 | 514 | /** 515 | * Make a mesh for each Box in the GroupObject. 516 | */ 517 | function makeMesh(d, i) { 518 | const material = new THREE.MeshLambertMaterial({ 519 | color: Math.random() * 0xffffff, 520 | }); 521 | const mesh = new THREE.Mesh(d.geometry, material); 522 | mesh.name = `Box ${i} in GroupObject`; 523 | mesh.position.x = THREE.Math.randFloatSpread(d.spread.x); 524 | mesh.position.y = THREE.Math.randFloatSpread(d.spread.y); 525 | mesh.position.z = THREE.Math.randFloatSpread(d.spread.z); 526 | mesh.rotation.x = Math.random() * 360 * (Math.PI / 180); 527 | mesh.rotation.y = Math.random() * 360 * (Math.PI / 180); 528 | mesh.rotation.z = Math.random() * 360 * (Math.PI / 180); 529 | return mesh; 530 | } 531 | -------------------------------------------------------------------------------- /src/js/bitmap-demo.js: -------------------------------------------------------------------------------- 1 | import { MainThreadAction, WorkerAction } from "./worker-actions"; 2 | import { toggleMobileNav } from "./components/navbar"; 3 | import { makeLi } from "./helpers"; 4 | import { CanvasIds, unsupportedOffscreenCanvasAlertMessage } from "./constants"; 5 | import "../css/index.css"; 6 | 7 | window.toggleMobileNav = toggleMobileNav; 8 | 9 | if ( 10 | !document.getElementById(CanvasIds.BITMAP_LOW_RES).transferControlToOffscreen 11 | ) { 12 | alert(unsupportedOffscreenCanvasAlertMessage); 13 | } 14 | 15 | // TODO: preload the web worker script with resource hints. Or is it done automatically by webpack's worker-loader? 16 | // const workerUrl = document.querySelector("[rel=preload][as=script]").href; 17 | (function iife() { 18 | const NAME = "Main thread"; 19 | 20 | const worker = new Worker("./workers/bitmap-worker.js", { 21 | name: "Dedicated worker global scope (bitmap worker)", 22 | type: "module", 23 | }); 24 | 25 | // https://developer.mozilla.org/en-US/docs/Web/API/ImageBitmapRenderingContext 26 | const bitmapsConfig = [ 27 | { 28 | ctx: document 29 | .getElementById(CanvasIds.BITMAP_LOW_RES) 30 | .getContext("bitmaprenderer"), 31 | resolution: { width: 160, height: 90 }, 32 | }, 33 | { 34 | ctx: document 35 | .getElementById(CanvasIds.BITMAP_MEDIUM_RES) 36 | .getContext("bitmaprenderer"), 37 | resolution: { width: 640, height: 480 }, 38 | }, 39 | { 40 | ctx: document 41 | .getElementById(CanvasIds.BITMAP_HIGH_RES) 42 | .getContext("bitmaprenderer"), 43 | resolution: { width: 1024, height: 768 }, 44 | }, 45 | ]; 46 | 47 | const resolutions = bitmapsConfig.reduce((accumul, curVal) => { 48 | return [...accumul, curVal.resolution]; 49 | }, []); 50 | 51 | const style = "color: green; font-weight: normal"; 52 | 53 | let reqId; 54 | 55 | const messages = document.querySelector(".messages ol"); 56 | 57 | const onMessage = event => { 58 | const text = `[${NAME} <-- ${event.data.source}] - ${event.data.action}`; 59 | console.log(`%c${text}`, style); 60 | 61 | const li = makeLi({ text, style }); 62 | messages.appendChild(li); 63 | messages.lastChild.scrollIntoView(); 64 | 65 | switch (event.data.action) { 66 | case WorkerAction.BITMAPS: { 67 | const { bitmaps } = event.data.payload; 68 | bitmapsConfig.forEach((cfg, i) => { 69 | cfg.ctx.transferFromImageBitmap(bitmaps[i]); 70 | }); 71 | break; 72 | } 73 | case WorkerAction.TERMINATE_ME: { 74 | worker.terminate(); 75 | console.warn(`${NAME} terminated ${event.data.source}`); 76 | // If the web worker is no longer listening, it makes no sense to keep 77 | // sending him messages in requestLoop; 78 | cancelAnimationFrame(reqId); 79 | break; 80 | } 81 | case WorkerAction.NOTIFY: { 82 | // we have already printed the message, so we simply break. 83 | break; 84 | } 85 | default: { 86 | console.warn(`${NAME} received a message that does not handle`, event); 87 | } 88 | } 89 | }; 90 | 91 | // When a runtime error occurs in the worker, its onerror event handler is 92 | // called. It receives an event named error which implements the ErrorEvent 93 | // interface. 94 | // https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent 95 | let errorInWorker = undefined; 96 | const onError = event => { 97 | errorInWorker = event; 98 | }; 99 | 100 | worker.onmessage = onMessage; 101 | worker.onerror = onError; 102 | 103 | const message = { 104 | action: MainThreadAction.INIT_WORKER_STATE, 105 | // width and height are for the OffscreenCanvas created by the web worker. 106 | // They will also be the width and height of the generated ImageBitmap 107 | // returned by the web-worker and rendered into the canvas that has a 108 | // `bitmaprenderer` context. 109 | payload: { width: 1024, height: 768, sceneName: "My Test Scene" }, 110 | source: NAME, 111 | }; 112 | worker.postMessage(message); 113 | 114 | const li = makeLi({ 115 | text: `[${NAME} --> worker] ${message.action}`, 116 | style: "color: red; font-weight: normal", 117 | }); 118 | messages.appendChild(li); 119 | messages.lastChild.scrollIntoView(); 120 | 121 | // Up until recently, requestAnimationFrame was not available in web workers, 122 | // so using requestAnimationFrame in the main thread was one of the possible 123 | // workarounds. Now I think it would be better to move requestAnimationFrame 124 | // to the web worker, so the main thread has less work to do. 125 | const requestLoop = tick => { 126 | worker.postMessage({ 127 | action: MainThreadAction.REQUEST_BITMAPS, 128 | payload: { 129 | resolutions, 130 | }, 131 | source: NAME, 132 | }); 133 | messages.appendChild( 134 | makeLi({ 135 | text: `[${NAME} --> worker] ${MainThreadAction.REQUEST_BITMAPS}`, 136 | style: "color: red; font-weight: normal", 137 | }) 138 | ); 139 | messages.lastChild.scrollIntoView(); 140 | reqId = requestAnimationFrame(requestLoop); 141 | if (errorInWorker) { 142 | cancelAnimationFrame(reqId); 143 | } 144 | }; 145 | 146 | requestLoop(); 147 | })(); 148 | -------------------------------------------------------------------------------- /src/js/components/navbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Toggle display of the navbar dropdown for small screens. 3 | */ 4 | export function toggleMobileNav() { 5 | const nav = document.querySelector("nav"); 6 | nav.classList.toggle("nav-grid--expanded"); 7 | } 8 | -------------------------------------------------------------------------------- /src/js/constants.js: -------------------------------------------------------------------------------- 1 | export const ButtonIds = Object.freeze({ 2 | INSTANTIATE_WORKER: "instantiate-worker", 3 | START_RENDER_LOOP: "start-render-loop", 4 | STOP_RENDER_LOOP: "stop-render-loop", 5 | TERMINATE_WORKER: "terminate-worker", 6 | }); 7 | 8 | export const CanvasIds = Object.freeze({ 9 | BITMAP_LOW_RES: "low-res-bitmap-canvas", 10 | BITMAP_MEDIUM_RES: "medium-res-bitmap-canvas", 11 | BITMAP_HIGH_RES: "high-res-bitmap-canvas", 12 | TRANSFER_CONTROL: "transfer-control-canvas", 13 | }); 14 | 15 | export const unsupportedOffscreenCanvasAlertMessage = ` 16 | Your browser does not support transferControlToOffscreen and OffscreenCanvas.\n 17 | See here:\n 18 | https://caniuse.com/#feat=mdn-api_htmlcanvaselement_transfercontroltooffscreen\n 19 | https://caniuse.com/#feat=offscreencanvas`; 20 | -------------------------------------------------------------------------------- /src/js/helpers.js: -------------------------------------------------------------------------------- 1 | export const makeLi = ({ text, style }) => { 2 | const li = document.createElement("li"); 3 | li.innerText = text; 4 | li.style = style; 5 | return li; 6 | }; 7 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | import { Application } from "./application"; 2 | import { toggleMobileNav } from "./components/navbar"; 3 | import "../css/index.css"; 4 | 5 | window.toggleMobileNav = toggleMobileNav; 6 | 7 | (function iife() { 8 | new Application(); 9 | })(); 10 | -------------------------------------------------------------------------------- /src/js/transfer-demo.js: -------------------------------------------------------------------------------- 1 | import { toggleMobileNav } from "./components/navbar"; 2 | import { makeLi } from "./helpers"; 3 | import { 4 | ButtonIds, 5 | CanvasIds, 6 | unsupportedOffscreenCanvasAlertMessage, 7 | } from "./constants"; 8 | import { MainThreadAction, WorkerAction } from "./worker-actions"; 9 | import "../css/index.css"; 10 | 11 | window.toggleMobileNav = toggleMobileNav; 12 | 13 | // TODO: preload the web worker script with resource hints. Or is it done automatically by webpack's worker-loader? 14 | // const workerUrl = document.querySelector("[rel=preload][as=script]").href; 15 | 16 | (function iife() { 17 | const NAME = "Main thread"; 18 | 19 | let worker = new Worker("./workers/transfer-worker.js", { 20 | name: `transfer-worker-original`, 21 | type: "module", 22 | }); 23 | 24 | const canvas = document.getElementById(CanvasIds.TRANSFER_CONTROL); 25 | 26 | // TODO: handle resize of the canvas 27 | 28 | const handleResize = event => { 29 | console.warn(event); 30 | // const { clientWidth, clientHeight } = this.container; 31 | console.log( 32 | "handleResize", 33 | // clientWidth, 34 | // clientHeight, 35 | this 36 | ); 37 | // this.camera.aspect = clientWidth / clientHeight; 38 | // this.camera.updateProjectionMatrix(); 39 | // this.renderer.setSize(clientWidth, clientHeight); 40 | }; 41 | 42 | window.addEventListener("resize", handleResize); 43 | 44 | const messages = document.querySelector(".messages ol"); 45 | 46 | const onMessage = event => { 47 | const text = `[${NAME} <-- ${event.data.source}] - ${event.data.action}`; 48 | const style = "color: green; font-weight: normal"; 49 | console.log(`%c${text}`, style); 50 | const li = makeLi({ 51 | text, 52 | style, 53 | }); 54 | messages.appendChild(li); 55 | messages.lastChild.scrollIntoView(); 56 | 57 | switch (event.data.action) { 58 | case WorkerAction.NOTIFY: 59 | console.log(event.data.payload.info); 60 | break; 61 | case WorkerAction.TERMINATE_ME: 62 | worker.terminate(); 63 | console.warn("Main thread terminated the worker"); 64 | break; 65 | default: 66 | console.warn(`${NAME} received a message that does not handle`, event); 67 | } 68 | }; 69 | 70 | const onError = event => { 71 | console.error("Error in web worker", event); 72 | alert(`${event.message} - ${event.filename}. See console for more.`); 73 | }; 74 | 75 | worker.onmessage = onMessage; 76 | worker.onerror = onError; 77 | 78 | // The width and height of the visible canvas will be used to set the size of 79 | // WebGL drawing buffer. BUT we cannot create a WebGLRenderingContext with 80 | // canvas.getContext("webgl") here because we need to transfer the ownership 81 | // of the canvas to the web worker's OffscreenCanvas. If we try to create the 82 | // context here we get the following error: Uncaught DOMException: Failed to 83 | // execute 'transferControlToOffscreen' on 'HTMLCanvasElement': Cannot 84 | // transfer control from a canvas that has a rendering context. 85 | // So we create the WebGL rendering context in the web worker. 86 | // https://webglfundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html 87 | canvas.setAttribute("width", "1024"); 88 | canvas.setAttribute("height", "768"); 89 | if (!canvas.transferControlToOffscreen) { 90 | alert(unsupportedOffscreenCanvasAlertMessage); 91 | } 92 | const offscreenCanvas = canvas.transferControlToOffscreen(); 93 | const message = { 94 | action: MainThreadAction.INIT_WORKER_STATE, 95 | payload: { canvas: offscreenCanvas, sceneName: "my-scene" }, 96 | source: NAME, 97 | }; 98 | const transfer = [offscreenCanvas]; 99 | worker.postMessage(message, transfer); 100 | 101 | const styleFromWorker = "color: red; font-weight: normal"; 102 | messages.appendChild( 103 | makeLi({ 104 | text: `[${NAME} --> worker] ${message.action}`, 105 | style: styleFromWorker, 106 | }) 107 | ); 108 | messages.lastChild.scrollIntoView(); 109 | 110 | const startButton = document.getElementById(ButtonIds.START_RENDER_LOOP); 111 | startButton.addEventListener("click", () => { 112 | worker.postMessage({ 113 | action: MainThreadAction.START_RENDER_LOOP, 114 | source: NAME, 115 | }); 116 | messages.appendChild( 117 | makeLi({ 118 | text: `[${NAME} --> worker] ${MainThreadAction.START_RENDER_LOOP}`, 119 | style: styleFromWorker, 120 | }) 121 | ); 122 | messages.lastChild.scrollIntoView(); 123 | }); 124 | 125 | const stopButton = document.getElementById(ButtonIds.STOP_RENDER_LOOP); 126 | stopButton.addEventListener("click", () => { 127 | worker.postMessage({ 128 | action: MainThreadAction.STOP_RENDER_LOOP, 129 | source: NAME, 130 | }); 131 | messages.appendChild( 132 | makeLi({ 133 | text: `[${NAME} --> worker] ${MainThreadAction.STOP_RENDER_LOOP}`, 134 | style: styleFromWorker, 135 | }) 136 | ); 137 | messages.lastChild.scrollIntoView(); 138 | }); 139 | 140 | const terminateButton = document.getElementById(ButtonIds.TERMINATE_WORKER); 141 | terminateButton.addEventListener("click", () => { 142 | worker.terminate(); 143 | }); 144 | 145 | const instantiateButton = document.getElementById( 146 | ButtonIds.INSTANTIATE_WORKER 147 | ); 148 | instantiateButton.addEventListener("click", () => { 149 | // We create a new worker but reuse the same variable. Otherwise we would 150 | // need to redefine the onmessage and onerror handlers. 151 | const workerId = Math.ceil(Math.random() * 1000); 152 | worker = new Worker("./workers/transfer-worker.js", { 153 | name: `transfer-worker-${workerId}`, 154 | type: "module", 155 | }); 156 | // It seems that there is no way of getting the control of the canvas back, 157 | // so we clone the original canvas and replace it in the DOM with the clone. 158 | // https://stackoverflow.com/a/46575483/3036129 159 | const oldCanvas = document.getElementById(CanvasIds.TRANSFER_CONTROL); 160 | const newCanvas = oldCanvas.cloneNode(); 161 | oldCanvas.parentNode.replaceChild(newCanvas, oldCanvas); 162 | // The control of the new, cloned canvas belongs to the main thread, so we 163 | // can transfer it to the OffscreenCanvas controlled by the worker. 164 | const anotherOffscreenCanvas = newCanvas.transferControlToOffscreen(); 165 | const msg = { 166 | action: MainThreadAction.INIT_WORKER_STATE, 167 | payload: { canvas: anotherOffscreenCanvas, sceneName: "another-scene" }, 168 | source: NAME, 169 | }; 170 | worker.postMessage(msg, [anotherOffscreenCanvas]); 171 | }); 172 | })(); 173 | -------------------------------------------------------------------------------- /src/js/vendor/Detector.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author alteredq / http://alteredqualia.com/ 3 | * @author mr.doob / http://mrdoob.com/ 4 | */ 5 | 6 | var Detector = { 7 | canvas: !!window.CanvasRenderingContext2D, 8 | webgl: (function() { 9 | try { 10 | var canvas = document.createElement("canvas"); 11 | return !!( 12 | window.WebGLRenderingContext && 13 | (canvas.getContext("webgl") || canvas.getContext("experimental-webgl")) 14 | ); 15 | } catch (e) { 16 | return false; 17 | } 18 | })(), 19 | workers: !!window.Worker, 20 | fileapi: window.File && window.FileReader && window.FileList && window.Blob, 21 | 22 | getWebGLErrorMessage: function() { 23 | var element = document.createElement("div"); 24 | element.id = "webgl-error-message"; 25 | element.style.fontFamily = "monospace"; 26 | element.style.fontSize = "13px"; 27 | element.style.fontWeight = "normal"; 28 | element.style.textAlign = "center"; 29 | element.style.background = "#fff"; 30 | element.style.color = "#000"; 31 | element.style.padding = "1.5em"; 32 | element.style.width = "400px"; 33 | element.style.margin = "5em auto 0"; 34 | 35 | if (!this.webgl) { 36 | element.innerHTML = window.WebGLRenderingContext 37 | ? [ 38 | 'Your graphics card does not seem to support WebGL.
      ', 39 | 'Find out how to get it here.', 40 | ].join("\n") 41 | : [ 42 | 'Your browser does not seem to support WebGL.
      ', 43 | 'Find out how to get it here.', 44 | ].join("\n"); 45 | } 46 | 47 | return element; 48 | }, 49 | 50 | addGetWebGLMessage: function(parameters) { 51 | var parent, id, element; 52 | 53 | parameters = parameters || {}; 54 | 55 | parent = 56 | parameters.parent !== undefined ? parameters.parent : document.body; 57 | id = parameters.id !== undefined ? parameters.id : "oldie"; 58 | 59 | element = Detector.getWebGLErrorMessage(); 60 | element.id = id; 61 | 62 | parent.appendChild(element); 63 | }, 64 | }; 65 | 66 | // browserify support 67 | if (typeof module === "object") { 68 | module.exports = Detector; 69 | } 70 | -------------------------------------------------------------------------------- /src/js/vendor/OBJLoader2.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Kai Salmen / https://kaisalmen.de 3 | * Development repository: https://github.com/kaisalmen/WWOBJLoader 4 | */ 5 | 6 | import { FileLoader, Object3D, Loader } from "three"; 7 | 8 | import { OBJLoader2Parser } from "./obj2/worker/parallel/OBJLoader2Parser"; 9 | import { MeshReceiver } from "./obj2/shared/MeshReceiver"; 10 | import { MaterialHandler } from "./obj2/shared/MaterialHandler"; 11 | 12 | /** 13 | * Creates a new OBJLoader2. Use it to load OBJ data from files or to parse OBJ data from arraybuffer or text. 14 | * 15 | * @param {LoadingManager} [manager] The loadingManager for the loader to use. Default is {@link LoadingManager} 16 | * @constructor 17 | */ 18 | const OBJLoader2 = function(manager) { 19 | Loader.call(this, manager); 20 | 21 | this.parser = new OBJLoader2Parser(); 22 | 23 | this.modelName = ""; 24 | this.instanceNo = 0; 25 | this.baseObject3d = new Object3D(); 26 | 27 | this.materialHandler = new MaterialHandler(); 28 | this.meshReceiver = new MeshReceiver(this.materialHandler); 29 | 30 | // as OBJLoader2 is no longer derived from OBJLoader2Parser, we need to override the default onAssetAvailable callback 31 | let scope = this; 32 | let defaultOnAssetAvailable = function(payload) { 33 | scope._onAssetAvailable(payload); 34 | }; 35 | this.parser.setCallbackOnAssetAvailable(defaultOnAssetAvailable); 36 | }; 37 | 38 | OBJLoader2.OBJLOADER2_VERSION = "3.1.1"; 39 | console.info("Using OBJLoader2 version: " + OBJLoader2.OBJLOADER2_VERSION); 40 | 41 | OBJLoader2.prototype = Object.assign(Object.create(Loader.prototype), { 42 | constructor: OBJLoader2, 43 | 44 | /** 45 | * See {@link OBJLoader2Parser.setLogging} 46 | * @return {OBJLoader2} 47 | */ 48 | setLogging: function(enabled, debug) { 49 | this.parser.setLogging(enabled, debug); 50 | return this; 51 | }, 52 | 53 | /** 54 | * See {@link OBJLoader2Parser.setMaterialPerSmoothingGroup} 55 | * @return {OBJLoader2} 56 | */ 57 | setMaterialPerSmoothingGroup: function(materialPerSmoothingGroup) { 58 | this.parser.setMaterialPerSmoothingGroup(materialPerSmoothingGroup); 59 | return this; 60 | }, 61 | 62 | /** 63 | * See {@link OBJLoader2Parser.setUseOAsMesh} 64 | * @return {OBJLoader2} 65 | */ 66 | setUseOAsMesh: function(useOAsMesh) { 67 | this.parser.setUseOAsMesh(useOAsMesh); 68 | return this; 69 | }, 70 | 71 | /** 72 | * See {@link OBJLoader2Parser.setUseIndices} 73 | * @return {OBJLoader2} 74 | */ 75 | setUseIndices: function(useIndices) { 76 | this.parser.setUseIndices(useIndices); 77 | return this; 78 | }, 79 | 80 | /** 81 | * See {@link OBJLoader2Parser.setDisregardNormals} 82 | * @return {OBJLoader2} 83 | */ 84 | setDisregardNormals: function(disregardNormals) { 85 | this.parser.setDisregardNormals(disregardNormals); 86 | return this; 87 | }, 88 | 89 | /** 90 | * Set the name of the model. 91 | * 92 | * @param {string} modelName 93 | * @return {OBJLoader2} 94 | */ 95 | setModelName: function(modelName) { 96 | this.modelName = modelName ? modelName : this.modelName; 97 | return this; 98 | }, 99 | 100 | /** 101 | * Set the node where the loaded objects will be attached directly. 102 | * 103 | * @param {Object3D} baseObject3d Object already attached to scenegraph where new meshes will be attached to 104 | * @return {OBJLoader2} 105 | */ 106 | setBaseObject3d: function(baseObject3d) { 107 | this.baseObject3d = 108 | baseObject3d === undefined || baseObject3d === null 109 | ? this.baseObject3d 110 | : baseObject3d; 111 | return this; 112 | }, 113 | 114 | /** 115 | * Add materials as associated array. 116 | * 117 | * @param {Object} materials Object with named {@link Material} 118 | * @param overrideExisting boolean Override existing material 119 | * @return {OBJLoader2} 120 | */ 121 | addMaterials: function(materials, overrideExisting) { 122 | this.materialHandler.addMaterials(materials, overrideExisting); 123 | return this; 124 | }, 125 | 126 | /** 127 | * See {@link OBJLoader2Parser.setCallbackOnAssetAvailable} 128 | * @return {OBJLoader2} 129 | */ 130 | setCallbackOnAssetAvailable: function(onAssetAvailable) { 131 | this.parser.setCallbackOnAssetAvailable(onAssetAvailable); 132 | return this; 133 | }, 134 | 135 | /** 136 | * See {@link OBJLoader2Parser.setCallbackOnProgress} 137 | * @return {OBJLoader2} 138 | */ 139 | setCallbackOnProgress: function(onProgress) { 140 | this.parser.setCallbackOnProgress(onProgress); 141 | return this; 142 | }, 143 | 144 | /** 145 | * See {@link OBJLoader2Parser.setCallbackOnError} 146 | * @return {OBJLoader2} 147 | */ 148 | setCallbackOnError: function(onError) { 149 | this.parser.setCallbackOnError(onError); 150 | return this; 151 | }, 152 | 153 | /** 154 | * See {@link OBJLoader2Parser.setCallbackOnLoad} 155 | * @return {OBJLoader2} 156 | */ 157 | setCallbackOnLoad: function(onLoad) { 158 | this.parser.setCallbackOnLoad(onLoad); 159 | return this; 160 | }, 161 | 162 | /** 163 | * Register a function that is called once a single mesh is available and it could be altered by the supplied function. 164 | * 165 | * @param {Function} [onMeshAlter] 166 | * @return {OBJLoader2} 167 | */ 168 | setCallbackOnMeshAlter: function(onMeshAlter) { 169 | this.meshReceiver._setCallbacks( 170 | this.parser.callbacks.onProgress, 171 | onMeshAlter 172 | ); 173 | return this; 174 | }, 175 | 176 | /** 177 | * Register a function that is called once all materials have been loaded and they could be altered by the supplied function. 178 | * 179 | * @param {Function} [onLoadMaterials] 180 | * @return {OBJLoader2} 181 | */ 182 | setCallbackOnLoadMaterials: function(onLoadMaterials) { 183 | this.materialHandler._setCallbacks(onLoadMaterials); 184 | return this; 185 | }, 186 | 187 | /** 188 | * Use this convenient method to load a file at the given URL. By default the fileLoader uses an ArrayBuffer. 189 | * 190 | * @param {string} url A string containing the path/URL of the file to be loaded. 191 | * @param {function} onLoad A function to be called after loading is successfully completed. The function receives loaded Object3D as an argument. 192 | * @param {function} [onFileLoadProgress] A function to be called while the loading is in progress. The argument will be the XMLHttpRequest instance, which contains total and Integer bytes. 193 | * @param {function} [onError] A function to be called if an error occurs during loading. The function receives the error as an argument. 194 | * @param {function} [onMeshAlter] Called after every single mesh is made available by the parser 195 | */ 196 | load: function(url, onLoad, onFileLoadProgress, onError, onMeshAlter) { 197 | let scope = this; 198 | if ( 199 | onLoad === null || 200 | onLoad === undefined || 201 | !(onLoad instanceof Function) 202 | ) { 203 | let errorMessage = "onLoad is not a function! Aborting..."; 204 | scope.parser.callbacks.onError(errorMessage); 205 | throw errorMessage; 206 | } else { 207 | this.parser.setCallbackOnLoad(onLoad); 208 | } 209 | if ( 210 | onError === null || 211 | onError === undefined || 212 | !(onError instanceof Function) 213 | ) { 214 | onError = function(event) { 215 | let errorMessage = event; 216 | if (event.currentTarget && event.currentTarget.statusText !== null) { 217 | errorMessage = 218 | "Error occurred while downloading!\nurl: " + 219 | event.currentTarget.responseURL + 220 | "\nstatus: " + 221 | event.currentTarget.statusText; 222 | } 223 | scope.parser.callbacks.onError(errorMessage); 224 | }; 225 | } 226 | if (!url) { 227 | onError("An invalid url was provided. Unable to continue!"); 228 | } 229 | 230 | // This does not work in a web worker because window is not available. 231 | // let urlFull = new URL(url, window.location.href).href; 232 | const urlFull = new URL(url).href; 233 | 234 | let filename = urlFull; 235 | let urlParts = urlFull.split("/"); 236 | if (urlParts.length > 2) { 237 | filename = urlParts[urlParts.length - 1]; 238 | let urlPartsPath = urlParts.slice(0, urlParts.length - 1).join("/") + "/"; 239 | if (urlPartsPath !== undefined && urlPartsPath !== null) 240 | this.path = urlPartsPath; 241 | } 242 | if ( 243 | onFileLoadProgress === null || 244 | onFileLoadProgress === undefined || 245 | !(onFileLoadProgress instanceof Function) 246 | ) { 247 | let numericalValueRef = 0; 248 | let numericalValue = 0; 249 | onFileLoadProgress = function(event) { 250 | if (!event.lengthComputable) return; 251 | 252 | numericalValue = event.loaded / event.total; 253 | if (numericalValue > numericalValueRef) { 254 | numericalValueRef = numericalValue; 255 | let output = 256 | 'Download of "' + 257 | url + 258 | '": ' + 259 | (numericalValue * 100).toFixed(2) + 260 | "%"; 261 | scope.parser.callbacks.onProgress( 262 | "progressLoad", 263 | output, 264 | numericalValue 265 | ); 266 | } 267 | }; 268 | } 269 | 270 | this.setCallbackOnMeshAlter(onMeshAlter); 271 | let fileLoaderOnLoad = function(content) { 272 | scope.parser.callbacks.onLoad( 273 | scope.parse(content), 274 | "OBJLoader2#load: Parsing completed" 275 | ); 276 | }; 277 | let fileLoader = new FileLoader(this.manager); 278 | fileLoader.setPath(this.path || this.resourcePath); 279 | fileLoader.setResponseType("arraybuffer"); 280 | fileLoader.load(filename, fileLoaderOnLoad, onFileLoadProgress, onError); 281 | }, 282 | 283 | /** 284 | * Parses OBJ data synchronously from arraybuffer or string and returns the {@link Object3D}. 285 | * 286 | * @param {arraybuffer|string} content OBJ data as Uint8Array or String 287 | * @return {Object3D} 288 | */ 289 | parse: function(content) { 290 | // fast-fail in case of illegal data 291 | if (content === null || content === undefined) { 292 | throw "Provided content is not a valid ArrayBuffer or String. Unable to continue parsing"; 293 | } 294 | if (this.parser.logging.enabled) { 295 | console.time("OBJLoader parse: " + this.modelName); 296 | } 297 | 298 | // Create default materials beforehand, but do not override previously set materials (e.g. during init) 299 | this.materialHandler.createDefaultMaterials(false); 300 | 301 | // code works directly on the material references, parser clear its materials before updating 302 | this.parser.setMaterials(this.materialHandler.getMaterials()); 303 | 304 | if (content instanceof ArrayBuffer || content instanceof Uint8Array) { 305 | if (this.parser.logging.enabled) console.info("Parsing arrayBuffer..."); 306 | this.parser.execute(content); 307 | } else if (typeof content === "string" || content instanceof String) { 308 | if (this.parser.logging.enabled) console.info("Parsing text..."); 309 | this.parser.executeLegacy(content); 310 | } else { 311 | this.parser.callbacks.onError( 312 | "Provided content was neither of type String nor Uint8Array! Aborting..." 313 | ); 314 | } 315 | if (this.parser.logging.enabled) { 316 | console.timeEnd("OBJLoader parse: " + this.modelName); 317 | } 318 | return this.baseObject3d; 319 | }, 320 | 321 | _onAssetAvailable: function(payload) { 322 | if (payload.cmd !== "assetAvailable") return; 323 | 324 | if (payload.type === "mesh") { 325 | let meshes = this.meshReceiver.buildMeshes(payload); 326 | for (let mesh of meshes) { 327 | this.baseObject3d.add(mesh); 328 | } 329 | } else if (payload.type === "material") { 330 | this.materialHandler.addPayloadMaterials(payload); 331 | } 332 | }, 333 | }); 334 | 335 | export { OBJLoader2 }; 336 | -------------------------------------------------------------------------------- /src/js/vendor/obj2/shared/MaterialHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Kai Salmen / https://kaisalmen.de 3 | * Development repository: https://github.com/kaisalmen/WWOBJLoader 4 | */ 5 | import { 6 | LineBasicMaterial, 7 | MaterialLoader, 8 | MeshStandardMaterial, 9 | PointsMaterial, 10 | VertexColors, 11 | } from "three"; 12 | 13 | const MaterialHandler = function() { 14 | this.logging = { 15 | enabled: false, 16 | debug: false, 17 | }; 18 | 19 | this.callbacks = { 20 | onLoadMaterials: null, 21 | }; 22 | this.materials = {}; 23 | }; 24 | 25 | MaterialHandler.prototype = { 26 | constructor: MaterialHandler, 27 | 28 | /** 29 | * Enable or disable logging in general (except warn and error), plus enable or disable debug logging. 30 | * 31 | * @param {boolean} enabled True or false. 32 | * @param {boolean} debug True or false. 33 | */ 34 | setLogging: function(enabled, debug) { 35 | this.logging.enabled = enabled === true; 36 | this.logging.debug = debug === true; 37 | }, 38 | 39 | _setCallbacks: function(onLoadMaterials) { 40 | if ( 41 | onLoadMaterials !== undefined && 42 | onLoadMaterials !== null && 43 | onLoadMaterials instanceof Function 44 | ) { 45 | this.callbacks.onLoadMaterials = onLoadMaterials; 46 | } 47 | }, 48 | 49 | /** 50 | * Creates default materials and adds them to the materials object. 51 | * 52 | * @param overrideExisting boolean Override existing material 53 | */ 54 | createDefaultMaterials: function(overrideExisting) { 55 | let defaultMaterial = new MeshStandardMaterial({ color: 0xdcf1ff }); 56 | defaultMaterial.name = "defaultMaterial"; 57 | 58 | let defaultVertexColorMaterial = new MeshStandardMaterial({ 59 | color: 0xdcf1ff, 60 | }); 61 | defaultVertexColorMaterial.name = "defaultVertexColorMaterial"; 62 | defaultVertexColorMaterial.vertexColors = VertexColors; 63 | 64 | let defaultLineMaterial = new LineBasicMaterial(); 65 | defaultLineMaterial.name = "defaultLineMaterial"; 66 | 67 | let defaultPointMaterial = new PointsMaterial({ size: 0.1 }); 68 | defaultPointMaterial.name = "defaultPointMaterial"; 69 | 70 | let runtimeMaterials = {}; 71 | runtimeMaterials[defaultMaterial.name] = defaultMaterial; 72 | runtimeMaterials[ 73 | defaultVertexColorMaterial.name 74 | ] = defaultVertexColorMaterial; 75 | runtimeMaterials[defaultLineMaterial.name] = defaultLineMaterial; 76 | runtimeMaterials[defaultPointMaterial.name] = defaultPointMaterial; 77 | 78 | this.addMaterials(runtimeMaterials, overrideExisting); 79 | }, 80 | 81 | /** 82 | * Updates the materials with contained material objects (sync) or from alteration instructions (async). 83 | * 84 | * @param {Object} materialPayload Material update instructions 85 | * @returns {Object} Map of {@link Material} 86 | */ 87 | addPayloadMaterials: function(materialPayload) { 88 | let material, materialName; 89 | let materialCloneInstructions = 90 | materialPayload.materials.materialCloneInstructions; 91 | let newMaterials = {}; 92 | 93 | if ( 94 | materialCloneInstructions !== undefined && 95 | materialCloneInstructions !== null 96 | ) { 97 | let materialNameOrg = materialCloneInstructions.materialNameOrg; 98 | materialNameOrg = 99 | materialNameOrg !== undefined && materialNameOrg !== null 100 | ? materialNameOrg 101 | : ""; 102 | let materialOrg = this.materials[materialNameOrg]; 103 | if (materialOrg) { 104 | material = materialOrg.clone(); 105 | 106 | materialName = materialCloneInstructions.materialName; 107 | material.name = materialName; 108 | 109 | Object.assign(material, materialCloneInstructions.materialProperties); 110 | 111 | this.materials[materialName] = material; 112 | newMaterials[materialName] = material; 113 | } else { 114 | if (this.logging.enabled) { 115 | console.info( 116 | 'Requested material "' + materialNameOrg + '" is not available!' 117 | ); 118 | } 119 | } 120 | } 121 | 122 | let materials = materialPayload.materials.serializedMaterials; 123 | if ( 124 | materials !== undefined && 125 | materials !== null && 126 | Object.keys(materials).length > 0 127 | ) { 128 | let loader = new MaterialLoader(); 129 | let materialJson; 130 | for (materialName in materials) { 131 | materialJson = materials[materialName]; 132 | if (materialJson !== undefined && materialJson !== null) { 133 | material = loader.parse(materialJson); 134 | if (this.logging.enabled) { 135 | console.info( 136 | 'De-serialized material with name "' + 137 | materialName + 138 | '" will be added.' 139 | ); 140 | } 141 | this.materials[materialName] = material; 142 | newMaterials[materialName] = material; 143 | } 144 | } 145 | } 146 | materials = materialPayload.materials.runtimeMaterials; 147 | newMaterials = this.addMaterials(materials, true, newMaterials); 148 | 149 | return newMaterials; 150 | }, 151 | 152 | /** 153 | * Set materials loaded by any supplier of an Array of {@link Material}. 154 | * 155 | * @param materials Object with named {@link Material} 156 | * @param overrideExisting boolean Override existing material 157 | * @param newMaterials [Object] with named {@link Material} 158 | */ 159 | addMaterials: function(materials, overrideExisting, newMaterials) { 160 | if (newMaterials === undefined || newMaterials === null) { 161 | newMaterials = {}; 162 | } 163 | if ( 164 | materials !== undefined && 165 | materials !== null && 166 | Object.keys(materials).length > 0 167 | ) { 168 | let material; 169 | let existingMaterial; 170 | let add; 171 | for (let materialName in materials) { 172 | material = materials[materialName]; 173 | add = overrideExisting === true; 174 | if (!add) { 175 | existingMaterial = this.materials[materialName]; 176 | add = existingMaterial === null || existingMaterial === undefined; 177 | } 178 | if (add) { 179 | this.materials[materialName] = material; 180 | newMaterials[materialName] = material; 181 | } 182 | if (this.logging.enabled && this.logging.debug) { 183 | console.info('Material with name "' + materialName + '" was added.'); 184 | } 185 | } 186 | } 187 | 188 | if (this.callbacks.onLoadMaterials) { 189 | this.callbacks.onLoadMaterials(newMaterials); 190 | } 191 | return newMaterials; 192 | }, 193 | 194 | /** 195 | * Returns the mapping object of material name and corresponding material. 196 | * 197 | * @returns {Object} Map of {@link Material} 198 | */ 199 | getMaterials: function() { 200 | return this.materials; 201 | }, 202 | 203 | /** 204 | * 205 | * @param {String} materialName 206 | * @returns {Material} 207 | */ 208 | getMaterial: function(materialName) { 209 | return this.materials[materialName]; 210 | }, 211 | 212 | /** 213 | * Returns the mapping object of material name and corresponding jsonified material. 214 | * 215 | * @returns {Object} Map of Materials in JSON representation 216 | */ 217 | getMaterialsJSON: function() { 218 | let materialsJSON = {}; 219 | let material; 220 | for (let materialName in this.materials) { 221 | material = this.materials[materialName]; 222 | materialsJSON[materialName] = material.toJSON(); 223 | } 224 | 225 | return materialsJSON; 226 | }, 227 | 228 | /** 229 | * Removes all materials 230 | */ 231 | clearMaterials: function() { 232 | this.materials = {}; 233 | }, 234 | }; 235 | 236 | export { MaterialHandler }; 237 | -------------------------------------------------------------------------------- /src/js/vendor/obj2/shared/MeshReceiver.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Kai Salmen / https://kaisalmen.de 3 | * Development repository: https://github.com/kaisalmen/WWOBJLoader 4 | */ 5 | 6 | import { 7 | BufferAttribute, 8 | BufferGeometry, 9 | LineSegments, 10 | Mesh, 11 | Points, 12 | } from "three"; 13 | 14 | /** 15 | * 16 | * @param {MaterialHandler} materialHandler 17 | * @constructor 18 | */ 19 | const MeshReceiver = function(materialHandler) { 20 | this.logging = { 21 | enabled: false, 22 | debug: false, 23 | }; 24 | 25 | this.callbacks = { 26 | onProgress: null, 27 | onMeshAlter: null, 28 | }; 29 | this.materialHandler = materialHandler; 30 | }; 31 | 32 | MeshReceiver.prototype = { 33 | constructor: MeshReceiver, 34 | 35 | /** 36 | * Enable or disable logging in general (except warn and error), plus enable or disable debug logging. 37 | * 38 | * @param {boolean} enabled True or false. 39 | * @param {boolean} debug True or false. 40 | */ 41 | setLogging: function(enabled, debug) { 42 | this.logging.enabled = enabled === true; 43 | this.logging.debug = debug === true; 44 | }, 45 | 46 | /** 47 | * 48 | * @param {Function} onProgress 49 | * @param {Function} onMeshAlter 50 | * @private 51 | */ 52 | _setCallbacks: function(onProgress, onMeshAlter) { 53 | if ( 54 | onProgress !== null && 55 | onProgress !== undefined && 56 | onProgress instanceof Function 57 | ) { 58 | this.callbacks.onProgress = onProgress; 59 | } 60 | if ( 61 | onMeshAlter !== null && 62 | onMeshAlter !== undefined && 63 | onMeshAlter instanceof Function 64 | ) { 65 | this.callbacks.onMeshAlter = onMeshAlter; 66 | } 67 | }, 68 | 69 | /** 70 | * Builds one or multiple meshes from the data described in the payload (buffers, params, material info). 71 | * 72 | * @param {Object} meshPayload Raw mesh description (buffers, params, materials) used to build one to many meshes. 73 | * @returns {Mesh[]} mesh Array of {@link Mesh} 74 | */ 75 | buildMeshes: function(meshPayload) { 76 | let meshName = meshPayload.params.meshName; 77 | let buffers = meshPayload.buffers; 78 | 79 | let bufferGeometry = new BufferGeometry(); 80 | if (buffers.vertices !== undefined && buffers.vertices !== null) { 81 | bufferGeometry.setAttribute( 82 | "position", 83 | new BufferAttribute(new Float32Array(buffers.vertices), 3) 84 | ); 85 | } 86 | if (buffers.indices !== undefined && buffers.indices !== null) { 87 | bufferGeometry.setIndex( 88 | new BufferAttribute(new Uint32Array(buffers.indices), 1) 89 | ); 90 | } 91 | if (buffers.colors !== undefined && buffers.colors !== null) { 92 | bufferGeometry.setAttribute( 93 | "color", 94 | new BufferAttribute(new Float32Array(buffers.colors), 3) 95 | ); 96 | } 97 | if (buffers.normals !== undefined && buffers.normals !== null) { 98 | bufferGeometry.setAttribute( 99 | "normal", 100 | new BufferAttribute(new Float32Array(buffers.normals), 3) 101 | ); 102 | } else { 103 | bufferGeometry.computeVertexNormals(); 104 | } 105 | if (buffers.uvs !== undefined && buffers.uvs !== null) { 106 | bufferGeometry.setAttribute( 107 | "uv", 108 | new BufferAttribute(new Float32Array(buffers.uvs), 2) 109 | ); 110 | } 111 | if (buffers.skinIndex !== undefined && buffers.skinIndex !== null) { 112 | bufferGeometry.setAttribute( 113 | "skinIndex", 114 | new BufferAttribute(new Uint16Array(buffers.skinIndex), 4) 115 | ); 116 | } 117 | if (buffers.skinWeight !== undefined && buffers.skinWeight !== null) { 118 | bufferGeometry.setAttribute( 119 | "skinWeight", 120 | new BufferAttribute(new Float32Array(buffers.skinWeight), 4) 121 | ); 122 | } 123 | 124 | let material, materialName, key; 125 | let materialNames = meshPayload.materials.materialNames; 126 | let createMultiMaterial = meshPayload.materials.multiMaterial; 127 | let multiMaterials = []; 128 | for (key in materialNames) { 129 | materialName = materialNames[key]; 130 | material = this.materialHandler.getMaterial(materialName); 131 | if (createMultiMaterial) multiMaterials.push(material); 132 | } 133 | if (createMultiMaterial) { 134 | material = multiMaterials; 135 | let materialGroups = meshPayload.materials.materialGroups; 136 | let materialGroup; 137 | for (key in materialGroups) { 138 | materialGroup = materialGroups[key]; 139 | bufferGeometry.addGroup( 140 | materialGroup.start, 141 | materialGroup.count, 142 | materialGroup.index 143 | ); 144 | } 145 | } 146 | 147 | let meshes = []; 148 | let mesh; 149 | let callbackOnMeshAlterResult; 150 | let useOrgMesh = true; 151 | let geometryType = 152 | meshPayload.geometryType === null ? 0 : meshPayload.geometryType; 153 | 154 | if (this.callbacks.onMeshAlter) { 155 | callbackOnMeshAlterResult = this.callbacks.onMeshAlter({ 156 | detail: { 157 | meshName: meshName, 158 | bufferGeometry: bufferGeometry, 159 | material: material, 160 | geometryType: geometryType, 161 | }, 162 | }); 163 | } 164 | 165 | // here LoadedMeshUserOverride is required to be provided by the callback used to alter the results 166 | if (callbackOnMeshAlterResult) { 167 | if (callbackOnMeshAlterResult.isDisregardMesh()) { 168 | useOrgMesh = false; 169 | } else if (callbackOnMeshAlterResult.providesAlteredMeshes()) { 170 | for (let i in callbackOnMeshAlterResult.meshes) { 171 | meshes.push(callbackOnMeshAlterResult.meshes[i]); 172 | } 173 | useOrgMesh = false; 174 | } 175 | } 176 | if (useOrgMesh) { 177 | if (meshPayload.computeBoundingSphere) 178 | bufferGeometry.computeBoundingSphere(); 179 | if (geometryType === 0) { 180 | mesh = new Mesh(bufferGeometry, material); 181 | } else if (geometryType === 1) { 182 | mesh = new LineSegments(bufferGeometry, material); 183 | } else { 184 | mesh = new Points(bufferGeometry, material); 185 | } 186 | mesh.name = meshName; 187 | meshes.push(mesh); 188 | } 189 | 190 | let progressMessage = meshPayload.params.meshName; 191 | if (meshes.length > 0) { 192 | let meshNames = []; 193 | for (let i in meshes) { 194 | mesh = meshes[i]; 195 | meshNames[i] = mesh.name; 196 | } 197 | progressMessage += 198 | ": Adding mesh(es) (" + 199 | meshNames.length + 200 | ": " + 201 | meshNames + 202 | ") from input mesh: " + 203 | meshName; 204 | progressMessage += 205 | " (" + (meshPayload.progress.numericalValue * 100).toFixed(2) + "%)"; 206 | } else { 207 | progressMessage += ": Not adding mesh: " + meshName; 208 | progressMessage += 209 | " (" + (meshPayload.progress.numericalValue * 100).toFixed(2) + "%)"; 210 | } 211 | if (this.callbacks.onProgress) { 212 | this.callbacks.onProgress( 213 | "progress", 214 | progressMessage, 215 | meshPayload.progress.numericalValue 216 | ); 217 | } 218 | 219 | return meshes; 220 | }, 221 | }; 222 | 223 | /** 224 | * Object to return by callback onMeshAlter. Used to disregard a certain mesh or to return one to many meshes. 225 | * @class 226 | * 227 | * @param {boolean} disregardMesh=false Tell implementation to completely disregard this mesh 228 | * @param {boolean} disregardMesh=false Tell implementation that mesh(es) have been altered or added 229 | */ 230 | const LoadedMeshUserOverride = function(disregardMesh, alteredMesh) { 231 | this.disregardMesh = disregardMesh === true; 232 | this.alteredMesh = alteredMesh === true; 233 | this.meshes = []; 234 | }; 235 | 236 | LoadedMeshUserOverride.prototype = { 237 | constructor: LoadedMeshUserOverride, 238 | 239 | /** 240 | * Add a mesh created within callback. 241 | * 242 | * @param {Mesh} mesh 243 | */ 244 | addMesh: function(mesh) { 245 | this.meshes.push(mesh); 246 | this.alteredMesh = true; 247 | }, 248 | 249 | /** 250 | * Answers if mesh shall be disregarded completely. 251 | * 252 | * @returns {boolean} 253 | */ 254 | isDisregardMesh: function() { 255 | return this.disregardMesh; 256 | }, 257 | 258 | /** 259 | * Answers if new mesh(es) were created. 260 | * 261 | * @returns {boolean} 262 | */ 263 | providesAlteredMeshes: function() { 264 | return this.alteredMesh; 265 | }, 266 | }; 267 | 268 | export { MeshReceiver, LoadedMeshUserOverride }; 269 | -------------------------------------------------------------------------------- /src/js/vendor/obj2/worker/parallel/OBJLoader2Parser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Kai Salmen / https://kaisalmen.de 3 | * Development repository: https://github.com/kaisalmen/WWOBJLoader 4 | */ 5 | 6 | /** 7 | * Parse OBJ data either from ArrayBuffer or string 8 | */ 9 | const OBJLoader2Parser = function() { 10 | this.logging = { 11 | enabled: false, 12 | debug: false, 13 | }; 14 | 15 | let scope = this; 16 | this.callbacks = { 17 | onProgress: function(text) { 18 | scope._onProgress(text); 19 | }, 20 | onAssetAvailable: function(payload) { 21 | scope._onAssetAvailable(payload); 22 | }, 23 | onError: function(errorMessage) { 24 | scope._onError(errorMessage); 25 | }, 26 | onLoad: function(object3d, message) { 27 | scope._onLoad(object3d, message); 28 | }, 29 | }; 30 | this.contentRef = null; 31 | this.legacyMode = false; 32 | 33 | this.materials = {}; 34 | this.materialPerSmoothingGroup = false; 35 | this.useOAsMesh = false; 36 | this.useIndices = false; 37 | this.disregardNormals = false; 38 | 39 | this.vertices = []; 40 | this.colors = []; 41 | this.normals = []; 42 | this.uvs = []; 43 | 44 | this.rawMesh = { 45 | objectName: "", 46 | groupName: "", 47 | activeMtlName: "", 48 | mtllibName: "", 49 | 50 | // reset with new mesh 51 | faceType: -1, 52 | subGroups: [], 53 | subGroupInUse: null, 54 | smoothingGroup: { 55 | splitMaterials: false, 56 | normalized: -1, 57 | real: -1, 58 | }, 59 | counts: { 60 | doubleIndicesCount: 0, 61 | faceCount: 0, 62 | mtlCount: 0, 63 | smoothingGroupCount: 0, 64 | }, 65 | }; 66 | 67 | this.inputObjectCount = 1; 68 | this.outputObjectCount = 1; 69 | this.globalCounts = { 70 | vertices: 0, 71 | faces: 0, 72 | doubleIndicesCount: 0, 73 | lineByte: 0, 74 | currentByte: 0, 75 | totalBytes: 0, 76 | }; 77 | }; 78 | 79 | OBJLoader2Parser.prototype = { 80 | constructor: OBJLoader2Parser, 81 | 82 | _resetRawMesh: function() { 83 | // faces are stored according combined index of group, material and smoothingGroup (0 or not) 84 | this.rawMesh.subGroups = []; 85 | this.rawMesh.subGroupInUse = null; 86 | this.rawMesh.smoothingGroup.normalized = -1; 87 | this.rawMesh.smoothingGroup.real = -1; 88 | 89 | // this default index is required as it is possible to define faces without 'g' or 'usemtl' 90 | this._pushSmoothingGroup(1); 91 | 92 | this.rawMesh.counts.doubleIndicesCount = 0; 93 | this.rawMesh.counts.faceCount = 0; 94 | this.rawMesh.counts.mtlCount = 0; 95 | this.rawMesh.counts.smoothingGroupCount = 0; 96 | }, 97 | 98 | /** 99 | * Tells whether a material shall be created per smoothing group. 100 | * 101 | * @param {boolean} materialPerSmoothingGroup=false 102 | * @return {OBJLoader2Parser} 103 | */ 104 | setMaterialPerSmoothingGroup: function(materialPerSmoothingGroup) { 105 | this.materialPerSmoothingGroup = materialPerSmoothingGroup === true; 106 | return this; 107 | }, 108 | 109 | /** 110 | * Usually 'o' is meta-information and does not result in creation of new meshes, but mesh creation on occurrence of "o" can be enforced. 111 | * 112 | * @param {boolean} useOAsMesh=false 113 | * @return {OBJLoader2Parser} 114 | */ 115 | setUseOAsMesh: function(useOAsMesh) { 116 | this.useOAsMesh = useOAsMesh === true; 117 | return this; 118 | }, 119 | 120 | /** 121 | * Instructs loaders to create indexed {@link BufferGeometry}. 122 | * 123 | * @param {boolean} useIndices=false 124 | * @return {OBJLoader2Parser} 125 | */ 126 | setUseIndices: function(useIndices) { 127 | this.useIndices = useIndices === true; 128 | return this; 129 | }, 130 | 131 | /** 132 | * Tells whether normals should be completely disregarded and regenerated. 133 | * 134 | * @param {boolean} disregardNormals=false 135 | * @return {OBJLoader2Parser} 136 | */ 137 | setDisregardNormals: function(disregardNormals) { 138 | this.disregardNormals = disregardNormals === true; 139 | return this; 140 | }, 141 | 142 | /** 143 | * Clears materials object and sets the new ones. 144 | * 145 | * @param {Object} materials Object with named materials 146 | */ 147 | setMaterials: function(materials) { 148 | this.materials = Object.assign({}, materials); 149 | }, 150 | 151 | /** 152 | * Register a function that is called once an asset (mesh/material) becomes available. 153 | * 154 | * @param onAssetAvailable 155 | * @return {OBJLoader2Parser} 156 | */ 157 | setCallbackOnAssetAvailable: function(onAssetAvailable) { 158 | if ( 159 | onAssetAvailable !== null && 160 | onAssetAvailable !== undefined && 161 | onAssetAvailable instanceof Function 162 | ) { 163 | this.callbacks.onAssetAvailable = onAssetAvailable; 164 | } 165 | return this; 166 | }, 167 | 168 | /** 169 | * Register a function that is used to report overall processing progress. 170 | * 171 | * @param {Function} onProgress 172 | * @return {OBJLoader2Parser} 173 | */ 174 | setCallbackOnProgress: function(onProgress) { 175 | if ( 176 | onProgress !== null && 177 | onProgress !== undefined && 178 | onProgress instanceof Function 179 | ) { 180 | this.callbacks.onProgress = onProgress; 181 | } 182 | return this; 183 | }, 184 | 185 | /** 186 | * Register an error handler function that is called if errors occur. It can decide to just log or to throw an exception. 187 | * 188 | * @param {Function} onError 189 | * @return {OBJLoader2Parser} 190 | */ 191 | setCallbackOnError: function(onError) { 192 | if ( 193 | onError !== null && 194 | onError !== undefined && 195 | onError instanceof Function 196 | ) { 197 | this.callbacks.onError = onError; 198 | } 199 | return this; 200 | }, 201 | 202 | /** 203 | * Register a function that is called when parsing was completed. 204 | * 205 | * @param {Function} onLoad 206 | * @return {OBJLoader2Parser} 207 | */ 208 | setCallbackOnLoad: function(onLoad) { 209 | if (onLoad !== null && onLoad !== undefined && onLoad instanceof Function) { 210 | this.callbacks.onLoad = onLoad; 211 | } 212 | return this; 213 | }, 214 | 215 | /** 216 | * Announce parse progress feedback which is logged to the console. 217 | * @private 218 | * 219 | * @param {string} text Textual description of the event 220 | */ 221 | _onProgress: function(text) { 222 | let message = text ? text : ""; 223 | if (this.logging.enabled && this.logging.debug) { 224 | console.log(message); 225 | } 226 | }, 227 | 228 | /** 229 | * Announce error feedback which is logged as error message. 230 | * @private 231 | * 232 | * @param {String} errorMessage The event containing the error 233 | */ 234 | _onError: function(errorMessage) { 235 | if (this.logging.enabled && this.logging.debug) { 236 | console.error(errorMessage); 237 | } 238 | }, 239 | 240 | _onAssetAvailable: function(payload) { 241 | let errorMessage = 242 | "OBJLoader2Parser does not provide implementation for onAssetAvailable. Aborting..."; 243 | this.callbacks.onError(errorMessage); 244 | throw errorMessage; 245 | }, 246 | 247 | _onLoad: function(object3d, message) { 248 | console.log("You reached parser default onLoad callback: " + message); 249 | }, 250 | 251 | /** 252 | * Enable or disable logging in general (except warn and error), plus enable or disable debug logging. 253 | * 254 | * @param {boolean} enabled True or false. 255 | * @param {boolean} debug True or false. 256 | * 257 | * @return {OBJLoader2Parser} 258 | */ 259 | setLogging: function(enabled, debug) { 260 | this.logging.enabled = enabled === true; 261 | this.logging.debug = debug === true; 262 | return this; 263 | }, 264 | 265 | _configure: function() { 266 | this._pushSmoothingGroup(1); 267 | if (this.logging.enabled) { 268 | let matKeys = Object.keys(this.materials); 269 | let matNames = 270 | matKeys.length > 0 271 | ? "\n\tmaterialNames:\n\t\t- " + matKeys.join("\n\t\t- ") 272 | : "\n\tmaterialNames: None"; 273 | let printedConfig = 274 | "OBJLoader.Parser configuration:" + 275 | matNames + 276 | "\n\tmaterialPerSmoothingGroup: " + 277 | this.materialPerSmoothingGroup + 278 | "\n\tuseOAsMesh: " + 279 | this.useOAsMesh + 280 | "\n\tuseIndices: " + 281 | this.useIndices + 282 | "\n\tdisregardNormals: " + 283 | this.disregardNormals; 284 | printedConfig += 285 | "\n\tcallbacks.onProgress: " + this.callbacks.onProgress.name; 286 | printedConfig += 287 | "\n\tcallbacks.onAssetAvailable: " + 288 | this.callbacks.onAssetAvailable.name; 289 | printedConfig += "\n\tcallbacks.onError: " + this.callbacks.onError.name; 290 | console.info(printedConfig); 291 | } 292 | }, 293 | 294 | /** 295 | * Parse the provided arraybuffer 296 | * 297 | * @param {Uint8Array} arrayBuffer OBJ data as Uint8Array 298 | */ 299 | execute: function(arrayBuffer) { 300 | if (this.logging.enabled) console.time("OBJLoader2Parser.execute"); 301 | this._configure(); 302 | 303 | let arrayBufferView = new Uint8Array(arrayBuffer); 304 | this.contentRef = arrayBufferView; 305 | let length = arrayBufferView.byteLength; 306 | this.globalCounts.totalBytes = length; 307 | let buffer = new Array(128); 308 | 309 | for ( 310 | let code, word = "", bufferPointer = 0, slashesCount = 0, i = 0; 311 | i < length; 312 | i++ 313 | ) { 314 | code = arrayBufferView[i]; 315 | switch (code) { 316 | // space 317 | case 32: 318 | if (word.length > 0) buffer[bufferPointer++] = word; 319 | word = ""; 320 | break; 321 | // slash 322 | case 47: 323 | if (word.length > 0) buffer[bufferPointer++] = word; 324 | slashesCount++; 325 | word = ""; 326 | break; 327 | 328 | // LF 329 | case 10: 330 | if (word.length > 0) buffer[bufferPointer++] = word; 331 | word = ""; 332 | this.globalCounts.lineByte = this.globalCounts.currentByte; 333 | this.globalCounts.currentByte = i; 334 | this._processLine(buffer, bufferPointer, slashesCount); 335 | bufferPointer = 0; 336 | slashesCount = 0; 337 | break; 338 | 339 | // CR 340 | case 13: 341 | break; 342 | 343 | default: 344 | word += String.fromCharCode(code); 345 | break; 346 | } 347 | } 348 | this._finalizeParsing(); 349 | if (this.logging.enabled) console.timeEnd("OBJLoader2Parser.execute"); 350 | }, 351 | 352 | /** 353 | * Parse the provided text 354 | * 355 | * @param {string} text OBJ data as string 356 | */ 357 | executeLegacy: function(text) { 358 | if (this.logging.enabled) console.time("OBJLoader2Parser.executeLegacy"); 359 | this._configure(); 360 | this.legacyMode = true; 361 | this.contentRef = text; 362 | let length = text.length; 363 | this.globalCounts.totalBytes = length; 364 | let buffer = new Array(128); 365 | 366 | for ( 367 | let char, word = "", bufferPointer = 0, slashesCount = 0, i = 0; 368 | i < length; 369 | i++ 370 | ) { 371 | char = text[i]; 372 | switch (char) { 373 | case " ": 374 | if (word.length > 0) buffer[bufferPointer++] = word; 375 | word = ""; 376 | break; 377 | 378 | case "/": 379 | if (word.length > 0) buffer[bufferPointer++] = word; 380 | slashesCount++; 381 | word = ""; 382 | break; 383 | 384 | case "\n": 385 | if (word.length > 0) buffer[bufferPointer++] = word; 386 | word = ""; 387 | this.globalCounts.lineByte = this.globalCounts.currentByte; 388 | this.globalCounts.currentByte = i; 389 | this._processLine(buffer, bufferPointer, slashesCount); 390 | bufferPointer = 0; 391 | slashesCount = 0; 392 | break; 393 | 394 | case "\r": 395 | break; 396 | 397 | default: 398 | word += char; 399 | } 400 | } 401 | this._finalizeParsing(); 402 | if (this.logging.enabled) console.timeEnd("OBJLoader2Parser.executeLegacy"); 403 | }, 404 | 405 | _processLine: function(buffer, bufferPointer, slashesCount) { 406 | if (bufferPointer < 1) return; 407 | 408 | let reconstructString = function(content, legacyMode, start, stop) { 409 | let line = ""; 410 | if (stop > start) { 411 | let i; 412 | if (legacyMode) { 413 | for (i = start; i < stop; i++) line += content[i]; 414 | } else { 415 | for (i = start; i < stop; i++) 416 | line += String.fromCharCode(content[i]); 417 | } 418 | line = line.trim(); 419 | } 420 | return line; 421 | }; 422 | 423 | let bufferLength, length, i, lineDesignation; 424 | lineDesignation = buffer[0]; 425 | switch (lineDesignation) { 426 | case "v": 427 | this.vertices.push(parseFloat(buffer[1])); 428 | this.vertices.push(parseFloat(buffer[2])); 429 | this.vertices.push(parseFloat(buffer[3])); 430 | if (bufferPointer > 4) { 431 | this.colors.push(parseFloat(buffer[4])); 432 | this.colors.push(parseFloat(buffer[5])); 433 | this.colors.push(parseFloat(buffer[6])); 434 | } 435 | break; 436 | 437 | case "vt": 438 | this.uvs.push(parseFloat(buffer[1])); 439 | this.uvs.push(parseFloat(buffer[2])); 440 | break; 441 | 442 | case "vn": 443 | this.normals.push(parseFloat(buffer[1])); 444 | this.normals.push(parseFloat(buffer[2])); 445 | this.normals.push(parseFloat(buffer[3])); 446 | break; 447 | 448 | case "f": 449 | bufferLength = bufferPointer - 1; 450 | 451 | // "f vertex ..." 452 | if (slashesCount === 0) { 453 | this._checkFaceType(0); 454 | for (i = 2, length = bufferLength; i < length; i++) { 455 | this._buildFace(buffer[1]); 456 | this._buildFace(buffer[i]); 457 | this._buildFace(buffer[i + 1]); 458 | } 459 | 460 | // "f vertex/uv ..." 461 | } else if (bufferLength === slashesCount * 2) { 462 | this._checkFaceType(1); 463 | for (i = 3, length = bufferLength - 2; i < length; i += 2) { 464 | this._buildFace(buffer[1], buffer[2]); 465 | this._buildFace(buffer[i], buffer[i + 1]); 466 | this._buildFace(buffer[i + 2], buffer[i + 3]); 467 | } 468 | 469 | // "f vertex/uv/normal ..." 470 | } else if (bufferLength * 2 === slashesCount * 3) { 471 | this._checkFaceType(2); 472 | for (i = 4, length = bufferLength - 3; i < length; i += 3) { 473 | this._buildFace(buffer[1], buffer[2], buffer[3]); 474 | this._buildFace(buffer[i], buffer[i + 1], buffer[i + 2]); 475 | this._buildFace(buffer[i + 3], buffer[i + 4], buffer[i + 5]); 476 | } 477 | 478 | // "f vertex//normal ..." 479 | } else { 480 | this._checkFaceType(3); 481 | for (i = 3, length = bufferLength - 2; i < length; i += 2) { 482 | this._buildFace(buffer[1], undefined, buffer[2]); 483 | this._buildFace(buffer[i], undefined, buffer[i + 1]); 484 | this._buildFace(buffer[i + 2], undefined, buffer[i + 3]); 485 | } 486 | } 487 | break; 488 | 489 | case "l": 490 | case "p": 491 | bufferLength = bufferPointer - 1; 492 | if (bufferLength === slashesCount * 2) { 493 | this._checkFaceType(4); 494 | for (i = 1, length = bufferLength + 1; i < length; i += 2) 495 | this._buildFace(buffer[i], buffer[i + 1]); 496 | } else { 497 | this._checkFaceType(lineDesignation === "l" ? 5 : 6); 498 | for (i = 1, length = bufferLength + 1; i < length; i++) 499 | this._buildFace(buffer[i]); 500 | } 501 | break; 502 | 503 | case "s": 504 | this._pushSmoothingGroup(buffer[1]); 505 | break; 506 | 507 | case "g": 508 | // 'g' leads to creation of mesh if valid data (faces declaration was done before), otherwise only groupName gets set 509 | this._processCompletedMesh(); 510 | this.rawMesh.groupName = reconstructString( 511 | this.contentRef, 512 | this.legacyMode, 513 | this.globalCounts.lineByte + 2, 514 | this.globalCounts.currentByte 515 | ); 516 | break; 517 | 518 | case "o": 519 | // 'o' is meta-information and usually does not result in creation of new meshes, but can be enforced with "useOAsMesh" 520 | if (this.useOAsMesh) this._processCompletedMesh(); 521 | this.rawMesh.objectName = reconstructString( 522 | this.contentRef, 523 | this.legacyMode, 524 | this.globalCounts.lineByte + 2, 525 | this.globalCounts.currentByte 526 | ); 527 | break; 528 | 529 | case "mtllib": 530 | this.rawMesh.mtllibName = reconstructString( 531 | this.contentRef, 532 | this.legacyMode, 533 | this.globalCounts.lineByte + 7, 534 | this.globalCounts.currentByte 535 | ); 536 | break; 537 | 538 | case "usemtl": 539 | let mtlName = reconstructString( 540 | this.contentRef, 541 | this.legacyMode, 542 | this.globalCounts.lineByte + 7, 543 | this.globalCounts.currentByte 544 | ); 545 | if (mtlName !== "" && this.rawMesh.activeMtlName !== mtlName) { 546 | this.rawMesh.activeMtlName = mtlName; 547 | this.rawMesh.counts.mtlCount++; 548 | this._checkSubGroup(); 549 | } 550 | break; 551 | 552 | default: 553 | break; 554 | } 555 | }, 556 | 557 | _pushSmoothingGroup: function(smoothingGroup) { 558 | let smoothingGroupInt = parseInt(smoothingGroup); 559 | if (isNaN(smoothingGroupInt)) { 560 | smoothingGroupInt = smoothingGroup === "off" ? 0 : 1; 561 | } 562 | 563 | let smoothCheck = this.rawMesh.smoothingGroup.normalized; 564 | this.rawMesh.smoothingGroup.normalized = this.rawMesh.smoothingGroup 565 | .splitMaterials 566 | ? smoothingGroupInt 567 | : smoothingGroupInt === 0 568 | ? 0 569 | : 1; 570 | this.rawMesh.smoothingGroup.real = smoothingGroupInt; 571 | 572 | if (smoothCheck !== smoothingGroupInt) { 573 | this.rawMesh.counts.smoothingGroupCount++; 574 | this._checkSubGroup(); 575 | } 576 | }, 577 | 578 | /** 579 | * Expanded faceTypes include all four face types, both line types and the point type 580 | * faceType = 0: "f vertex ..." 581 | * faceType = 1: "f vertex/uv ..." 582 | * faceType = 2: "f vertex/uv/normal ..." 583 | * faceType = 3: "f vertex//normal ..." 584 | * faceType = 4: "l vertex/uv ..." or "l vertex ..." 585 | * faceType = 5: "l vertex ..." 586 | * faceType = 6: "p vertex ..." 587 | */ 588 | _checkFaceType: function(faceType) { 589 | if (this.rawMesh.faceType !== faceType) { 590 | this._processCompletedMesh(); 591 | this.rawMesh.faceType = faceType; 592 | this._checkSubGroup(); 593 | } 594 | }, 595 | 596 | _checkSubGroup: function() { 597 | let index = 598 | this.rawMesh.activeMtlName + "|" + this.rawMesh.smoothingGroup.normalized; 599 | this.rawMesh.subGroupInUse = this.rawMesh.subGroups[index]; 600 | 601 | if ( 602 | this.rawMesh.subGroupInUse === undefined || 603 | this.rawMesh.subGroupInUse === null 604 | ) { 605 | this.rawMesh.subGroupInUse = { 606 | index: index, 607 | objectName: this.rawMesh.objectName, 608 | groupName: this.rawMesh.groupName, 609 | materialName: this.rawMesh.activeMtlName, 610 | smoothingGroup: this.rawMesh.smoothingGroup.normalized, 611 | vertices: [], 612 | indexMappingsCount: 0, 613 | indexMappings: [], 614 | indices: [], 615 | colors: [], 616 | uvs: [], 617 | normals: [], 618 | }; 619 | this.rawMesh.subGroups[index] = this.rawMesh.subGroupInUse; 620 | } 621 | }, 622 | 623 | _buildFace: function(faceIndexV, faceIndexU, faceIndexN) { 624 | let subGroupInUse = this.rawMesh.subGroupInUse; 625 | let scope = this; 626 | let updateSubGroupInUse = function() { 627 | let faceIndexVi = parseInt(faceIndexV); 628 | let indexPointerV = 629 | 3 * 630 | (faceIndexVi > 0 631 | ? faceIndexVi - 1 632 | : faceIndexVi + scope.vertices.length / 3); 633 | let indexPointerC = scope.colors.length > 0 ? indexPointerV : null; 634 | 635 | let vertices = subGroupInUse.vertices; 636 | vertices.push(scope.vertices[indexPointerV++]); 637 | vertices.push(scope.vertices[indexPointerV++]); 638 | vertices.push(scope.vertices[indexPointerV]); 639 | 640 | if (indexPointerC !== null) { 641 | let colors = subGroupInUse.colors; 642 | colors.push(scope.colors[indexPointerC++]); 643 | colors.push(scope.colors[indexPointerC++]); 644 | colors.push(scope.colors[indexPointerC]); 645 | } 646 | if (faceIndexU) { 647 | let faceIndexUi = parseInt(faceIndexU); 648 | let indexPointerU = 649 | 2 * 650 | (faceIndexUi > 0 651 | ? faceIndexUi - 1 652 | : faceIndexUi + scope.uvs.length / 2); 653 | let uvs = subGroupInUse.uvs; 654 | uvs.push(scope.uvs[indexPointerU++]); 655 | uvs.push(scope.uvs[indexPointerU]); 656 | } 657 | if (faceIndexN && !scope.disregardNormals) { 658 | let faceIndexNi = parseInt(faceIndexN); 659 | let indexPointerN = 660 | 3 * 661 | (faceIndexNi > 0 662 | ? faceIndexNi - 1 663 | : faceIndexNi + scope.normals.length / 3); 664 | let normals = subGroupInUse.normals; 665 | normals.push(scope.normals[indexPointerN++]); 666 | normals.push(scope.normals[indexPointerN++]); 667 | normals.push(scope.normals[indexPointerN]); 668 | } 669 | }; 670 | 671 | if (this.useIndices) { 672 | if (this.disregardNormals) faceIndexN = undefined; 673 | let mappingName = 674 | faceIndexV + 675 | (faceIndexU ? "_" + faceIndexU : "_n") + 676 | (faceIndexN ? "_" + faceIndexN : "_n"); 677 | let indicesPointer = subGroupInUse.indexMappings[mappingName]; 678 | if (indicesPointer === undefined || indicesPointer === null) { 679 | indicesPointer = this.rawMesh.subGroupInUse.vertices.length / 3; 680 | updateSubGroupInUse(); 681 | subGroupInUse.indexMappings[mappingName] = indicesPointer; 682 | subGroupInUse.indexMappingsCount++; 683 | } else { 684 | this.rawMesh.counts.doubleIndicesCount++; 685 | } 686 | subGroupInUse.indices.push(indicesPointer); 687 | } else { 688 | updateSubGroupInUse(); 689 | } 690 | this.rawMesh.counts.faceCount++; 691 | }, 692 | 693 | _createRawMeshReport: function(inputObjectCount) { 694 | return ( 695 | "Input Object number: " + 696 | inputObjectCount + 697 | "\n\tObject name: " + 698 | this.rawMesh.objectName + 699 | "\n\tGroup name: " + 700 | this.rawMesh.groupName + 701 | "\n\tMtllib name: " + 702 | this.rawMesh.mtllibName + 703 | "\n\tVertex count: " + 704 | this.vertices.length / 3 + 705 | "\n\tNormal count: " + 706 | this.normals.length / 3 + 707 | "\n\tUV count: " + 708 | this.uvs.length / 2 + 709 | "\n\tSmoothingGroup count: " + 710 | this.rawMesh.counts.smoothingGroupCount + 711 | "\n\tMaterial count: " + 712 | this.rawMesh.counts.mtlCount + 713 | "\n\tReal MeshOutputGroup count: " + 714 | this.rawMesh.subGroups.length 715 | ); 716 | }, 717 | 718 | /** 719 | * Clear any empty subGroup and calculate absolute vertex, normal and uv counts 720 | */ 721 | _finalizeRawMesh: function() { 722 | let meshOutputGroupTemp = []; 723 | let meshOutputGroup; 724 | let absoluteVertexCount = 0; 725 | let absoluteIndexMappingsCount = 0; 726 | let absoluteIndexCount = 0; 727 | let absoluteColorCount = 0; 728 | let absoluteNormalCount = 0; 729 | let absoluteUvCount = 0; 730 | let indices; 731 | for (let name in this.rawMesh.subGroups) { 732 | meshOutputGroup = this.rawMesh.subGroups[name]; 733 | if (meshOutputGroup.vertices.length > 0) { 734 | indices = meshOutputGroup.indices; 735 | if (indices.length > 0 && absoluteIndexMappingsCount > 0) { 736 | for (let i = 0; i < indices.length; i++) { 737 | indices[i] = indices[i] + absoluteIndexMappingsCount; 738 | } 739 | } 740 | meshOutputGroupTemp.push(meshOutputGroup); 741 | absoluteVertexCount += meshOutputGroup.vertices.length; 742 | absoluteIndexMappingsCount += meshOutputGroup.indexMappingsCount; 743 | absoluteIndexCount += meshOutputGroup.indices.length; 744 | absoluteColorCount += meshOutputGroup.colors.length; 745 | absoluteUvCount += meshOutputGroup.uvs.length; 746 | absoluteNormalCount += meshOutputGroup.normals.length; 747 | } 748 | } 749 | 750 | // do not continue if no result 751 | let result = null; 752 | if (meshOutputGroupTemp.length > 0) { 753 | result = { 754 | name: 755 | this.rawMesh.groupName !== "" 756 | ? this.rawMesh.groupName 757 | : this.rawMesh.objectName, 758 | subGroups: meshOutputGroupTemp, 759 | absoluteVertexCount: absoluteVertexCount, 760 | absoluteIndexCount: absoluteIndexCount, 761 | absoluteColorCount: absoluteColorCount, 762 | absoluteNormalCount: absoluteNormalCount, 763 | absoluteUvCount: absoluteUvCount, 764 | faceCount: this.rawMesh.counts.faceCount, 765 | doubleIndicesCount: this.rawMesh.counts.doubleIndicesCount, 766 | }; 767 | } 768 | return result; 769 | }, 770 | 771 | _processCompletedMesh: function() { 772 | let result = this._finalizeRawMesh(); 773 | let haveMesh = result !== null; 774 | if (haveMesh) { 775 | if ( 776 | this.colors.length > 0 && 777 | this.colors.length !== this.vertices.length 778 | ) { 779 | this.callbacks.onError( 780 | "Vertex Colors were detected, but vertex count and color count do not match!" 781 | ); 782 | } 783 | if (this.logging.enabled && this.logging.debug) 784 | console.debug(this._createRawMeshReport(this.inputObjectCount)); 785 | this.inputObjectCount++; 786 | 787 | this._buildMesh(result); 788 | let progressBytesPercent = 789 | this.globalCounts.currentByte / this.globalCounts.totalBytes; 790 | this._onProgress( 791 | "Completed [o: " + 792 | this.rawMesh.objectName + 793 | " g:" + 794 | this.rawMesh.groupName + 795 | "" + 796 | "] Total progress: " + 797 | (progressBytesPercent * 100).toFixed(2) + 798 | "%" 799 | ); 800 | this._resetRawMesh(); 801 | } 802 | return haveMesh; 803 | }, 804 | 805 | /** 806 | * SubGroups are transformed to too intermediate format that is forwarded to the MeshReceiver. 807 | * It is ensured that SubGroups only contain objects with vertices (no need to check). 808 | * 809 | * @param result 810 | */ 811 | _buildMesh: function(result) { 812 | let meshOutputGroups = result.subGroups; 813 | 814 | let vertexFA = new Float32Array(result.absoluteVertexCount); 815 | this.globalCounts.vertices += result.absoluteVertexCount / 3; 816 | this.globalCounts.faces += result.faceCount; 817 | this.globalCounts.doubleIndicesCount += result.doubleIndicesCount; 818 | let indexUA = 819 | result.absoluteIndexCount > 0 820 | ? new Uint32Array(result.absoluteIndexCount) 821 | : null; 822 | let colorFA = 823 | result.absoluteColorCount > 0 824 | ? new Float32Array(result.absoluteColorCount) 825 | : null; 826 | let normalFA = 827 | result.absoluteNormalCount > 0 828 | ? new Float32Array(result.absoluteNormalCount) 829 | : null; 830 | let uvFA = 831 | result.absoluteUvCount > 0 832 | ? new Float32Array(result.absoluteUvCount) 833 | : null; 834 | let haveVertexColors = colorFA !== null; 835 | 836 | let meshOutputGroup; 837 | let materialNames = []; 838 | 839 | let createMultiMaterial = meshOutputGroups.length > 1; 840 | let materialIndex = 0; 841 | let materialIndexMapping = []; 842 | let selectedMaterialIndex; 843 | let materialGroup; 844 | let materialGroups = []; 845 | 846 | let vertexFAOffset = 0; 847 | let indexUAOffset = 0; 848 | let colorFAOffset = 0; 849 | let normalFAOffset = 0; 850 | let uvFAOffset = 0; 851 | let materialGroupOffset = 0; 852 | let materialGroupLength = 0; 853 | 854 | let materialOrg, material, materialName, materialNameOrg; 855 | // only one specific face type 856 | for (let oodIndex in meshOutputGroups) { 857 | if (!meshOutputGroups.hasOwnProperty(oodIndex)) continue; 858 | meshOutputGroup = meshOutputGroups[oodIndex]; 859 | 860 | materialNameOrg = meshOutputGroup.materialName; 861 | if (this.rawMesh.faceType < 4) { 862 | materialName = 863 | materialNameOrg + 864 | (haveVertexColors ? "_vertexColor" : "") + 865 | (meshOutputGroup.smoothingGroup === 0 ? "_flat" : ""); 866 | } else { 867 | materialName = 868 | this.rawMesh.faceType === 6 869 | ? "defaultPointMaterial" 870 | : "defaultLineMaterial"; 871 | } 872 | materialOrg = this.materials[materialNameOrg]; 873 | material = this.materials[materialName]; 874 | 875 | // both original and derived names do not lead to an existing material => need to use a default material 876 | if ( 877 | (materialOrg === undefined || materialOrg === null) && 878 | (material === undefined || material === null) 879 | ) { 880 | materialName = haveVertexColors 881 | ? "defaultVertexColorMaterial" 882 | : "defaultMaterial"; 883 | material = this.materials[materialName]; 884 | if (this.logging.enabled) { 885 | console.info( 886 | 'object_group "' + 887 | meshOutputGroup.objectName + 888 | "_" + 889 | meshOutputGroup.groupName + 890 | '" was defined with unresolvable material "' + 891 | materialNameOrg + 892 | '"! Assigning "' + 893 | materialName + 894 | '".' 895 | ); 896 | } 897 | } 898 | if (material === undefined || material === null) { 899 | let materialCloneInstructions = { 900 | materialNameOrg: materialNameOrg, 901 | materialName: materialName, 902 | materialProperties: { 903 | vertexColors: haveVertexColors ? 2 : 0, 904 | flatShading: meshOutputGroup.smoothingGroup === 0, 905 | }, 906 | }; 907 | let payload = { 908 | cmd: "assetAvailable", 909 | type: "material", 910 | materials: { 911 | materialCloneInstructions: materialCloneInstructions, 912 | }, 913 | }; 914 | this.callbacks.onAssetAvailable(payload); 915 | 916 | // only set materials if they don't exist, yet 917 | let matCheck = this.materials[materialName]; 918 | if (matCheck === undefined || matCheck === null) { 919 | this.materials[materialName] = materialCloneInstructions; 920 | } 921 | } 922 | 923 | if (createMultiMaterial) { 924 | // re-use material if already used before. Reduces materials array size and eliminates duplicates 925 | selectedMaterialIndex = materialIndexMapping[materialName]; 926 | if (!selectedMaterialIndex) { 927 | selectedMaterialIndex = materialIndex; 928 | materialIndexMapping[materialName] = materialIndex; 929 | materialNames.push(materialName); 930 | materialIndex++; 931 | } 932 | materialGroupLength = this.useIndices 933 | ? meshOutputGroup.indices.length 934 | : meshOutputGroup.vertices.length / 3; 935 | materialGroup = { 936 | start: materialGroupOffset, 937 | count: materialGroupLength, 938 | index: selectedMaterialIndex, 939 | }; 940 | materialGroups.push(materialGroup); 941 | materialGroupOffset += materialGroupLength; 942 | } else { 943 | materialNames.push(materialName); 944 | } 945 | 946 | vertexFA.set(meshOutputGroup.vertices, vertexFAOffset); 947 | vertexFAOffset += meshOutputGroup.vertices.length; 948 | 949 | if (indexUA) { 950 | indexUA.set(meshOutputGroup.indices, indexUAOffset); 951 | indexUAOffset += meshOutputGroup.indices.length; 952 | } 953 | 954 | if (colorFA) { 955 | colorFA.set(meshOutputGroup.colors, colorFAOffset); 956 | colorFAOffset += meshOutputGroup.colors.length; 957 | } 958 | 959 | if (normalFA) { 960 | normalFA.set(meshOutputGroup.normals, normalFAOffset); 961 | normalFAOffset += meshOutputGroup.normals.length; 962 | } 963 | if (uvFA) { 964 | uvFA.set(meshOutputGroup.uvs, uvFAOffset); 965 | uvFAOffset += meshOutputGroup.uvs.length; 966 | } 967 | 968 | if (this.logging.enabled && this.logging.debug) { 969 | let materialIndexLine = 970 | selectedMaterialIndex === undefined || selectedMaterialIndex === null 971 | ? "" 972 | : "\n\t\tmaterialIndex: " + selectedMaterialIndex; 973 | let createdReport = 974 | "\tOutput Object no.: " + 975 | this.outputObjectCount + 976 | "\n\t\tgroupName: " + 977 | meshOutputGroup.groupName + 978 | "\n\t\tIndex: " + 979 | meshOutputGroup.index + 980 | "\n\t\tfaceType: " + 981 | this.rawMesh.faceType + 982 | "\n\t\tmaterialName: " + 983 | meshOutputGroup.materialName + 984 | "\n\t\tsmoothingGroup: " + 985 | meshOutputGroup.smoothingGroup + 986 | materialIndexLine + 987 | "\n\t\tobjectName: " + 988 | meshOutputGroup.objectName + 989 | "\n\t\t#vertices: " + 990 | meshOutputGroup.vertices.length / 3 + 991 | "\n\t\t#indices: " + 992 | meshOutputGroup.indices.length + 993 | "\n\t\t#colors: " + 994 | meshOutputGroup.colors.length / 3 + 995 | "\n\t\t#uvs: " + 996 | meshOutputGroup.uvs.length / 2 + 997 | "\n\t\t#normals: " + 998 | meshOutputGroup.normals.length / 3; 999 | console.debug(createdReport); 1000 | } 1001 | } 1002 | this.outputObjectCount++; 1003 | this.callbacks.onAssetAvailable( 1004 | { 1005 | cmd: "assetAvailable", 1006 | type: "mesh", 1007 | progress: { 1008 | numericalValue: 1009 | this.globalCounts.currentByte / this.globalCounts.totalBytes, 1010 | }, 1011 | params: { 1012 | meshName: result.name, 1013 | }, 1014 | materials: { 1015 | multiMaterial: createMultiMaterial, 1016 | materialNames: materialNames, 1017 | materialGroups: materialGroups, 1018 | }, 1019 | buffers: { 1020 | vertices: vertexFA, 1021 | indices: indexUA, 1022 | colors: colorFA, 1023 | normals: normalFA, 1024 | uvs: uvFA, 1025 | }, 1026 | // 0: mesh, 1: line, 2: point 1027 | geometryType: 1028 | this.rawMesh.faceType < 4 ? 0 : this.rawMesh.faceType === 6 ? 2 : 1, 1029 | }, 1030 | [vertexFA.buffer], 1031 | indexUA !== null ? [indexUA.buffer] : null, 1032 | colorFA !== null ? [colorFA.buffer] : null, 1033 | normalFA !== null ? [normalFA.buffer] : null, 1034 | uvFA !== null ? [uvFA.buffer] : null 1035 | ); 1036 | }, 1037 | 1038 | _finalizeParsing: function() { 1039 | if (this.logging.enabled) 1040 | console.info("Global output object count: " + this.outputObjectCount); 1041 | if (this._processCompletedMesh() && this.logging.enabled) { 1042 | let parserFinalReport = 1043 | "Overall counts: " + 1044 | "\n\tVertices: " + 1045 | this.globalCounts.vertices + 1046 | "\n\tFaces: " + 1047 | this.globalCounts.faces + 1048 | "\n\tMultiple definitions: " + 1049 | this.globalCounts.doubleIndicesCount; 1050 | console.info(parserFinalReport); 1051 | } 1052 | }, 1053 | }; 1054 | 1055 | export { OBJLoader2Parser }; 1056 | -------------------------------------------------------------------------------- /src/js/worker-actions.js: -------------------------------------------------------------------------------- 1 | // Actions sent by main thread to the web worker 2 | export const MainThreadAction = Object.freeze({ 3 | INIT_WORKER_STATE: "initialize-worker-state", 4 | REQUEST_BITMAPS: "request-bitmaps", 5 | START_RENDER_LOOP: "start-render-loop", 6 | STOP_RENDER_LOOP: "stop-render-loop", 7 | }); 8 | 9 | // Actions sent by the web worker to the main thread 10 | export const WorkerAction = Object.freeze({ 11 | BITMAPS: "bitmaps", 12 | NOTIFY: "notify", 13 | TERMINATE_ME: "terminate-me", 14 | }); 15 | -------------------------------------------------------------------------------- /src/js/workers/bitmap-worker.js: -------------------------------------------------------------------------------- 1 | import { 2 | AmbientLight, 3 | AxesHelper, 4 | Color, 5 | CubeGeometry, 6 | DirectionalLight, 7 | GridHelper, 8 | Mesh, 9 | MeshLambertMaterial, 10 | PerspectiveCamera, 11 | Scene, 12 | WebGLRenderer, 13 | } from "three"; 14 | 15 | import { MainThreadAction, WorkerAction } from "../worker-actions"; 16 | 17 | const NAME = "bitmap-worker"; 18 | 19 | // stop the demo after x renderers (just to test worker.terminate()) 20 | const NUM_RENDER_FOR_DEMO = 300; 21 | 22 | // internal state of this web worker 23 | const state = {}; 24 | 25 | /** 26 | * Initialize a Three.js scene in this web worker's state. 27 | */ 28 | const initScene = ({ name = "Default Scene name", addAxesHelpers = true }) => { 29 | const scene = new Scene(); 30 | scene.autoUpdate = true; 31 | const color = new Color(0x222222); // it's a dark gray 32 | scene.background = color; 33 | scene.fog = null; 34 | scene.name = name; 35 | 36 | const side = 30; 37 | const geometry = new CubeGeometry(side, side, side); 38 | const material = new MeshLambertMaterial({ color: 0xfbbc05 }); 39 | const cube = new Mesh(geometry, material); 40 | cube.name = "Cube"; 41 | cube.position.set(0, side / 2, 0); 42 | scene.add(cube); 43 | 44 | const gridHelper = new GridHelper(200, 16); 45 | gridHelper.name = "Floor GridHelper"; 46 | scene.add(gridHelper); 47 | 48 | if (addAxesHelpers) { 49 | // XYZ axes helper (XYZ axes are RGB colors, respectively) 50 | const axesHelper = new AxesHelper(75); 51 | axesHelper.name = "XYZ AzesHelper"; 52 | scene.add(axesHelper); 53 | } 54 | 55 | const dirLight = new DirectionalLight(0x4682b4, 1); // steelblue 56 | dirLight.position.set(120, 30, -200); 57 | dirLight.castShadow = true; 58 | dirLight.shadow.camera.near = 10; 59 | scene.add(dirLight); 60 | 61 | const ambientLight = new AmbientLight(0xffffff, 0.2); 62 | scene.add(ambientLight); 63 | 64 | state.scene = scene; 65 | postMessage({ 66 | action: WorkerAction.NOTIFY, 67 | payload: { info: `scene '${name}' initialized` }, 68 | source: NAME, 69 | }); 70 | }; 71 | 72 | const initRenderer = () => { 73 | if (!state.canvas) { 74 | throw new Error("Cannot initialize a renderer without a canvas"); 75 | } 76 | const gl = state.canvas.getContext("webgl"); 77 | state.renderer = new WebGLRenderer({ 78 | antialias: true, 79 | canvas: state.canvas, 80 | }); 81 | 82 | state.renderer.setClearColor(0x222222); // it's a dark gray 83 | 84 | // We are not in the DOM, so we don't have access to window.devicePixelRatio. 85 | // Maybe I could compute the aspect in the onscreen canvas and pass it here 86 | // within the message payload. 87 | state.renderer.setPixelRatio(1); 88 | 89 | // We are rendering offscreen, so there is no DOM and we cannot set the inline 90 | // style of the canvas. 91 | state.renderer.setSize(gl.drawingBufferWidth, gl.drawingBufferHeight, false); 92 | 93 | state.renderer.shadowMap.enabled = true; 94 | 95 | postMessage({ 96 | action: WorkerAction.NOTIFY, 97 | payload: { info: "renderer initialized" }, 98 | source: NAME, 99 | }); 100 | }; 101 | 102 | const initCamera = ({ 103 | aspect = 1, 104 | far = 10000, 105 | fov, 106 | name = "Default camera name", 107 | near = 0.1, 108 | }) => { 109 | if (!state.scene) { 110 | throw new Error("Cannot initialize camera without a scene"); 111 | } 112 | state.camera = new PerspectiveCamera(fov, aspect, near, far); 113 | state.camera.name = name; 114 | state.camera.position.set(100, 100, 100); 115 | state.camera.lookAt(state.scene.position); 116 | 117 | postMessage({ 118 | action: WorkerAction.NOTIFY, 119 | payload: { info: `camera '${state.camera.name}' initialized` }, 120 | source: NAME, 121 | }); 122 | }; 123 | 124 | const initState = payload => { 125 | const { height, sceneName, width } = payload; 126 | state.canvas = new OffscreenCanvas(width, height); 127 | 128 | initScene({ addAxesHelpers: false, sceneName }); 129 | 130 | const cameraConfig = { 131 | aspect: state.canvas.width / state.canvas.height, 132 | fov: 75, 133 | name: "My Perspective Camera", 134 | }; 135 | initCamera(cameraConfig); 136 | 137 | initRenderer(); 138 | 139 | state.counter = 0; 140 | }; 141 | 142 | const set2DRenderingContexts = resolutions => { 143 | const contexts = resolutions.map(r => { 144 | const canvas = new OffscreenCanvas(r.width, r.height); 145 | return canvas.getContext("2d"); 146 | }); 147 | state.contexts = contexts; 148 | }; 149 | 150 | /** 151 | * Render the scene to the offscreen canvas, then create a bitmap and send it to 152 | * the main thread with a zero-copy operation. 153 | * 154 | * We call the transferToImageBitmap method on the offscreen canvas and send the 155 | * bitmap to the main thread synchronously. 156 | * 157 | * Note: requestAnimationFrame and cancelAnimationFrame are available for web 158 | * workers. But we can also use requestAnimationFrame in the main thread and 159 | * send a message to the web worker requesting a new bitmap when the main thread 160 | * needs it. 161 | */ 162 | const render = resolutions => { 163 | // This web worker has some internal state. If these conditions are not 164 | // satisfied we crash and burn, so the main thread knows there is a problem. 165 | if (!state.renderer) { 166 | throw new Error( 167 | `Cannot call "render" without a renderer in ${NAME}'s state` 168 | ); 169 | } 170 | if (!state.canvas) { 171 | throw new Error(`Cannot call "render" without a canvas in ${NAME}'s state`); 172 | } 173 | if (!state.camera) { 174 | throw new Error(`Cannot call "render" without a camera in ${NAME}'s state`); 175 | } 176 | if (!state.scene) { 177 | throw new Error(`Cannot call "render" without a scene in ${NAME}'s state`); 178 | } 179 | 180 | // signal to the main thread that this web worker is done, so this worker's 181 | // heap can be freed. You can see that this web worker's heap disappears in 182 | // the Chrome Dev Tools Memory tab. 183 | if (state.counter > NUM_RENDER_FOR_DEMO) { 184 | state.scene.dispose(); 185 | postMessage({ action: WorkerAction.TERMINATE_ME, source: NAME }); 186 | } 187 | 188 | // render the scene in a "source" OffscreenCanvas only once 189 | state.renderer.render(state.scene, state.camera); 190 | 191 | // copy the "source" canvas to N "destination" canvases, one for each 192 | // requested bitmap 193 | const bitmaps = state.contexts.map((ctx, i) => { 194 | ctx.globalCompositeOperation = "copy"; 195 | // ctx.fillStyle = "rgb(200,0,0)"; 196 | // ctx.fillRect(10, 10, 55, 50); 197 | const { width, height } = resolutions[i]; 198 | // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage 199 | ctx.drawImage(state.canvas, 0, 0, width, height); 200 | return ctx.canvas.transferToImageBitmap(); 201 | }); 202 | 203 | // in other use-cases the web worker could send back a `Blob` with 204 | // `canvas.convertToBlob().then(blob => postMessage({ blob }, [blob]))` 205 | 206 | const message = { 207 | action: WorkerAction.BITMAPS, 208 | payload: { bitmaps }, 209 | source: NAME, 210 | }; 211 | 212 | // ImageBitmap implements the Transerable interface, so we can send it to the 213 | // main thread WITHOUT using the structured clone algorithm. In other words, 214 | // this `postMessage` is a ZERO-COPY operation: we are passing each bitmap BY 215 | // REFERENCE, NOT BY VALUE. 216 | // https://developer.mozilla.org/en-US/docs/Web/API/Transferable 217 | // https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm 218 | postMessage(message, bitmaps); 219 | }; 220 | 221 | const style = "color: red; font-weight: normal"; 222 | 223 | const onMessage = event => { 224 | const label = `[${event.data.source} --> ${NAME}] - ${event.data.action}`; 225 | // console.groupCollapsed(`%c${label}`, style); 226 | // if (event.data.payload) { 227 | // console.log(`payload: ${JSON.stringify(event.data.payload)}`); 228 | // // console.table(event.data); 229 | // } 230 | // console.groupEnd(); 231 | console.log(`%c${label}`, style); 232 | 233 | switch (event.data.action) { 234 | case MainThreadAction.INIT_WORKER_STATE: 235 | console.log("SELF NAME (DedicatedWorkerGlobalScope name)", self.name); 236 | initState(event.data.payload); 237 | break; 238 | case MainThreadAction.REQUEST_BITMAPS: { 239 | state.counter++; 240 | state.camera.rotateZ(0.2); 241 | set2DRenderingContexts(event.data.payload.resolutions); 242 | render(event.data.payload.resolutions); 243 | break; 244 | } 245 | default: { 246 | console.warn(`${NAME} received a message that does not handle`, event); 247 | } 248 | } 249 | }; 250 | onmessage = onMessage; 251 | -------------------------------------------------------------------------------- /src/js/workers/transfer-worker.js: -------------------------------------------------------------------------------- 1 | import { 2 | AmbientLight, 3 | AxesHelper, 4 | CanvasTexture, 5 | Color, 6 | CubeGeometry, 7 | DirectionalLight, 8 | GridHelper, 9 | ImageBitmapLoader, 10 | LoadingManager, 11 | Mesh, 12 | MeshBasicMaterial, 13 | MeshLambertMaterial, 14 | ObjectLoader, 15 | PerspectiveCamera, 16 | Scene, 17 | WebGLRenderer, 18 | } from "three"; 19 | 20 | import { MainThreadAction, WorkerAction } from "../worker-actions"; 21 | import { OBJLoader2 } from "../vendor/OBJLoader2"; 22 | import * as relativeURL from "../../models/male02.obj"; 23 | 24 | // https://bwasty.github.io/gltf-loader-ts/index.html 25 | // https://github.com/KhronosGroup/glTF-Sample-Models/tree/master/2.0/ 26 | // https://github.com/mrdoob/three.js/blob/master/examples/webgl_loader_gltf.html 27 | import { GltfLoader } from "gltf-loader-ts"; 28 | 29 | const NAME = "transfer-worker"; 30 | 31 | const gltfLoader = new GltfLoader(); 32 | 33 | const makeScene = name => { 34 | const scene = new Scene(); 35 | scene.autoUpdate = true; 36 | const color = new Color(0x222222); // it's a dark gray 37 | scene.background = color; 38 | scene.fog = null; 39 | // Any Three.js object in the scene (and the scene itself) can have a name. 40 | scene.name = name; 41 | 42 | const side = 10; 43 | const geometry = new CubeGeometry(side, side, side); 44 | const material = new MeshLambertMaterial({ color: 0xfbbc05 }); 45 | const cube = new Mesh(geometry, material); 46 | cube.name = "Cube"; 47 | cube.position.set(0, side / 2, 0); 48 | scene.add(cube); 49 | 50 | const gridHelper = new GridHelper(200, 16); 51 | gridHelper.name = "Floor GridHelper"; 52 | scene.add(gridHelper); 53 | 54 | // XYZ axes helper (XYZ axes are RGB colors, respectively) 55 | const axesHelper = new AxesHelper(75); 56 | axesHelper.name = "XYZ AzesHelper"; 57 | scene.add(axesHelper); 58 | 59 | const dirLight = new DirectionalLight(0x4682b4, 1); // steelblue 60 | dirLight.position.set(120, 30, -200); 61 | dirLight.castShadow = true; 62 | dirLight.shadow.camera.near = 10; 63 | scene.add(dirLight); 64 | 65 | const ambientLight = new AmbientLight(0xffffff, 0.2); 66 | scene.add(ambientLight); 67 | 68 | return scene; 69 | }; 70 | 71 | const makeRenderer = canvas => { 72 | const gl = canvas.getContext("webgl"); 73 | const renderer = new WebGLRenderer({ 74 | antialias: true, 75 | canvas, 76 | }); 77 | 78 | renderer.setClearColor(0x222222); // it's a dark gray 79 | 80 | // We are not in the DOM, so we don't have access to window.devicePixelRatio. 81 | // Maybe I could compute the aspect in the onscreen canvas and pass it here 82 | // within the message payload. 83 | renderer.setPixelRatio(1); 84 | 85 | // We are rendering offscreen, so there is no DOM and we cannot set the inline 86 | // style of the canvas. 87 | const updateStyle = false; 88 | renderer.setSize(gl.drawingBufferWidth, gl.drawingBufferHeight, updateStyle); 89 | renderer.shadowMap.enabled = true; 90 | return renderer; 91 | }; 92 | 93 | const makeCamera = (canvas, scene) => { 94 | const fov = 75; 95 | // There are no clientWith and clientHeight in an OffscreenCanvas 96 | // const { clientWidth, clientHeight } = canvas; 97 | // const aspect = clientWidth / clientHeight; 98 | const aspect = canvas.width / canvas.height; 99 | const near = 0.1; 100 | const far = 10000; 101 | const camera = new PerspectiveCamera(fov, aspect, near, far); 102 | camera.name = "my-camera"; 103 | // camera.position.set(100, 100, 100); 104 | // Axes Helpers: RED (X), GREEN (Y), BLUE (Z) 105 | camera.position.set(100, 50, 200); 106 | camera.lookAt(scene.position); 107 | 108 | return camera; 109 | }; 110 | 111 | const tryGltf = uri => { 112 | return new Promise((resolve, reject) => { 113 | return gltfLoader 114 | .load(uri) 115 | .then(asset => { 116 | // console.log("tryGltf -> asset", asset); 117 | const gltf = asset.gltf; 118 | // console.log("GLTF", gltf); 119 | resolve(gltf); 120 | }) 121 | .catch(err => { 122 | reject(err); 123 | }); 124 | }); 125 | }; 126 | 127 | // async function tryGltf(uri) { 128 | // const asset = await gltfLoader.load(uri); 129 | // console.log("tryGltf -> asset", asset); 130 | // let gltf = asset.gltf; 131 | // console.log("GLTF", gltf); 132 | // // let data = await asset.accessorData(0); // fetches BoxTextured0.bin 133 | // // let image: Image = await asset.imageData.get(0); // fetches CesiumLogoFlat.png 134 | // } 135 | 136 | const init = payload => { 137 | const { canvas, sceneName } = payload; 138 | 139 | postMessage({ 140 | action: WorkerAction.NOTIFY, 141 | payload: { info: `[${NAME}] - scene initialized` }, 142 | source: NAME, 143 | }); 144 | const scene = makeScene(sceneName); 145 | 146 | postMessage({ 147 | action: WorkerAction.NOTIFY, 148 | payload: { info: `[${NAME}] - renderer inizialized` }, 149 | source: NAME, 150 | }); 151 | const renderer = makeRenderer(canvas); 152 | 153 | postMessage({ 154 | action: WorkerAction.NOTIFY, 155 | payload: { info: `[${NAME}] - camera inizialized` }, 156 | source: NAME, 157 | }); 158 | const camera = makeCamera(canvas, scene); 159 | 160 | const onManagerLoad = () => { 161 | postMessage({ 162 | action: WorkerAction.NOTIFY, 163 | payload: { 164 | info: `[${NAME}] - Loaded all items`, 165 | }, 166 | source: NAME, 167 | }); 168 | }; 169 | 170 | const onManagerProgress = (item, loaded, total) => { 171 | // console.log("LoadingManager progress:", item, loaded, total); 172 | postMessage({ 173 | action: WorkerAction.NOTIFY, 174 | payload: { 175 | info: `[${NAME}] - Loaded ${loaded} of ${total} items`, 176 | }, 177 | source: NAME, 178 | }); 179 | }; 180 | 181 | const onManagerStart = (url, itemsLoaded, itemsTotal) => { 182 | postMessage({ 183 | action: WorkerAction.NOTIFY, 184 | payload: { 185 | info: `[${NAME}] - started loading file ${url} (Loaded ${itemsLoaded} of ${itemsTotal} items)`, 186 | }, 187 | source: NAME, 188 | }); 189 | }; 190 | 191 | const onManagerError = error => { 192 | console.error("ERROR IN LOADING MANAGER", error); 193 | }; 194 | 195 | const manager = new LoadingManager( 196 | onManagerLoad, 197 | onManagerProgress, 198 | onManagerError 199 | ); 200 | manager.onStart = onManagerStart; 201 | 202 | const objectLoader = new ObjectLoader(manager); 203 | const bitmapLoader = new ImageBitmapLoader(manager); 204 | bitmapLoader.setOptions({ imageOrientation: "flipY" }); 205 | 206 | const objLoader = new OBJLoader2(manager); 207 | 208 | const objURL = PUBLIC_URL.includes("github.io") 209 | ? `${PUBLIC_URL}/${relativeURL}` 210 | : `http://localhost:8080/${relativeURL}`; 211 | 212 | console.log( 213 | "=== PUBLIC_URL ===", 214 | PUBLIC_URL, 215 | "relativeURL", 216 | relativeURL, 217 | "objURL (3D model)", 218 | objURL 219 | ); 220 | 221 | objLoader.load(objURL, object3D => { 222 | object3D.name = "male02"; 223 | scene.add(object3D); 224 | }); 225 | 226 | const onObjectLoad = object3D => { 227 | // console.log("=== object3D ===", object3D); 228 | const name = object3D.name || "unnamed-object"; 229 | const info = `[${NAME}] - Loaded ${name} (geometry: ${object3D.geometry.type}, material: ${object3D.material.type})`; 230 | postMessage({ 231 | action: WorkerAction.NOTIFY, 232 | payload: { 233 | info, 234 | }, 235 | source: NAME, 236 | }); 237 | // object3D.traverse(function(child) { 238 | // console.log("child", child); 239 | // if (child.isMesh) { 240 | // child.material.map = texture; 241 | // } 242 | // }); 243 | object3D.position.set(50, 0, 50); 244 | object3D.scale.set(15, 15, 15); 245 | state.scene.add(object3D); 246 | }; 247 | 248 | const onProgress = xhr => { 249 | if (xhr.lengthComputable) { 250 | const percentComplete = Math.round((xhr.loaded / xhr.total) * 100); 251 | postMessage({ 252 | action: WorkerAction.NOTIFY, 253 | payload: { 254 | info: `[${NAME}] - downloading model (${percentComplete}%)`, 255 | }, 256 | source: NAME, 257 | }); 258 | } 259 | }; 260 | 261 | const onError = error => { 262 | console.error("ERROR IN LOADER", error); 263 | }; 264 | 265 | const url = 266 | "https://raw.githubusercontent.com/mrdoob/three.js/master/examples/models/json/teapot-claraio.json"; 267 | objectLoader.load(url, onObjectLoad, onProgress, onError); 268 | 269 | // This one fails because it tries to access `window`, which of course is not 270 | // available in a web worker. 271 | // const url2 = 272 | // "https://raw.githubusercontent.com/mrdoob/three.js/master/examples/models/json/multimaterial.json"; 273 | // objectLoader.load(url2, onLoad, onProgress, onError); 274 | 275 | const onBitmapLoad = bitmap => { 276 | const info = `[${NAME}] - Loaded bitmap (${bitmap.width}x${bitmap.height})`; 277 | postMessage({ 278 | action: WorkerAction.NOTIFY, 279 | payload: { 280 | info, 281 | }, 282 | source: NAME, 283 | }); 284 | const texture = new CanvasTexture(bitmap); 285 | const materialWithTexture = new MeshBasicMaterial({ map: texture }); 286 | 287 | const geom = new CubeGeometry(30, 30, 30); 288 | const cube = new Mesh(geom, materialWithTexture); 289 | cube.name = "Cube with textured material"; 290 | cube.position.set(-50, 50, 50); 291 | scene.add(cube); 292 | }; 293 | 294 | bitmapLoader.load( 295 | "https://raw.githubusercontent.com/mrdoob/three.js/master/examples/textures/planets/earth_atmos_2048.jpg", 296 | onBitmapLoad, 297 | // onProgress callback currently not supported 298 | undefined, 299 | onError 300 | ); 301 | 302 | tryGltf( 303 | "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/master/2.0/BoxTextured/glTF/BoxTextured.gltf" 304 | ) 305 | .then(glft => { 306 | console.log("glft", glft); 307 | // scene.add( gltf.scene ); 308 | 309 | // gltf.animations; // Array 310 | // gltf.scene; // THREE.Group 311 | // gltf.scenes; // Array 312 | // gltf.cameras; // Array 313 | // gltf.asset; // Object 314 | }) 315 | .catch(err => { 316 | console.log("err with gltf", err); 317 | }); 318 | 319 | state.camera = camera; 320 | state.canvas = canvas; 321 | state.error = undefined; 322 | state.renderer = renderer; 323 | state.reqId = performance.now(); 324 | state.scene = scene; 325 | }; 326 | 327 | /** 328 | * Render the scene to the offscreen canvas. 329 | * 330 | * The scene rendered in the offscreen canvas appears automatically on the 331 | * onscreen canvas because the main thread transferred the ownership of the 332 | * rendering context to the offscreen canvas managed by this web worker. 333 | * The onscreen canvas is updated automatically and asynchronously. 334 | */ 335 | const render = tick => { 336 | // We could use the tick to control the animation. 337 | if (!state.renderer) { 338 | state.error = new Error( 339 | `Cannot call "render" without a renderer in ${NAME}'s state` 340 | ); 341 | return; 342 | } 343 | if (!state.canvas) { 344 | state.error = new Error( 345 | `Cannot call "render" without a canvas in ${NAME}'s state` 346 | ); 347 | return; 348 | } 349 | if (!state.camera) { 350 | state.error = new Error( 351 | `Cannot call "render" without a camera in ${NAME}'s state` 352 | ); 353 | return; 354 | } 355 | if (!state.scene) { 356 | state.error = new Error( 357 | `Cannot call "render" without a scene in ${NAME}'s state` 358 | ); 359 | return; 360 | } 361 | 362 | // If we made it here, the web worker can safely render the scene. 363 | const male02 = state.scene.getObjectByName("male02"); 364 | // console.log("Math.sin(tick)", Math.sin(tick)); 365 | male02.rotateY(0.03); 366 | // male02.rotateZ(0.05); 367 | state.renderer.render(state.scene, state.camera); 368 | 369 | // Maybe the main thread is interested in knowing what this web worker is 370 | // doing, so we notify it about what has been done. Please note that the main 371 | // thread doesn't have to do anything, and CANNOT do anything on the canvas on 372 | // the screen, because it transferred the ownership of that canvas to the web 373 | // worker. So basically the visible, onscreen canvas is just a proxy for the 374 | // offscreen canvas. 375 | postMessage({ 376 | action: WorkerAction.NOTIFY, 377 | payload: { info: `[${NAME}] - render loop` }, 378 | source: NAME, 379 | }); 380 | }; 381 | 382 | let state = { 383 | camera: undefined, 384 | canvas: undefined, 385 | error: undefined, 386 | renderer: undefined, 387 | reqId: undefined, 388 | scene: undefined, 389 | }; 390 | 391 | const style = "color: red; font-weight: normal"; 392 | 393 | const onMessage = event => { 394 | // console.log(`%c${event.data.action}`, "color: red"); 395 | const text = `[${event.data.source} --> ${NAME}] - ${event.data.action}`; 396 | console.log(`%c${text}`, style); 397 | 398 | switch (event.data.action) { 399 | case MainThreadAction.INIT_WORKER_STATE: { 400 | console.log("SELF NAME (DedicatedWorkerGlobalScope name)", self.name); 401 | init(event.data.payload); 402 | break; 403 | } 404 | case MainThreadAction.START_RENDER_LOOP: { 405 | renderLoop(); 406 | break; 407 | } 408 | case MainThreadAction.STOP_RENDER_LOOP: { 409 | cancelAnimationFrame(state.reqId); 410 | break; 411 | } 412 | default: { 413 | console.warn(`${NAME} received a message that does not handle`, event); 414 | } 415 | } 416 | }; 417 | onmessage = onMessage; 418 | 419 | const renderLoop = tick => { 420 | // state.camera.rotateZ(0.05); 421 | render(tick); 422 | if (state.error) { 423 | postMessage({ 424 | action: WorkerAction.NOTIFY, 425 | payload: { 426 | info: `[${NAME}] - error: ${state.error.message}. Please terminate me.`, 427 | }, 428 | source: NAME, 429 | }); 430 | cancelAnimationFrame(state.reqId); 431 | postMessage({ action: WorkerAction.TERMINATE_ME, source: NAME }); 432 | } else { 433 | state.reqId = requestAnimationFrame(renderLoop); 434 | } 435 | }; 436 | -------------------------------------------------------------------------------- /src/models/README.md: -------------------------------------------------------------------------------- 1 | # Models 2 | 3 | This directory contains 3D models in various formats. 4 | 5 | Each 3D model is emitted as a file in the output director, and can be required in JS, thanks to Webpack [file-loader](https://webpack.js.org/loaders/file-loader/). 6 | 7 | Here is the list of 3D models used in the application: 8 | 9 | - [male02](https://github.com/mrdoob/three.js/tree/master/examples/models/obj/male02/male02.obj) 10 | -------------------------------------------------------------------------------- /src/textures/checkerboard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackdbd/threejs-es6-webpack-starter/e4b00bbbfdb93016fbd486ec4a4b3456587b61fd/src/textures/checkerboard.jpg -------------------------------------------------------------------------------- /src/textures/star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackdbd/threejs-es6-webpack-starter/e4b00bbbfdb93016fbd486ec4a4b3456587b61fd/src/textures/star.png --------------------------------------------------------------------------------