├── .eslintignore ├── .eslintrc.cjs ├── .github ├── screenshots │ ├── asset-manager.png │ ├── console-build.png │ ├── console-error.png │ ├── console-start.png │ ├── console.png │ ├── console.psd │ └── demo.png └── workflows │ └── deploy-github-pages.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── esbuild.js ├── logging-utils.js ├── package.json ├── public ├── assets │ ├── ouside-afternoon-blurred-hdr.jpg │ ├── spotty-metal │ │ ├── albedo.jpg │ │ ├── metalness.jpg │ │ ├── normal.jpg │ │ └── roughness.jpg │ └── suzanne.gltf ├── css │ └── style.css └── index.html ├── src ├── index.js ├── scene │ ├── Box.js │ ├── CannonSphere.js │ ├── Suzanne.js │ └── lights.js ├── screenshot-record-buttons.js └── utils │ ├── AssetManager.js │ ├── ExponentialNumberController.js │ ├── WebGLApp.js │ ├── customizeShader.js │ ├── loadEnvMap.js │ ├── loadGLTF.js │ └── loadTexture.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | node: true, 5 | es6: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'prettier', 10 | ], 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 'latest', 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /.github/screenshots/asset-manager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/.github/screenshots/asset-manager.png -------------------------------------------------------------------------------- /.github/screenshots/console-build.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/.github/screenshots/console-build.png -------------------------------------------------------------------------------- /.github/screenshots/console-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/.github/screenshots/console-error.png -------------------------------------------------------------------------------- /.github/screenshots/console-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/.github/screenshots/console-start.png -------------------------------------------------------------------------------- /.github/screenshots/console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/.github/screenshots/console.png -------------------------------------------------------------------------------- /.github/screenshots/console.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/.github/screenshots/console.psd -------------------------------------------------------------------------------- /.github/screenshots/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/.github/screenshots/demo.png -------------------------------------------------------------------------------- /.github/workflows/deploy-github-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout repo 24 | uses: actions/checkout@v3 25 | - name: Setup Pages 26 | uses: actions/configure-pages@v2 27 | - name: Use latest node 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: 18 31 | cache: 'yarn' 32 | - name: Install Dependencies 33 | run: yarn 34 | - name: Build 35 | run: yarn build 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v1 38 | with: 39 | path: ./build 40 | 41 | deploy: 42 | runs-on: ubuntu-latest 43 | needs: build 44 | environment: 45 | name: github-pages 46 | url: ${{ steps.deployment.outputs.page_url }} 47 | steps: 48 | - name: Deploy to GitHub Pages 49 | id: deployment 50 | uses: actions/deploy-pages@v1 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | public/app.js 3 | public/app.js.map 4 | 5 | node_modules/ 6 | *.log 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | .* 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # threejs-modern-app 2 | 3 | > Boilerplate and utils for a fullscreen three.js app 4 | 5 | [![demo](.github/screenshots/demo.png)](https://marcofugaro.github.io/threejs-modern-app/?debug) 6 | 7 | It is inspired from [mattdesl](https://twitter.com/mattdesl)'s [threejs-app](https://github.com/mattdesl/threejs-app), but it was rewritten and simplified using **ES6** syntax rather than node, making it easier to read and well commented, so it can be easily customized to fit your needs. 8 | 9 | ### [DEMO](https://marcofugaro.github.io/threejs-modern-app/?debug) 10 | 11 | ## Features 12 | 13 | - All the **three.js boilerplate code is tucked away** in a file, the exported `WebGLApp` is easily configurable from the outside, for example you can enable [postprocessing](https://github.com/vanruesc/postprocessing), orbit controls, a [gui](https://github.com/georgealways/lil-gui), [FPS stats](https://github.com/marcofugaro/stats.js/), [Detect GPU](https://github.com/TimvanScherpenzeel/detect-gpu), and use the save screenshot or record mp4 functionality. It also has built-in support for [cannon-es](https://github.com/pmndrs/cannon-es). [[Read more](#webglapp)] 14 | - A **scalable three.js component structure** where each component is a class which extends `THREE.Group`, so you can add any object to it. The class also has update, resize, and pointer hooks. [[Read more](#component-structure)] 15 | - An **asset manager** which handles the preloading of `.gltf` models, images, audios, videos and can be easily extended to support other files. It also automatically uploads a texture to the GPU, loads cube env maps or parses equirectangular projection images. [[Read more](#asset-manager)] 16 | - global `window.DEBUG` flag which is true when the url contains `?debug` as a query parameter. So you can enable **debug mode** both locally and in production. [[Read more](#debug-mode)] 17 | - [glslify](https://github.com/glslify/glslify) to import shaders from `node_modules`. [[Read more](#glslify)] 18 | - GPU tiering info using [detect-gpu](https://github.com/TimvanScherpenzeel/detect-gpu) [[Read more](#gpu-info)] 19 | 20 | - Modern and customizable development tools such as ⚡️**esbuild**⚡️, eslint, and prettier. 21 | - Beautiful console output: 22 | 23 | ![console screenshots](.github/screenshots/console.png) 24 | 25 | ## Usage 26 | 27 | Once you installed the dependencies running `yarn`, these are the available commands: 28 | 29 | - `yarn start` starts a server locally 30 | - `yarn start --https` starts an HTTPS server locally 31 | - `yarn build` builds the project for production, ready to be deployed from the `build/` folder 32 | 33 | All the build tools logic is in the `package.json` and `esbuild.js`. 34 | 35 | ## WebGLApp 36 | 37 | ```js 38 | import WebGLApp from './utils/WebGLApp' 39 | 40 | const webgl = new WebGLApp({ ...options }) 41 | ``` 42 | 43 | The WebGLApp class contains all the code needed for three.js to run a scene, it is always the same so it makes sense to hide it in a standalone file and don't think about it. 44 | 45 | You can see an example configuration here: 46 | 47 | https://github.com/marcofugaro/threejs-modern-app/blob/3f9d43d2ad790a20b0c9a0b62db7095bac6cef82/src/index.js#L14-L30 48 | 49 | You can pass the class the options you would pass to the [THREE.WebGLRenderer](https://threejs.org/docs/#api/en/renderers/WebGLRenderer), and also some more options: 50 | 51 | | Option | Default | Description | 52 | | --------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 53 | | `background` | `'#111'` | The background of the scene. | 54 | | `backgroundAlpha` | 1 | The transparency of the background. | 55 | | `maxPixelRatio` | 2 | You can clamp the pixelRatio. Often the pixelRatio is clamped for performance reasons. | 56 | | `width` | `window.innerWidth` | The canvas width. | 57 | | `height` | `window.innerHeight` | The canvas height. | 58 | | `orthographic` | false | Use an [OrthographicCamera](https://threejs.org/docs/#api/en/cameras/PerspectiveCamera) instead of the default [PerspectiveCamera](https://threejs.org/docs/#api/en/cameras/PerspectiveCamera). | 59 | | `cameraPosition` | `new Vector3(0, 0, 4)` | Set the initial camera position. The camera will always look at [0, 0, 0]. | 60 | | `fov` | 45 | The field of view of the PerspectiveCamera. It is ignored if the option `orthographic` is true. | 61 | | `frustumSize` | 3 | Defines the size of the OrthographicCamera frustum. It is ignored if the option `orthographic` is false. | 62 | | `near` | 0.01 | The camera near plane. | 63 | | `far` | 100 | The camera far plane. | 64 | | `postprocessing` | false | Enable the [postprocessing library](https://github.com/vanruesc/postprocessing). The composer gets exposed as `webgl.composer`. | 65 | | `xr` | false | Enable three.js WebXR mode. The update function now will have a `xrframe` object passed as a third parameter. | 66 | | `showFps` | false | Show the [stats.js](https://github.com/mrdoob/stats.js/) fps counter. | 67 | | `orbitControls` | undefined | Set this to `true` to enable OrbitControls. You can also pass an object of [OrbitControls properties](https://threejs.org/docs/index.html#examples/en/controls/OrbitControls) to set. | 68 | | `gui` | undefined | Wether or not to initialize the gui using [lil-gui](https://github.com/georgealways/lil-gui). Exposed ad `webgl.gui`. | 69 | | `guiClosed` | false | Set this to `true` to initialize the gui panel closed. | 70 | | `world` | undefined | Accepts an instance of the [cannon-es](https://github.com/pmndrs/cannon-es) world (`new CANNON.World()`). Exposed as `webgl.world`. | 71 | | `showWorldWireframes` | false | Set this to `true` to show the wireframes of every body in the world. Uses [cannon-es-debugger](https://github.com/pmndrs/cannon-es-debugger). | 72 | 73 | The `webgl` instance will contain all the three.js elements such as `webgl.scene`, `webgl.renderer`, `webgl.camera` or `webgl.canvas`. It also exposes some useful properties and methods: 74 | 75 | ### webgl.isDragging 76 | 77 | Wether or not the user is currently dragging. It is `true` between the `onPointerDown` and `onPointerUp` events. 78 | 79 | ### webgl.cursor 80 | 81 | Set this property to change the cursor style of the canvas. For example you can use it to display the pointer cursor on some objects: 82 | 83 | ```js 84 | onPointerMove(event, { x, y }) { 85 | // raycast and get the intersecting mesh 86 | const intersectingMesh = getIntersectingMesh([x, y], this, this.webgl) 87 | 88 | if (intersectingMesh) { 89 | this.webgl.cursor = 'pointer' 90 | } else { 91 | this.webgl.cursor = null 92 | } 93 | } 94 | ``` 95 | 96 | ### webgl.saveScreenshot({ ...options }) 97 | 98 | Save a screenshot of the application as a png. 99 | 100 | | Option | Default | Description | 101 | | ---------- | -------------------- | --------------------------------- | 102 | | `width` | `window.innerWidth` | The width of the screenshot. | 103 | | `height` | `window.innerHeight` | The height of the screenshot. | 104 | | `fileName` | `'Screenshot'` | The name the .png file will have. | 105 | 106 | ### webgl.startRecording({ ...options }) 107 | 108 | Start the recording of a video using [mp4-wasm](https://github.com/mattdesl/mp4-wasm). 109 | 110 | | Option | Default | Description | 111 | | ---------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------- | 112 | | `width` | `window.innerWidth` | The width of the video. | 113 | | `height` | `window.innerHeight` | The height of the video. | 114 | | `fileName` | `'Recording'` | The name the .mp4 file will have. | 115 | | ...others | | Other options that you can pass to the `createWebCodecsEncoder()` method of [mp4-wasm](https://github.com/mattdesl/mp4-wasm). | 116 | 117 | ### webgl.stopRecording() 118 | 119 | Stop the recording and download the video. 120 | It returns a promise that is resolved once the processing is done. 121 | 122 | ### webgl.onUpdate((dt, time) => {}) 123 | 124 | Subscribe to the update `requestAnimationFrame` without having to create a component. If needed you can later unsubscribe the function with `webgl.offUpdate(function)`. 125 | 126 | | Parameter | Description | 127 | | --------- | -------------------------------------------------------------------------------------------- | 128 | | `dt` | The seconds elapsed from the latest frame, in a 60fps application it's `0.016s` (aka `16ms`) | 129 | | `time` | The time in seconds elapsed from when the animation loop starts | 130 | 131 | ### webgl.onPointerDown((event, { x, y }) => {}) 132 | 133 | Subscribe a function to the `pointerdown` event on the canvas without having to create a component. If needed you can later unsubscribe the function with `webgl.offPointerDown(function)`. 134 | 135 | | Parameter | Description | 136 | | ---------- | ---------------------------------------------------------------------------------- | 137 | | `event` | The native event. | 138 | | `position` | An object containing the `x` and the `y` position from the top left of the canvas. | 139 | 140 | ### webgl.onPointerMove((event, { x, y }) => {}) 141 | 142 | Subscribe a function to the `pointermove` event on the canvas without having to create a component. If needed you can later unsubscribe the function with `webgl.offPointerMove(function)`. 143 | 144 | | Parameter | Description | 145 | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 146 | | `event` | The native event. | 147 | | `position` | An object containing the `x` and the `y` position from the top left of the canvas. If the user is dragging, the object contains also the `dragX` and `dragY` distances from the drag start point. | 148 | 149 | ### webgl.onPointerUp((event, { x, y }) => {}) 150 | 151 | Subscribe a function to the `pointerup` event on the canvas without having to create a component. If needed you can later unsubscribe the function with `webgl.offPointerUp(function)`. 152 | 153 | | Parameter | Description | 154 | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 155 | | `event` | The native event. | 156 | | `position` | An object containing the `x` and the `y` position from the top left of the canvas. The object contains also the `dragX` and `dragY` distances from the drag start point. | 157 | 158 | ### webgl.gui.wireUniforms('My Material', material.uniforms) 159 | 160 | Automatically adds the exposed uniforms of a material to the gui, as a folder. 161 | Internally it adds the uniforms using [`gui.addSmart()`](#webgl-gui-add-smart-object-property) 162 | 163 | | Argument | Description | 164 | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 165 | | `folderName` | The name of the folder the gui will make. | 166 | | `uniforms` | The uniforms of the material. They are exposed by default in a [ShaderMaterial](https://threejs.org/docs/#api/en/materials/ShaderMaterial) or [RawShaderMaterial](https://threejs.org/docs/#api/en/materials/RawShaderMaterial). Or if you use the `addUniforms` function. | 167 | 168 | ### webgl.gui.addSmart(object, 'property') 169 | 170 | Like [`gui.add()`](https://lil-gui.georgealways.com/#GUI#add) but tries to be more smart about it. 171 | For example if the number is between 0 and 1, it sets the slider min to 0 and max to 1. Otherwise the slider uses an exponential mapping for easy iteration. 172 | 173 | | Argument | Description | 174 | | ------------ | -------------------------------------------------------------------------- | 175 | | `object` | The object the gui will modify. For example it can be any material. | 176 | | `'property'` | The name of the property in the object. The gui will modify this property. | 177 | 178 | ## Component structure 179 | 180 | Rather than writing all of your three.js app in one file instruction after instruction, you can split your app into thhree.js components". This makes it easier to manage the app as it grows. Here is a basic component: 181 | 182 | https://github.com/marcofugaro/threejs-modern-app/blob/master/src/scene/Box.js 183 | 184 | A three.js component is a class which extends [`THREE.Group`](https://threejs.org/docs/#api/en/objects/Group) (an alias for [`THREE.Object3D`](https://threejs.org/docs/#api/en/core/Object3D)) and subsequently inherits its properties and methods, such as `this.add(someMesh)` or `this.position` or `this.rotation`. [Here is a full list](https://threejs.org/docs/#api/en/core/Object3D). 185 | 186 | After having instantiated the class, you can add it directly to the scene. 187 | 188 | ```js 189 | // attach it to the scene so you can access it in other components 190 | webgl.scene.birds = new Birds(webgl, { count: 1000 }) 191 | webgl.scene.add(webgl.scene.birds) 192 | ``` 193 | 194 | And in the component, you can use the options like this. 195 | 196 | ```js 197 | export default class Birds extends Group { 198 | constructor(webgl, options = {}) { 199 | super(options) 200 | // these can be used also in other methods 201 | this.webgl = webgl 202 | this.options = options 203 | 204 | // destructure and default values like you do in React 205 | const { count = 10 } = this.options 206 | 207 | // ... 208 | ``` 209 | 210 | The class supports some hooks, which get called once the element is in the scene: 211 | 212 | ### update(dt, time) {} 213 | 214 | Called each frame of the animation loop of the application. Gets called by the main `requestAnimationFrame`. 215 | 216 | | Parameter | Description | 217 | | --------- | --------------------------------------------------------------------------------------------- | 218 | | `dt` | The seconds elapsed from the latest frame, in a 60fps application it's `0.016s` (aka `16ms`). | 219 | | `time` | The time in seconds elapsed from when the animation loop starts. | 220 | 221 | ### resize({ width, height, pixelRatio }) {} 222 | 223 | Called each time the window has been resized. 224 | 225 | | Parameter | Description | 226 | | ------------ | ---------------------------------------------------------------------------------------------------------- | 227 | | `width` | The window width. | 228 | | `height` | The window height. | 229 | | `pixelRatio` | The application pixelRatio, it's usually `window.devicePixelRatio` but clamped with `webgl.maxPixelRatio`. | 230 | 231 | ### onPointerDown(event, { x, y }) {} 232 | 233 | Called on the `pointerdown` event on the canvas. 234 | 235 | | Parameter | Description | 236 | | ---------- | ---------------------------------------------------------------------------------- | 237 | | `event` | The native event. | 238 | | `position` | An object containing the `x` and the `y` position from the top left of the canvas. | 239 | 240 | ### onPointerMove(event, { x, y }) {} 241 | 242 | Called on the `pointermove` event on the canvas. 243 | 244 | | Parameter | Description | 245 | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 246 | | `event` | The native event. | 247 | | `position` | An object containing the `x` and the `y` position from the top left of the canvas. If the user is dragging, the object contains also the `dragX` and `dragY` distances from the drag start point. | 248 | 249 | ### onPointerUp(event, { x, y }) {} 250 | 251 | Called on the `pointerup` event on the canvas. 252 | 253 | | Parameter | Description | 254 | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 255 | | `event` | The native event. | 256 | | `position` | An object containing the `x` and the `y` position from the top left of the canvas. The object contains also the `dragX` and `dragY` distances from the drag start point. | 257 | 258 | ### Functional Components 259 | 260 | If you don't need any of the previous methods, you can use functional components, which are just plain functions with the objective of making code easier to navigate in. 261 | 262 | ```js 263 | export function addLights(webgl) { 264 | const directionalLight = new DirectionalLight(0xffffff, 0.6) 265 | directionalLight.position.copy(position) 266 | webgl.scene.add(directionalLight) 267 | 268 | const ambientLight = new AmbientLight(0xffffff, 0.5) 269 | webgl.scene.add(ambientLight) 270 | } 271 | 272 | // ... 273 | 274 | addLights(webgl) 275 | ``` 276 | 277 | ## Asset Manager 278 | 279 | The Asset Manager handles the preloading of all the assets needed to run the scene. 280 | 281 | 282 | 283 | You can use it like this: 284 | 285 | https://github.com/marcofugaro/threejs-modern-app/blob/3f9d43d2ad790a20b0c9a0b62db7095bac6cef82/src/scene/Suzanne.js#L14-L45 286 | 287 | https://github.com/marcofugaro/threejs-modern-app/blob/3f9d43d2ad790a20b0c9a0b62db7095bac6cef82/src/index.js#L41 288 | 289 | https://github.com/marcofugaro/threejs-modern-app/blob/3f9d43d2ad790a20b0c9a0b62db7095bac6cef82/src/scene/Suzanne.js#L53 290 | 291 | In detail, first you queue the asset you want to preload in the component where you will use it 292 | 293 | ```js 294 | import assets from '../utils/AssetManager' 295 | 296 | const key = assets.queue({ 297 | url: 'assets/model.gltf', 298 | type: 'gltf', 299 | }) 300 | ``` 301 | 302 | Then you import the component in the `index.js` so that code gets executed 303 | 304 | ```js 305 | import Component from './scene/Component' 306 | ``` 307 | 308 | And then you start the queued assets loading promise, always in the `index.js` 309 | 310 | ```js 311 | assets.load({ renderer: webgl.renderer }).then(() => { 312 | // assets loaded! we can show the canvas 313 | }) 314 | ``` 315 | 316 | After that, you init the component and use the asset in the component like this 317 | 318 | ```js 319 | const modelGltf = assets.get(key) 320 | ``` 321 | 322 | These are all the exposed methods: 323 | 324 | ### assets.queue({ url, type, ...others }) 325 | 326 | Queue an asset to be downloaded later with [`assets.load()`](#assetsload-renderer-). 327 | 328 | | Option | Default | Description | 329 | | --------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 330 | | `url` | | The url of the asset relative to the `public/` folder. Can be an array if `type: 'env-map'` and you're loading a [cube texture](https://github.com/mrdoob/three.js/tree/dev/examples/textures/cube). | 331 | | `type` | autodetected | The type of the asset, can be either `gltf`, `image`, `svg`, `texture`, `env-map`, `json`, `audio` or `video`. If omitted it will be discerned from the asset extension. | 332 | | `pmrem` | false | Only if you set `type: 'env-map'`, you can pass `pmrem: true` to use the [PMREMGenerator](https://threejs.org/docs/#api/en/extras/PMREMGenerator) and prefilter for irradiance. This is often used when applying an envMap to an object rather than a scene background. | 333 | | `gamma` | false | Only if you set `type: 'texture'` or `type: 'env-map'`. By default, the encoding of the texture is set to linear color space. You can pass `gamma: true` to enable gamma correction for the texture, useful when loading color data such as albedo maps in a gamma corrected workflow. | 334 | | ...others | | Other options that can be assigned to a [Texture](https://threejs.org/docs/index.html#api/en/textures/Texture) when the type is either `env-map` or `texture`. | 335 | 336 | Returns a `key` that later you can use with [`assets.get()`](#assetsgetkey). 337 | 338 | ### assets.queueStandardMaterial(maps, options) 339 | 340 | Utility to queue multiple maps belonging to the same PBR material. They can later be passed directly to the [MeshStandardMaterial](https://threejs.org/docs/#api/en/materials/MeshStandardMaterial). 341 | 342 | For example, here is how you load a brick PBR texture: 343 | 344 | ```js 345 | const bricksKeys = assets.queueStandardMaterial( 346 | { 347 | map: `assets/bricks/albedo.jpg`, 348 | roughnessMap: `assets/bricks/roughness.jpg`, 349 | metalnessMap: `assets/bricks/metallic.jpg`, 350 | normalMap: `assets/bricks/normal.jpg`, 351 | displacementMap: `assets/bricks/height.jpg`, 352 | aoMap: `assets/bricks/ambientocclusion.jpg`, 353 | }, 354 | { 355 | repeat: new Vector2().setScalar(0.5), 356 | wrapS: RepeatWrapping, 357 | wrapT: RepeatWrapping, 358 | } 359 | ) 360 | ``` 361 | 362 | As you can see, you can pass as a second argument any property you want to apply to all textures. 363 | 364 | If you're using gamma, the textures with color data will be automatically gamma encoded. 365 | 366 | | Option | Default | Description | 367 | | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 368 | | `maps` | | An object containing urls for any map from the [MeshStandardMaterial](https://threejs.org/docs/#api/en/materials/MeshStandardMaterial) or [MeshPhysicalMaterial](https://threejs.org/docs/#api/en/materials/MeshPhysicalMaterial). | 369 | | `options` | | Options you can assign to all textures, such as wrapping or repeating. Any other property of the [Texture](https://threejs.org/docs/#api/en/textures/Texture) can be set. | 370 | 371 | Returns a `keys` object that later you can use with [`assets.getStandardMaterial()`](#assetsgetstandardmaterialkeys). 372 | 373 | ### assets.load({ renderer }) 374 | 375 | Load all the assets previously queued. 376 | 377 | | Option | Default | Description | 378 | | ---------- | ------- | ------------------------------------------------------------------- | 379 | | `renderer` | | The WebGLRenderer of your application, exposed as `webgl.renderer`. | 380 | 381 | ### assets.loadSingle({ url, type, renderer, ...others }) 382 | 383 | Load a single asset without having to pass through the queue. Useful if you want to lazy-load some assets after the application has started. Usually the assets that are not needed immediately. 384 | 385 | | Option | Default | Description | 386 | | ---------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 387 | | `renderer` | | The WebGLRenderer of your application, exposed as `webgl.renderer`. | 388 | | `url` | | The url of the asset relative to the `public/` folder. Can be an array if `type: 'env-map'` and you're loading a [cube texture](https://github.com/mrdoob/three.js/tree/dev/examples/textures/cube). | 389 | | `type` | autodetected | The type of the asset, can be either `gltf`, `image`, `svg`, `texture`, `env-map`, `json`, `audio` or `video`. If omitted it will be discerned from the asset extension. | 390 | | `pmrem` | false | Only if you set `type: 'env-map'`, you can pass `pmrem: true` to use the [PMREMGenerator](https://threejs.org/docs/#api/en/extras/PMREMGenerator) and prefilter for irradiance. This is often used when applying an envMap to an object rather than a scene background. | 391 | | `gamma` | false | Only if you set `type: 'texture'` or `type: 'env-map'`. By default, the encoding of the texture is set to linear color space. You can pass `gamma: true` to enable gamma correction for the texture, useful when loading color data such as albedo maps in a gamma corrected workflow. | 392 | | ...others | | Other options that can be assigned to a [Texture](https://threejs.org/docs/index.html#api/en/textures/Texture) when the type is either `env-map` or `texture`. | 393 | 394 | Returns a `key` that later you can use with [`assets.get()`](#assetsgetkey). 395 | 396 | ### assets.addProgressListener((progress) => {}) 397 | 398 | Pass a function that gets called each time an assets finishes downloading. The argument `progress` goes from 0 to 1, with 1 being every asset queued has been downloaded. 399 | 400 | ### assets.get(key) 401 | 402 | Retrieve an asset previously loaded with [`assets.queue()`](#assetsqueue-url-type-others-) or [`assets.loadSingle()`](#assetsloadsingle-url-type-renderer-others-). 403 | 404 | | Option | Default | Description | 405 | | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 406 | | `key` | | The key returned from [`assets.queue()`](#assetsqueue-url-type-others-) or [`assets.loadSingle()`](#assetsloadsingle-url-type-renderer-others-). It corresponds to the url of the asset. | 407 | 408 | ### assets.getStandardMaterial(keys) 409 | 410 | Retrieve an asset previously queued with [`assets.queueStandardMaterial()`](#assetsqueuestandardmaterialmaps-options). 411 | 412 | It returns an object of the loaded textures that can be fed directly into [MeshStandardMaterial](https://threejs.org/docs/#api/en/materials/MeshStandardMaterial) like this: 413 | 414 | ```js 415 | const textures = assets.getStandardMaterial(keys) 416 | const material = new MeshStandardMaterial({ ...textures }) 417 | ``` 418 | 419 | | Option | Default | Description | 420 | | ------ | ------- | ----------------------------------------------------------------------------------------------------------- | 421 | | `keys` | | The keys object returned from [`assets.queueStandardMaterial()`](#assetsqueuestandardmaterialmaps-options). | 422 | 423 | ## Debug mode 424 | 425 | Often you want to show the fps count or debug helpers such as the [SpotLightHelper](https://threejs.org/docs/#api/en/helpers/SpotLightHelper) only when you're developing or debugging. 426 | 427 | A really manageable way is to have a global `window.DEBUG` constant which is true only if you append `?debug` to your url, for example `http://localhost:8080/?debug` or even in production like `https://example.com/?debug`. 428 | 429 | This is done [here](https://github.com/marcofugaro/threejs-modern-app/blob/3f9d43d2ad790a20b0c9a0b62db7095bac6cef82/src/index.js#L9) in just one line: 430 | 431 | ```js 432 | window.DEBUG = window.location.search.includes('debug') 433 | ``` 434 | 435 | You could also add more global constants by just using more query-string parameters, like this `?debug&fps`. 436 | 437 | ## glslify 438 | 439 | [glslify](https://github.com/glslify/glslify) lets you import shader code directly from `node_modules`. 440 | 441 | For example, if you run through glslify a string you're using in three's [onBeforeCompile](https://threejs.org/docs/#api/en/materials/Material.onBeforeCompile), you can import [glsl-noise](https://github.com/hughsk/glsl-noise) like this: 442 | 443 | ```js 444 | import glsl from 'glslify' 445 | 446 | // ... 447 | 448 | shader.vertexShader = glsl` 449 | uniform float time; 450 | uniform float speed; 451 | uniform float frequency; 452 | uniform float amplitude; 453 | 454 | #pragma glslify: noise = require('glsl-noise/simplex/3d') 455 | 456 | // the function which defines the displacement 457 | float displace(vec3 point) { 458 | return noise(vec3(point.xy * frequency, time * speed)) * amplitude; 459 | } 460 | 461 | // ... 462 | ` 463 | ``` 464 | 465 | 💡 **BONUS TIP**: you can have glsl syntax highlighting for inline glsl strings in VSCode with the extension [glsl-literal](https://marketplace.visualstudio.com/items?itemName=boyswan.glsl-literal). 466 | 467 | glslify is applied also to files with the `.frag`, `.vert` or `.glsl` extensions. They are imported as plain strings: 468 | 469 | ```c 470 | // pass.vert 471 | varying vec2 vUv; 472 | 473 | void main() { 474 | vUv = uv; 475 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 476 | } 477 | ``` 478 | 479 | ```js 480 | // index.js 481 | import passVertexShader from '../shaders/pass.vert' 482 | 483 | // ... 484 | 485 | const material = new ShaderMaterial({ 486 | // it's a string 487 | vertexShader: passVert, 488 | 489 | // ... 490 | }) 491 | ``` 492 | 493 | For a list of shaders you can import via glslify check out [stack.gl packages list](http://stack.gl/packages/). 494 | 495 | ## GPU Info 496 | 497 | Sometimes it might be useful to enable expensive application configuration only on higher-end devices. 498 | 499 | This can be done by detecting the user's GPU and checking in which tier it belongs to based on its benchmark score. 500 | 501 | This is done thanks to [detect-gpu](https://github.com/TimvanScherpenzeel/detect-gpu), more detailed info about these mechanics in its README. 502 | 503 | For example, here is how to enable shadows only on high-tier devices: 504 | 505 | ```js 506 | if (webgl.gpu.tier > 1) { 507 | webgl.renderer.shadowMap.enabled = true 508 | 509 | // soft shadows 510 | webgl.renderer.shadowMap.type = PCFSoftShadowMap 511 | } 512 | ``` 513 | 514 | Here is what the exposed `webgl.gpu` object contains: 515 | 516 | | Key | Example Value | Description | 517 | | ---------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | 518 | | `tier` | `1` | The tier the GPU belongs to. It is incremental, so the higher the better. It goes from 0 to 3. Most GPUs belong to the Tier 2 | 519 | | `isMobile` | `false` | Wheter it is a mobile/tablet GPU, or a desktop GPU. | 520 | | `name` | `'intel iris graphics 6100'` | The string name of the GPU. | 521 | | `fps` | `21` | The specific rank value of the GPU. | 522 | 523 | ⚠️ **WARNING**: `webgl.gpu` is set asyncronously since the benchmark data needs to be fetched. You might want to wait for the exposed promise `webgl.loadGPUTier`. 524 | 525 | More info on this approach also in [this great talk](http://www.youtube.com/watch?v=iNMD8Vr1tKg&t=32m4s) by [luruke](https://github.com/luruke) 526 | -------------------------------------------------------------------------------- /esbuild.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | import esbuild from 'esbuild' 3 | import { glslify } from 'esbuild-plugin-glslify' 4 | import { glslifyInline } from 'esbuild-plugin-glslify-inline' 5 | import browserSync from 'browser-sync' 6 | import openBrowser from 'react-dev-utils/openBrowser.js' 7 | import { ip } from 'address' 8 | import { devLogger, prodLogger } from './logging-utils.js' 9 | 10 | const HTTPS = process.argv.includes('--https') 11 | const PORT = '8080' 12 | 13 | const isDevelopment = process.env.NODE_ENV === 'development' 14 | 15 | let local 16 | let external 17 | if (isDevelopment) { 18 | // start the development server 19 | const server = browserSync.create() 20 | server.init({ 21 | server: './public', 22 | watch: true, 23 | https: HTTPS, 24 | port: PORT, 25 | 26 | open: false, // don't open automatically 27 | notify: false, // don't show the browser notification 28 | minify: false, // don't minify files 29 | logLevel: 'silent', // no logging to console 30 | }) 31 | 32 | const urlOptions = server.instance.utils.getUrlOptions(server.instance.options) 33 | local = urlOptions.get('local') 34 | external = `${HTTPS ? 'https' : 'http'}://${ip()}:${PORT}` 35 | } 36 | 37 | const result = await esbuild 38 | .build({ 39 | entryPoints: ['src/index.js'], 40 | bundle: true, 41 | format: 'esm', 42 | logLevel: 'silent', // sssh... 43 | legalComments: 'none', // don't include licenses txt file 44 | sourcemap: true, 45 | ...(isDevelopment 46 | ? // 47 | // $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$$$\ 48 | // $$ __$$\ \__$$ __| $$ __$$\ $$ __$$\ \__$$ __| 49 | // $$ / \__| $$ | $$ / $$ | $$ | $$ | $$ | 50 | // \$$$$$$\ $$ | $$$$$$$$ | $$$$$$$ | $$ | 51 | // \____$$\ $$ | $$ __$$ | $$ __$$< $$ | 52 | // $$\ $$ | $$ | $$ | $$ | $$ | $$ | $$ | 53 | // \$$$$$$ | $$ | $$ | $$ | $$ | $$ | $$ | 54 | // \______/ \__| \__| \__| \__| \__| \__| 55 | // 56 | { 57 | outfile: 'public/app.js', 58 | watch: true, 59 | plugins: [ 60 | glslify(), 61 | glslifyInline(), 62 | devLogger({ 63 | localUrl: local, 64 | networkUrl: external, 65 | onFisrtBuild() { 66 | openBrowser(local) 67 | }, 68 | }), 69 | ], 70 | } 71 | : // 72 | // $$$$$$$\ $$\ $$\ $$$$$$\ $$\ $$$$$$$\ 73 | // $$ __$$\ $$ | $$ | \_$$ _| $$ | $$ __$$\ 74 | // $$ | $$ | $$ | $$ | $$ | $$ | $$ | $$ | 75 | // $$$$$$$\ | $$ | $$ | $$ | $$ | $$ | $$ | 76 | // $$ __$$\ $$ | $$ | $$ | $$ | $$ | $$ | 77 | // $$ | $$ | $$ | $$ | $$ | $$ | $$ | $$ | 78 | // $$$$$$$ | \$$$$$$ | $$$$$$\ $$$$$$$$\ $$$$$$$ | 79 | // \_______/ \______/ \______| \________| \_______/ 80 | // 81 | { 82 | outfile: 'build/app.js', 83 | minify: true, 84 | plugins: [ 85 | glslify({ compress: true }), 86 | glslifyInline({ compress: true }), 87 | prodLogger({ outDir: 'build/' }), 88 | ], 89 | metafile: true, 90 | entryNames: '[name]-[hash]', // add the contenthash to the filename 91 | }), 92 | }) 93 | .catch((err) => { 94 | console.error(err) 95 | process.exit(1) 96 | }) 97 | 98 | if (!isDevelopment) { 99 | // inject the hash into the index.html 100 | const jsFilePath = Object.keys(result.metafile.outputs).find((o) => o.endsWith('.js')) 101 | const jsFileName = jsFilePath.slice('build/'.length) // --> app-Y4WC7QZS.js 102 | 103 | let indexHtml = await fs.readFile('./build/index.html', 'utf-8') 104 | indexHtml = indexHtml.replace('src="app.js"', `src="${jsFileName}"`) 105 | await fs.writeFile('./build/index.html', indexHtml) 106 | } 107 | -------------------------------------------------------------------------------- /logging-utils.js: -------------------------------------------------------------------------------- 1 | import { performance } from 'perf_hooks' 2 | import chalk from 'chalk' 3 | import prettyMs from 'pretty-ms' 4 | import indentString from 'indent-string' 5 | import _ from 'lodash-es' 6 | import ora from 'ora' 7 | import tree from 'tree-node-cli' 8 | 9 | export function devLogger({ localUrl, networkUrl, onFisrtBuild = () => {} }) { 10 | return { 11 | name: 'devLogger', 12 | setup(build) { 13 | let startTime 14 | let isFirstBuild = true 15 | let spinner 16 | 17 | build.onStart(() => { 18 | startTime = performance.now() 19 | 20 | console.clear() 21 | spinner = ora(`Compiling...`).start() 22 | }) 23 | 24 | build.onEnd(({ errors }) => { 25 | if (errors.length > 0) { 26 | console.clear() 27 | spinner.fail(chalk.red`Failed to compile.`) 28 | const error = formatError(errors[0]) 29 | console.log(error) 30 | return 31 | } 32 | 33 | if (isFirstBuild) { 34 | isFirstBuild = false 35 | onFisrtBuild() 36 | } 37 | 38 | const buildTime = prettyMs(performance.now() - startTime) 39 | 40 | console.clear() 41 | spinner.succeed(chalk.green`Compiled successfully in ${chalk.cyan(buildTime)}`) 42 | console.log() 43 | console.log(` ${chalk.bold(`Local`)}: ${chalk.cyan(localUrl)}`) 44 | console.log(` ${chalk.bold(`On your network`)}: ${chalk.cyan(networkUrl)}`) 45 | console.log() 46 | }) 47 | }, 48 | } 49 | } 50 | 51 | export function prodLogger({ outDir }) { 52 | return { 53 | name: 'prodLogger', 54 | setup(build) { 55 | const startTime = performance.now() 56 | 57 | console.log() 58 | const spinner = ora(`Compiling...`).start() 59 | 60 | build.onEnd(({ errors }) => { 61 | if (errors.length > 0) { 62 | spinner.fail(chalk.red`Failed to compile.`) 63 | const error = formatError(errors[0]) 64 | console.log(error) 65 | return 66 | } 67 | 68 | const buildTime = prettyMs(performance.now() - startTime) 69 | 70 | spinner.succeed(chalk.green`Compiled successfully in ${chalk.cyan(buildTime)}`) 71 | console.log(`The folder ${chalk.bold(`${outDir}`)} is ready to be deployed`) 72 | console.log() 73 | 74 | const fileTree = tree(outDir, { dirsFirst: true, sizes: true }) 75 | console.log(beautifyTree(fileTree)) 76 | 77 | console.log() 78 | }) 79 | }, 80 | } 81 | } 82 | 83 | // format an esbuild error json 84 | function formatError(error) { 85 | const { text } = error 86 | const { column, file, line, lineText } = error.location 87 | const spacing = Array(column).fill(' ').join('') 88 | 89 | return ` 90 | ${chalk.bgWhiteBright.black(file)} 91 | ${chalk.red.bold`error:`} ${text} (${line}:${column}) 92 | 93 | ${chalk.dim` ${line} │ ${lineText} 94 | ╵ `}${spacing}${chalk.green`^`} 95 | ` 96 | } 97 | 98 | // make the console >tree command look pretty 99 | export function beautifyTree(tree) { 100 | const removeFolderSize = (s) => s.slice(s.indexOf(' ') + 1) 101 | const colorFilesizes = (s) => 102 | s.replace(/ ([A-Za-z0-9.]+) ([A-Za-z0-9.-]+)$/gm, ` ${chalk.yellow('$1')} $2`) 103 | const boldFirstLine = (s) => s.replace(/^(.*\n)/g, chalk.bold('$1')) 104 | const colorIt = (s) => chalk.cyan(s) 105 | const indent = (s) => indentString(s, 2) 106 | 107 | const beautify = _.flow([removeFolderSize, colorFilesizes, boldFirstLine, colorIt, indent]) 108 | 109 | return beautify(tree) 110 | } 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "version": "0.0.0", 4 | "description": "", 5 | "license": "MIT", 6 | "repository": "", 7 | "author": { 8 | "name": "", 9 | "email": "", 10 | "url": "" 11 | }, 12 | "type": "module", 13 | "scripts": { 14 | "start": "cross-env NODE_ENV=development node ./esbuild.js", 15 | "prebuild": "yarn clean; yarn copy-public", 16 | "build": "cross-env NODE_ENV=production node ./esbuild.js", 17 | "clean": "rimraf build/; rimraf public/app.js; rimraf public/app.js.map", 18 | "copy-public": "cpr public/ build/" 19 | }, 20 | "dependencies": { 21 | "cannon-es": "^0.20.0", 22 | "cannon-es-debugger": "^1.0.0", 23 | "detect-gpu": "^5.0.37", 24 | "image-promise": "^7.0.1", 25 | "lil-gui": "^0.19.1", 26 | "lodash-es": "^4.17.21", 27 | "mp4-wasm": "marcofugaro/mp4-wasm#build-embedded", 28 | "p-map": "^6.0.0", 29 | "postprocessing": "^6.33.3", 30 | "pretty-ms": "^8.0.0", 31 | "stats.js": "marcofugaro/stats.js", 32 | "three": "0.158.0" 33 | }, 34 | "devDependencies": { 35 | "address": "^2.0.1", 36 | "browser-sync": "^2.29.3", 37 | "chalk": "4.1.2", 38 | "cpr": "^3.0.1", 39 | "cross-env": "^7.0.3", 40 | "esbuild": "0.16.17", 41 | "esbuild-plugin-glslify": "^1.0.1", 42 | "esbuild-plugin-glslify-inline": "^1.1.0", 43 | "eslint": "^8.54.0", 44 | "eslint-config-prettier": "^9.0.0", 45 | "glslify": "^7.1.1", 46 | "indent-string": "^5.0.0", 47 | "ora": "^7.0.1", 48 | "react-dev-utils": "^12.0.1", 49 | "rimraf": "^5.0.5", 50 | "tree-node-cli": "^1.6.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /public/assets/ouside-afternoon-blurred-hdr.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/public/assets/ouside-afternoon-blurred-hdr.jpg -------------------------------------------------------------------------------- /public/assets/spotty-metal/albedo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/public/assets/spotty-metal/albedo.jpg -------------------------------------------------------------------------------- /public/assets/spotty-metal/metalness.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/public/assets/spotty-metal/metalness.jpg -------------------------------------------------------------------------------- /public/assets/spotty-metal/normal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/public/assets/spotty-metal/normal.jpg -------------------------------------------------------------------------------- /public/assets/spotty-metal/roughness.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcofugaro/threejs-modern-app/293f4233b71539db115690dab423af913641576d/public/assets/spotty-metal/roughness.jpg -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | canvas { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | outline: none; 6 | } 7 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { EffectPass, VignetteEffect } from 'postprocessing' 2 | import WebGLApp from './utils/WebGLApp' 3 | import assets from './utils/AssetManager' 4 | import Suzanne from './scene/Suzanne' 5 | import { addNaturalLight } from './scene/lights' 6 | import { addScreenshotButton, addRecordButton } from './screenshot-record-buttons' 7 | 8 | // true if the url has the `?debug` parameter, otherwise false 9 | window.DEBUG = window.location.search.includes('debug') 10 | 11 | // grab our canvas 12 | const canvas = document.querySelector('#app') 13 | 14 | // setup the WebGLRenderer 15 | const webgl = new WebGLApp({ 16 | canvas, 17 | // set the scene background color 18 | background: '#111', 19 | backgroundAlpha: 1, 20 | // enable postprocessing 21 | postprocessing: true, 22 | // show the fps counter from stats.js 23 | showFps: window.DEBUG, 24 | // enable OrbitControls 25 | orbitControls: window.DEBUG, 26 | // show the GUI 27 | gui: window.DEBUG, 28 | // enable cannon-es 29 | // world: new CANNON.World(), 30 | }) 31 | 32 | // attach it to the window to inspect in the console 33 | if (window.DEBUG) { 34 | window.webgl = webgl 35 | } 36 | 37 | // hide canvas 38 | webgl.canvas.style.visibility = 'hidden' 39 | 40 | // load any queued assets 41 | await assets.load({ renderer: webgl.renderer }) 42 | 43 | // add any "WebGL components" here... 44 | // append them to the scene so you can 45 | // use them from other components easily 46 | webgl.scene.suzanne = new Suzanne(webgl) 47 | webgl.scene.add(webgl.scene.suzanne) 48 | 49 | // lights and other scene related stuff 50 | addNaturalLight(webgl) 51 | 52 | // postprocessing 53 | // add an existing effect from the postprocessing library 54 | webgl.composer.addPass(new EffectPass(webgl.camera, new VignetteEffect())) 55 | 56 | // add the save screenshot and save gif buttons 57 | if (window.DEBUG) { 58 | addScreenshotButton(webgl) 59 | addRecordButton(webgl) 60 | } 61 | 62 | // show canvas 63 | webgl.canvas.style.visibility = '' 64 | 65 | // start animation loop 66 | webgl.start() 67 | -------------------------------------------------------------------------------- /src/scene/Box.js: -------------------------------------------------------------------------------- 1 | import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three' 2 | 3 | // basic three.js component example 4 | 5 | export default class Box extends Group { 6 | constructor(webgl, options = {}) { 7 | super(options) 8 | // these can be used also in other methods 9 | this.webgl = webgl 10 | this.options = options 11 | 12 | // destructure and default values like you do in React 13 | const { color = 0x00ff00 } = this.options 14 | 15 | const geometry = new BoxGeometry(1, 1, 1) 16 | const material = new MeshBasicMaterial({ color, wireframe: true }) 17 | this.box = new Mesh(geometry, material) 18 | 19 | // add it to the group, 20 | // later the group will be added to the scene 21 | this.add(this.box) 22 | } 23 | 24 | update(dt, time) { 25 | this.box.rotation.y += dt * 0.5 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/scene/CannonSphere.js: -------------------------------------------------------------------------------- 1 | import { Group, Mesh, MeshStandardMaterial, SphereGeometry } from 'three' 2 | import { Body, Sphere } from 'cannon-es' 3 | 4 | // remember to add the body to the CANNON world and 5 | // the mesh to the three.js scene or to some component 6 | // 7 | // const sphere = new CannonSphere(webgl, { mass: 1, radius: 1 }) 8 | // webgl.world.addBody(sphere) 9 | // webgl.scene.add(sphere.mesh) 10 | 11 | export default class CannonSphere extends Body { 12 | mesh = new Group() 13 | 14 | constructor(webgl, options = {}) { 15 | super(options) 16 | this.webgl = webgl 17 | this.options = options 18 | 19 | const { radius = 1 } = this.options 20 | 21 | this.addShape(new Sphere(radius)) 22 | 23 | // add corresponding geometry and material 24 | this.mesh.add( 25 | new Mesh( 26 | new SphereGeometry(radius, 32, 32), 27 | new MeshStandardMaterial({ color: Math.random() * 0xffffff }) 28 | ) 29 | ) 30 | 31 | // sync the position the first time 32 | this.update() 33 | } 34 | 35 | update(dt, time) { 36 | // sync the mesh to the physical body 37 | this.mesh.position.copy(this.position) 38 | this.mesh.quaternion.copy(this.quaternion) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/scene/Suzanne.js: -------------------------------------------------------------------------------- 1 | import { Group, MeshStandardMaterial, Raycaster, Vector2 } from 'three' 2 | import glsl from 'glslify' 3 | import assets from '../utils/AssetManager' 4 | import { addUniforms, customizeVertexShader } from '../utils/customizeShader' 5 | 6 | // elaborated three.js component example 7 | // containing example usage of 8 | // - asset manager 9 | // - control panel 10 | // - touch events 11 | // - postprocessing 12 | // - screenshot saving 13 | 14 | // preload the suzanne model 15 | const suzanneKey = assets.queue({ 16 | url: 'assets/suzanne.gltf', 17 | type: 'gltf', 18 | }) 19 | 20 | // preload the materials 21 | const albedoKey = assets.queue({ 22 | url: 'assets/spotty-metal/albedo.jpg', 23 | type: 'texture', 24 | gamma: true, // use gamma correction 25 | }) 26 | const metalnessKey = assets.queue({ 27 | url: 'assets/spotty-metal/metalness.jpg', 28 | type: 'texture', 29 | }) 30 | const roughnessKey = assets.queue({ 31 | url: 'assets/spotty-metal/roughness.jpg', 32 | type: 'texture', 33 | }) 34 | const normalKey = assets.queue({ 35 | url: 'assets/spotty-metal/normal.jpg', 36 | type: 'texture', 37 | }) 38 | 39 | // preload the environment map 40 | const hdrKey = assets.queue({ 41 | url: 'assets/ouside-afternoon-blurred-hdr.jpg', 42 | type: 'env-map', 43 | gamma: true, // use gamma correction 44 | }) 45 | 46 | export default class Suzanne extends Group { 47 | constructor(webgl, options = {}) { 48 | super(options) 49 | this.webgl = webgl 50 | this.options = options 51 | 52 | const suzanneGltf = assets.get(suzanneKey) 53 | const suzanne = suzanneGltf.scene.clone() 54 | 55 | const envMap = assets.get(hdrKey) 56 | const material = new MeshStandardMaterial({ 57 | map: assets.get(albedoKey), 58 | metalnessMap: assets.get(metalnessKey), 59 | roughnessMap: assets.get(roughnessKey), 60 | normalMap: assets.get(normalKey), 61 | normalScale: new Vector2(2, 2), 62 | envMap, 63 | roughness: 0.5, 64 | metalness: 1, 65 | }) 66 | webgl.gui?.addSmart(material, 'roughness') 67 | this.material = material 68 | 69 | // add new unifroms and expose current uniforms 70 | addUniforms(material, { 71 | time: { value: 0 }, 72 | frequency: { value: 0.5 }, 73 | amplitude: { value: 0.7 }, 74 | }) 75 | webgl.gui?.wireUniforms('Movement', material.uniforms, { blacklist: ['time'] }) 76 | 77 | customizeVertexShader(material, { 78 | head: glsl` 79 | uniform float time; 80 | uniform float frequency; 81 | uniform float amplitude; 82 | 83 | // you could import glsl packages like this 84 | // #pragma glslify: noise3d = require(glsl-noise/simplex/3d) 85 | `, 86 | main: glsl` 87 | float theta = sin(position.z * frequency + time) * amplitude; 88 | float c = cos(theta); 89 | float s = sin(theta); 90 | mat3 deformMatrix = mat3(c, 0, s, 0, 1, 0, -s, 0, c); 91 | `, 92 | // hook that lets you modify the normal 93 | objectNormal: glsl` 94 | objectNormal *= deformMatrix; 95 | `, 96 | // hook that lets you modify the position 97 | transformed: glsl` 98 | transformed *= deformMatrix; 99 | `, 100 | }) 101 | 102 | // apply the material to the model 103 | suzanne.traverse((child) => { 104 | if (child.isMesh) { 105 | child.material = material 106 | } 107 | }) 108 | 109 | // make it a little bigger 110 | suzanne.scale.multiplyScalar(1.2) 111 | 112 | // incremental speed, we can change it through the GUI 113 | this.speed = 1.5 114 | webgl.gui?.folders.find((f) => f._title === 'Movement').addSmart(this, 'speed') 115 | 116 | this.add(suzanne) 117 | 118 | // set the background as the hdr 119 | this.webgl.scene.background = envMap 120 | } 121 | 122 | onPointerDown(event, { x, y }) { 123 | // for example, check of we clicked on an 124 | // object with raycasting 125 | const coords = new Vector2().set( 126 | (x / this.webgl.width) * 2 - 1, 127 | (-y / this.webgl.height) * 2 + 1 128 | ) 129 | const raycaster = new Raycaster() 130 | raycaster.setFromCamera(coords, this.webgl.camera) 131 | const hits = raycaster.intersectObject(this, true) 132 | console.log(hits.length > 0 ? `Hit ${hits[0].object.name}!` : 'No hit') 133 | // this, of course, doesn't take into consideration the 134 | // mesh deformation in the vertex shader 135 | } 136 | 137 | update(dt, time) { 138 | this.material.uniforms.time.value += dt * this.speed 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/scene/lights.js: -------------------------------------------------------------------------------- 1 | import { DirectionalLight, HemisphereLight } from 'three' 2 | 3 | // natural hemisphere light from 4 | // https://threejs.org/examples/#webgl_lights_hemisphere 5 | export function addNaturalLight(webgl) { 6 | const hemiLight = new HemisphereLight(0xffffff, 0xffffff, 0.6) 7 | hemiLight.color.setHSL(0.6, 1, 0.6) 8 | hemiLight.groundColor.setHSL(0.095, 1, 0.75) 9 | hemiLight.position.set(0, 50, 0) 10 | webgl.scene.add(hemiLight) 11 | 12 | const dirLight = new DirectionalLight(0xffffff, 1) 13 | dirLight.color.setHSL(0.1, 1, 0.95) 14 | dirLight.position.set(3, 5, 1) 15 | dirLight.position.multiplyScalar(50) 16 | webgl.scene.add(dirLight) 17 | } 18 | -------------------------------------------------------------------------------- /src/screenshot-record-buttons.js: -------------------------------------------------------------------------------- 1 | // normally the styles would be in style.css 2 | const buttonStyles = ` 3 | .button { 4 | background: chocolate; 5 | box-shadow: 0px 5px 0px 0px #c71e1e; 6 | cursor: pointer; 7 | padding: 12px 16px; 8 | margin: 12px; 9 | border-radius: 5px; 10 | color: white; 11 | font-size: 24px; 12 | } 13 | 14 | .button:active { 15 | transform: translateY(4px); 16 | box-shadow: none; 17 | } 18 | 19 | .button[disabled] { 20 | pointer-events: none; 21 | opacity: 0.7; 22 | } 23 | ` 24 | 25 | // demo the save screenshot feature 26 | export function addScreenshotButton(webgl) { 27 | document.head.innerHTML = `${document.head.innerHTML}` 28 | 29 | const screenshotButton = document.createElement('div') 30 | screenshotButton.classList.add('button') 31 | screenshotButton.style.position = 'fixed' 32 | screenshotButton.style.bottom = 0 33 | screenshotButton.style.right = 0 34 | 35 | screenshotButton.textContent = '📸 Save screenshot' 36 | screenshotButton.addEventListener('click', () => webgl.saveScreenshot()) 37 | 38 | document.body.appendChild(screenshotButton) 39 | } 40 | 41 | // demo the save video feature 42 | export function addRecordButton(webgl) { 43 | document.head.innerHTML = `${document.head.innerHTML}` 44 | 45 | const recordButton = document.createElement('div') 46 | recordButton.classList.add('button') 47 | recordButton.style.position = 'fixed' 48 | recordButton.style.bottom = 0 49 | recordButton.style.left = 0 50 | 51 | recordButton.textContent = '🔴 Start recording mp4' 52 | recordButton.addEventListener('click', async () => { 53 | if (!webgl.isRecording) { 54 | recordButton.textContent = '🟥 Stop recording mp4' 55 | webgl.startRecording() 56 | } else { 57 | recordButton.textContent = '⏳ Processing video...' 58 | recordButton.setAttribute('disabled', '') 59 | await webgl.stopRecording() 60 | recordButton.removeAttribute('disabled') 61 | recordButton.textContent = '🔴 Start recording mp4' 62 | } 63 | }) 64 | 65 | document.body.appendChild(recordButton) 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/AssetManager.js: -------------------------------------------------------------------------------- 1 | import pMap from 'p-map' 2 | import prettyMs from 'pretty-ms' 3 | import loadImage from 'image-promise' 4 | import omit from 'lodash/omit' 5 | import loadTexture from './loadTexture' 6 | import loadEnvMap from './loadEnvMap' 7 | import loadGLTF from './loadGLTF' 8 | import { mapValues } from 'lodash-es' 9 | 10 | class AssetManager { 11 | #queue = [] 12 | #loaded = {} 13 | #onProgressListeners = [] 14 | #asyncConcurrency = 10 15 | #logs = [] 16 | 17 | addProgressListener(fn) { 18 | if (typeof fn !== 'function') { 19 | throw new TypeError('onProgress must be a function') 20 | } 21 | this.#onProgressListeners.push(fn) 22 | } 23 | 24 | // Add an asset to be queued, input: { url, type, ...options } 25 | queue({ url, type, ...options }) { 26 | if (!url) throw new TypeError('Must specify a URL or opt.url for AssetManager.queue()') 27 | 28 | const queued = this._getQueued(url) 29 | if (queued) { 30 | // if it's already present, add only if the options are different 31 | const queuedOptions = omit(queued, ['url', 'type']) 32 | if (JSON.stringify(options) !== JSON.stringify(queuedOptions)) { 33 | const hash = performance.now().toFixed(3).replace('.', '') 34 | const key = `${url}.${hash}` 35 | this.#queue.push({ key, url, type: type || this._extractType(url), ...options }) 36 | return key 37 | } 38 | 39 | return queued.url 40 | } 41 | 42 | this.#queue.push({ url, type: type || this._extractType(url), ...options }) 43 | return url 44 | } 45 | 46 | // Add a MeshStandardMaterial to be queued, 47 | // input: { map, metalnessMap, roughnessMap, normalMap, ... } 48 | queueStandardMaterial(maps, options = {}) { 49 | const keys = {} 50 | 51 | // These textures are non-color and they don't 52 | // need gamma correction 53 | const linearTextures = [ 54 | 'pbrMap', 55 | 'alphaMap', 56 | 'aoMap', 57 | 'bumpMap', 58 | 'displacementMap', 59 | 'lightMap', 60 | 'metalnessMap', 61 | 'normalMap', 62 | 'roughnessMap', 63 | 'clearcoatMap', 64 | 'clearcoatNormalMap', 65 | 'clearcoatRoughnessMap', 66 | 'sheenRoughnessMap', 67 | 'sheenColorMap', 68 | 'specularIntensityMap', 69 | 'specularColorMap', 70 | 'thicknessMap', 71 | 'transmissionMap', 72 | ] 73 | 74 | Object.keys(maps).forEach((map) => { 75 | keys[map] = this.queue({ 76 | url: maps[map], 77 | type: 'texture', 78 | ...options, 79 | ...(!linearTextures.includes(map) && { gamma: true }), 80 | }) 81 | }) 82 | 83 | return keys 84 | } 85 | 86 | _getQueued(url) { 87 | return this.#queue.find((item) => item.url === url) 88 | } 89 | 90 | _extractType(url) { 91 | const ext = url.slice(url.lastIndexOf('.')) 92 | 93 | switch (true) { 94 | case /\.(gltf|glb)$/i.test(ext): 95 | return 'gltf' 96 | case /\.json$/i.test(ext): 97 | return 'json' 98 | case /\.svg$/i.test(ext): 99 | return 'svg' 100 | case /\.(jpe?g|png|gif|bmp|tga|tif)$/i.test(ext): 101 | return 'image' 102 | case /\.(wav|mp3)$/i.test(ext): 103 | return 'audio' 104 | case /\.(mp4|webm|ogg|ogv)$/i.test(ext): 105 | return 'video' 106 | default: 107 | throw new Error(`Could not load ${url}, unknown file extension!`) 108 | } 109 | } 110 | 111 | // Fetch a loaded asset by URL 112 | get = (key) => { 113 | if (!key) throw new TypeError('Must specify an URL for AssetManager.get()') 114 | 115 | return this.#loaded[key] 116 | } 117 | 118 | // Fetch a loaded MeshStandardMaterial object 119 | getStandardMaterial = (keys) => { 120 | return mapValues(keys, (key) => this.get(key)) 121 | } 122 | 123 | // Loads a single asset on demand. 124 | async loadSingle({ renderer, ...item }) { 125 | // renderer is used to load textures and env maps, 126 | // but require it always since it is an extensible pattern 127 | if (!renderer) { 128 | throw new Error('You must provide a renderer to the loadSingle function.') 129 | } 130 | 131 | try { 132 | const itemLoadingStart = performance.now() 133 | 134 | const key = item.key || item.url 135 | if (!(key in this.#loaded)) { 136 | this.#loaded[key] = await this._loadItem({ renderer, ...item }) 137 | } 138 | 139 | if (window.DEBUG) { 140 | console.log( 141 | `📦 Loaded single asset %c${item.url}%c in ${prettyMs( 142 | performance.now() - itemLoadingStart 143 | )}`, 144 | 'color:blue', 145 | 'color:black' 146 | ) 147 | } 148 | 149 | return key 150 | } catch (err) { 151 | console.error(`📦 Asset ${item.url} was not loaded:\n${err}`) 152 | } 153 | } 154 | 155 | // Loads all queued assets 156 | async load({ renderer }) { 157 | // renderer is used to load textures and env maps, 158 | // but require it always since it is an extensible pattern 159 | if (!renderer) { 160 | throw new Error('You must provide a renderer to the load function.') 161 | } 162 | 163 | const queue = this.#queue.slice() 164 | this.#queue.length = 0 // clear queue 165 | 166 | const total = queue.length 167 | if (total === 0) { 168 | // resolve first this functions and then call the progress listeners 169 | setTimeout(() => this.#onProgressListeners.forEach((fn) => fn(1)), 0) 170 | return 171 | } 172 | 173 | const loadingStart = performance.now() 174 | 175 | await pMap( 176 | queue, 177 | async (item, i) => { 178 | try { 179 | const itemLoadingStart = performance.now() 180 | 181 | const key = item.key || item.url 182 | if (!(key in this.#loaded)) { 183 | this.#loaded[key] = await this._loadItem({ renderer, ...item }) 184 | } 185 | 186 | if (window.DEBUG) { 187 | this.log( 188 | `Loaded %c${item.url}%c in ${prettyMs(performance.now() - itemLoadingStart)}`, 189 | 'color:blue', 190 | 'color:black' 191 | ) 192 | } 193 | } catch (err) { 194 | this.logError(`Asset ${item.url} was not loaded:\n${err}`) 195 | } 196 | 197 | const percent = (i + 1) / total 198 | this.#onProgressListeners.forEach((fn) => fn(percent)) 199 | }, 200 | { concurrency: this.#asyncConcurrency } 201 | ) 202 | 203 | if (window.DEBUG) { 204 | const errors = this.#logs.filter((log) => log.type === 'error') 205 | 206 | if (errors.length === 0) { 207 | this.groupLog(`📦 Assets loaded in ${prettyMs(performance.now() - loadingStart)} ⏱`) 208 | } else { 209 | this.groupLog( 210 | `📦 %c Could not load ${errors.length} asset${errors.length > 1 ? 's' : ''} `, 211 | 'color:white;background:red;' 212 | ) 213 | } 214 | } 215 | } 216 | 217 | // Loads a single asset. 218 | _loadItem({ url, type, renderer, ...options }) { 219 | switch (type) { 220 | case 'gltf': 221 | return loadGLTF(url, options) 222 | case 'json': 223 | return fetch(url).then((response) => response.json()) 224 | case 'envmap': 225 | case 'envMap': 226 | case 'env-map': 227 | return loadEnvMap(url, { renderer, ...options }) 228 | case 'svg': 229 | case 'image': 230 | return loadImage(url, { crossorigin: 'anonymous' }) 231 | case 'texture': 232 | return loadTexture(url, { renderer, ...options }) 233 | case 'audio': 234 | // You might not want to load big audio files and 235 | // store them in memory, that might be inefficient. 236 | // Rather load them outside of the queue 237 | return fetch(url).then((response) => response.arrayBuffer()) 238 | case 'video': 239 | // You might not want to load big video files and 240 | // store them in memory, that might be inefficient. 241 | // Rather load them outside of the queue 242 | return fetch(url).then((response) => response.blob()) 243 | default: 244 | throw new Error(`Could not load ${url}, the type ${type} is unknown!`) 245 | } 246 | } 247 | 248 | log(...text) { 249 | this.#logs.push({ type: 'log', text }) 250 | } 251 | 252 | logError(...text) { 253 | this.#logs.push({ type: 'error', text }) 254 | } 255 | 256 | groupLog(...text) { 257 | console.groupCollapsed(...text) 258 | this.#logs.forEach((log) => { 259 | console[log.type](...log.text) 260 | }) 261 | console.groupEnd() 262 | 263 | this.#logs.length = 0 // clear logs 264 | } 265 | } 266 | 267 | // asset manager is a singleton, you can require it from 268 | // different files and use the same instance. 269 | // A plain js object would have worked just fine, 270 | // fucking java patterns 271 | export default new AssetManager() 272 | -------------------------------------------------------------------------------- /src/utils/ExponentialNumberController.js: -------------------------------------------------------------------------------- 1 | import { NumberController } from 'lil-gui' 2 | 3 | // Exponential slider for lil-gui. 4 | // Only for numbers > 0 5 | 6 | const mapping = (x) => Math.pow(10, x) 7 | const inverseMapping = Math.log10 8 | 9 | export class ExponentialNumberController extends NumberController { 10 | updateDisplay() { 11 | super.updateDisplay() 12 | 13 | if (this._hasSlider) { 14 | const value = inverseMapping(this.getValue()) 15 | const min = inverseMapping(this._min) 16 | const max = inverseMapping(this._max) 17 | let percent = (value - min) / (max - min) 18 | percent = Math.max(0, Math.min(percent, 1)) 19 | 20 | this.$fill.style.width = percent * 100 + '%' 21 | } 22 | 23 | return this 24 | } 25 | 26 | _initSlider() { 27 | this._hasSlider = true 28 | 29 | // Build DOM 30 | // --------------------------------------------------------------------- 31 | 32 | this.$slider = document.createElement('div') 33 | this.$slider.classList.add('slider') 34 | 35 | this.$fill = document.createElement('div') 36 | this.$fill.classList.add('fill') 37 | 38 | this.$slider.appendChild(this.$fill) 39 | this.$widget.insertBefore(this.$slider, this.$input) 40 | 41 | this.domElement.classList.add('hasSlider') 42 | 43 | // Map clientX to value 44 | // --------------------------------------------------------------------- 45 | 46 | const min = inverseMapping(this._min) 47 | const max = inverseMapping(this._max) 48 | 49 | const clamp = (value) => { 50 | if (value < min) value = min 51 | if (value > max) value = max 52 | return value 53 | } 54 | 55 | const map = (v, a, b, c, d) => { 56 | return ((v - a) / (b - a)) * (d - c) + c 57 | } 58 | 59 | const setValueFromX = (clientX) => { 60 | const rect = this.$slider.getBoundingClientRect() 61 | let value = map(clientX, rect.left, rect.right, min, max) 62 | this.setValue(this._snap(mapping(clamp(this._snap(value))))) 63 | } 64 | 65 | const mouseDown = (e) => { 66 | this._setDraggingStyle(true) 67 | setValueFromX(e.clientX) 68 | window.addEventListener('pointermove', mouseMove) 69 | window.addEventListener('pointerup', mouseUp) 70 | } 71 | 72 | const mouseMove = (e) => { 73 | setValueFromX(e.clientX) 74 | } 75 | 76 | const mouseUp = () => { 77 | this._callOnFinishChange() 78 | this._setDraggingStyle(false) 79 | window.removeEventListener('pointermove', mouseMove) 80 | window.removeEventListener('pointerup', mouseUp) 81 | } 82 | 83 | this.$slider.addEventListener('pointerdown', mouseDown) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/WebGLApp.js: -------------------------------------------------------------------------------- 1 | import { 2 | Color, 3 | HalfFloatType, 4 | OrthographicCamera, 5 | PerspectiveCamera, 6 | Scene, 7 | Vector3, 8 | WebGLRenderer, 9 | } from 'three' 10 | import { OrbitControls } from 'three/addons/controls/OrbitControls.js' 11 | import Stats from 'stats.js' 12 | import { getGPUTier } from 'detect-gpu' 13 | import { EffectComposer, RenderPass } from 'postprocessing' 14 | import CannonDebugger from 'cannon-es-debugger' 15 | import loadMP4Module, { isWebCodecsSupported } from 'mp4-wasm' 16 | import GUI from 'lil-gui' 17 | import { ExponentialNumberController } from '../utils/ExponentialNumberController' 18 | 19 | export default class WebGLApp { 20 | #width 21 | #height 22 | isRunning = false 23 | time = 0 24 | dt = 0 25 | #lastTime = performance.now() 26 | #updateListeners = [] 27 | #pointerdownListeners = [] 28 | #pointermoveListeners = [] 29 | #pointerupListeners = [] 30 | #startX 31 | #startY 32 | #mp4 33 | #mp4Encoder 34 | #fileName 35 | #frames = [] 36 | 37 | get background() { 38 | return this.renderer.getClearColor(new Color()) 39 | } 40 | 41 | get backgroundAlpha() { 42 | return this.renderer.getClearAlpha() 43 | } 44 | 45 | set background(background) { 46 | this.renderer.setClearColor(background, this.backgroundAlpha) 47 | } 48 | 49 | set backgroundAlpha(backgroundAlpha) { 50 | this.renderer.setClearColor(this.background, backgroundAlpha) 51 | } 52 | 53 | get isRecording() { 54 | return Boolean(this.#mp4Encoder) 55 | } 56 | 57 | constructor({ 58 | background = '#111', 59 | backgroundAlpha = 1, 60 | fov = 45, 61 | frustumSize = 3, 62 | near = 0.01, 63 | far = 100, 64 | ...options 65 | } = {}) { 66 | this.renderer = new WebGLRenderer({ 67 | antialias: !options.postprocessing, 68 | alpha: backgroundAlpha !== 1, 69 | // enabled for recording gifs or videos, 70 | // might disable it for performance reasons 71 | preserveDrawingBuffer: true, 72 | ...options, 73 | }) 74 | 75 | if (options.sortObjects !== undefined) { 76 | this.renderer.sortObjects = options.sortObjects 77 | } 78 | if (options.xr) { 79 | this.renderer.xr.enabled = true 80 | } 81 | 82 | this.canvas = this.renderer.domElement 83 | 84 | this.renderer.setClearColor(background, backgroundAlpha) 85 | 86 | // save the fixed dimensions 87 | this.#width = options.width 88 | this.#height = options.height 89 | 90 | // clamp pixel ratio for performance 91 | this.maxPixelRatio = options.maxPixelRatio || 1.5 92 | // clamp delta to avoid stepping anything too far forward 93 | this.maxDeltaTime = options.maxDeltaTime || 1 / 30 94 | 95 | // setup the camera 96 | const aspect = this.#width / this.#height 97 | if (!options.orthographic) { 98 | this.camera = new PerspectiveCamera(fov, aspect, near, far) 99 | } else { 100 | this.camera = new OrthographicCamera( 101 | -(frustumSize * aspect) / 2, 102 | (frustumSize * aspect) / 2, 103 | frustumSize / 2, 104 | -frustumSize / 2, 105 | near, 106 | far 107 | ) 108 | this.camera.frustumSize = frustumSize 109 | } 110 | this.camera.position.copy(options.cameraPosition || new Vector3(0, 0, 4)) 111 | this.camera.lookAt(options.cameraTarget || new Vector3()) 112 | 113 | this.scene = new Scene() 114 | 115 | this.gl = this.renderer.getContext() 116 | 117 | // handle resize events 118 | window.addEventListener('resize', this.resize) 119 | window.addEventListener('orientationchange', this.resize) 120 | 121 | // force an initial resize event 122 | this.resize() 123 | 124 | // __________________________ADDONS__________________________ 125 | 126 | // really basic pointer events handler, the second argument 127 | // contains the x and y relative to the top left corner 128 | // of the canvas. 129 | // In case of touches with multiple fingers, only the 130 | // first touch is registered. 131 | this.isDragging = false 132 | this.canvas.addEventListener('pointerdown', (event) => { 133 | if (!event.isPrimary) return 134 | this.isDragging = true 135 | this.#startX = event.offsetX 136 | this.#startY = event.offsetY 137 | // call onPointerDown method 138 | this.scene.traverse((child) => { 139 | if (typeof child.onPointerDown === 'function') { 140 | child.onPointerDown(event, { x: event.offsetX, y: event.offsetY }) 141 | } 142 | }) 143 | // call the pointerdown listeners 144 | this.#pointerdownListeners.forEach((fn) => fn(event, { x: event.offsetX, y: event.offsetY })) 145 | }) 146 | this.canvas.addEventListener('pointermove', (event) => { 147 | if (!event.isPrimary) return 148 | // call onPointerMove method 149 | const position = { 150 | x: event.offsetX, 151 | y: event.offsetY, 152 | ...(this.#startX !== undefined && { dragX: event.offsetX - this.#startX }), 153 | ...(this.#startY !== undefined && { dragY: event.offsetY - this.#startY }), 154 | } 155 | this.scene.traverse((child) => { 156 | if (typeof child.onPointerMove === 'function') { 157 | child.onPointerMove(event, position) 158 | } 159 | }) 160 | // call the pointermove listeners 161 | this.#pointermoveListeners.forEach((fn) => fn(event, position)) 162 | }) 163 | this.canvas.addEventListener('pointerup', (event) => { 164 | if (!event.isPrimary) return 165 | this.isDragging = false 166 | // call onPointerUp method 167 | const position = { 168 | x: event.offsetX, 169 | y: event.offsetY, 170 | ...(this.#startX !== undefined && { dragX: event.offsetX - this.#startX }), 171 | ...(this.#startY !== undefined && { dragY: event.offsetY - this.#startY }), 172 | } 173 | this.scene.traverse((child) => { 174 | if (typeof child.onPointerUp === 'function') { 175 | child.onPointerUp(event, position) 176 | } 177 | }) 178 | // call the pointerup listeners 179 | this.#pointerupListeners.forEach((fn) => fn(event, position)) 180 | 181 | this.#startX = undefined 182 | this.#startY = undefined 183 | }) 184 | 185 | // expose a composer for postprocessing passes 186 | if (options.postprocessing) { 187 | const maxMultisampling = this.gl.getParameter(this.gl.MAX_SAMPLES) 188 | this.composer = new EffectComposer(this.renderer, { 189 | multisampling: Math.min(8, maxMultisampling), 190 | frameBufferType: HalfFloatType, 191 | ...options, 192 | }) 193 | this.composer.addPass(new RenderPass(this.scene, this.camera)) 194 | } 195 | 196 | // set up OrbitControls 197 | if (options.orbitControls) { 198 | this.orbitControls = new OrbitControls(this.camera, this.canvas) 199 | 200 | this.orbitControls.enableDamping = true 201 | this.orbitControls.dampingFactor = 0.15 202 | this.orbitControls.enablePan = false 203 | 204 | if (options.orbitControls instanceof Object) { 205 | Object.keys(options.orbitControls).forEach((key) => { 206 | this.orbitControls[key] = options.orbitControls[key] 207 | }) 208 | } 209 | } 210 | 211 | // Attach the Cannon physics engine 212 | if (options.world) { 213 | this.world = options.world 214 | if (options.showWorldWireframes) { 215 | this.cannonDebugger = new CannonDebugger(this.scene, this.world.bodies) 216 | } 217 | } 218 | 219 | // show the fps meter 220 | if (options.showFps) { 221 | this.stats = new Stats({ showMinMax: false, context: this.gl }) 222 | this.stats.showPanel(0) 223 | document.body.appendChild(this.stats.dom) 224 | } 225 | 226 | // initialize the gui 227 | if (options.gui) { 228 | this.gui = new GUI() 229 | 230 | if (options.guiClosed) { 231 | this.gui.close() 232 | } 233 | 234 | Object.assign(Object.getPrototypeOf(this.gui), { 235 | // let's try to be smart 236 | addSmart(object, key, name = '') { 237 | const value = object[key] 238 | switch (typeof value) { 239 | case 'number': { 240 | if (value === 0) { 241 | return this.add(object, key, -10, 10, 0.01) 242 | } else if ( 243 | 0 < value && 244 | value < 1 && 245 | !['f', 'a', 'frequency', 'amplitude'].includes(name) 246 | ) { 247 | return this.add(object, key, 0, 1, 0.01) 248 | } else if (value > 0) { 249 | return new ExponentialNumberController( 250 | this, 251 | object, 252 | key, 253 | 0.01, 254 | value < 100 ? 100 : 1000, 255 | 0.01 256 | ) 257 | } else { 258 | return this.add(object, key, -10, 0, 0.01) 259 | } 260 | } 261 | case 'object': { 262 | return this.addColor(object, key) 263 | } 264 | default: { 265 | return this.add(object, key) 266 | } 267 | } 268 | }, 269 | // specifically for three.js exposed uniforms 270 | wireUniforms(folderName, uniforms, { blacklist = [] } = {}) { 271 | const folder = this.addFolder(folderName) 272 | 273 | Object.keys(uniforms).forEach((key) => { 274 | if (blacklist.includes(key)) return 275 | const uniformObject = uniforms[key] 276 | folder.addSmart(uniformObject, 'value', key).name(key) 277 | }) 278 | }, 279 | }) 280 | 281 | if (typeof options.gui === 'object') { 282 | this.guiState = options.gui 283 | Object.keys(options.gui).forEach((key) => { 284 | this.gui.addSmart(this.guiState, key) 285 | }) 286 | } 287 | } 288 | 289 | // detect the gpu info 290 | this.loadGPUTier = getGPUTier({ glContext: this.gl }).then((gpuTier) => { 291 | this.gpu = { 292 | name: gpuTier.gpu, 293 | tier: gpuTier.tier, 294 | isMobile: gpuTier.isMobile, 295 | fps: gpuTier.fps, 296 | } 297 | }) 298 | 299 | // initialize the mp4 recorder 300 | if (isWebCodecsSupported()) { 301 | loadMP4Module().then((mp4) => { 302 | this.#mp4 = mp4 303 | }) 304 | } 305 | } 306 | 307 | get width() { 308 | return this.#width || window.innerWidth 309 | } 310 | 311 | get height() { 312 | return this.#height || window.innerHeight 313 | } 314 | 315 | get pixelRatio() { 316 | return Math.min(this.maxPixelRatio, window.devicePixelRatio) 317 | } 318 | 319 | resize = ({ width = this.width, height = this.height, pixelRatio = this.pixelRatio } = {}) => { 320 | // update pixel ratio if necessary 321 | if (this.renderer.getPixelRatio() !== pixelRatio) { 322 | this.renderer.setPixelRatio(pixelRatio) 323 | } 324 | 325 | // setup new size & update camera aspect if necessary 326 | this.renderer.setSize(width, height) 327 | if (this.camera.isPerspectiveCamera) { 328 | this.camera.aspect = width / height 329 | } else { 330 | const aspect = width / height 331 | this.camera.left = -(this.camera.frustumSize * aspect) / 2 332 | this.camera.right = (this.camera.frustumSize * aspect) / 2 333 | this.camera.top = this.camera.frustumSize / 2 334 | this.camera.bottom = -this.camera.frustumSize / 2 335 | } 336 | this.camera.updateProjectionMatrix() 337 | 338 | // resize also the composer, width and height 339 | // are automatically extracted from the renderer 340 | if (this.composer) { 341 | this.composer.setSize() 342 | } 343 | 344 | // recursively tell all child objects to resize 345 | this.scene.traverse((obj) => { 346 | if (typeof obj.resize === 'function') { 347 | obj.resize({ 348 | width, 349 | height, 350 | pixelRatio, 351 | }) 352 | } 353 | }) 354 | 355 | // draw a frame to ensure the new size has been registered visually 356 | this.draw() 357 | return this 358 | } 359 | 360 | // convenience function to trigger a PNG download of the canvas 361 | saveScreenshot = async ({ 362 | width = this.width, 363 | height = this.height, 364 | fileName = 'Screenshot', 365 | } = {}) => { 366 | // force a specific output size 367 | this.resize({ width, height, pixelRatio: 1 }) 368 | 369 | const blob = await new Promise((resolve) => this.canvas.toBlob(resolve, 'image/png')) 370 | 371 | // reset to default size 372 | this.resize() 373 | 374 | // save 375 | downloadFile(`${fileName}.png`, blob) 376 | } 377 | 378 | // start recording of a gif or a video 379 | startRecording = ({ 380 | width = this.width, 381 | height = this.height, 382 | fileName = 'Recording', 383 | ...options 384 | } = {}) => { 385 | if (!isWebCodecsSupported()) { 386 | throw new Error('You need the WebCodecs API to use mp4-wasm') 387 | } 388 | 389 | if (this.isRecording) { 390 | return 391 | } 392 | 393 | this.#fileName = fileName 394 | 395 | // force a specific output size 396 | this.resize({ width, height, pixelRatio: 1 }) 397 | this.draw() 398 | 399 | const fps = 60 400 | this.#mp4Encoder = this.#mp4.createWebCodecsEncoder({ 401 | width: roundEven(width), 402 | height: roundEven(height), 403 | fps, 404 | bitrate: 120 * 1000 * 1000, // 120 Mbit/s 405 | encoderOptions: { 406 | // https://github.com/mattdesl/mp4-wasm/blob/d266bc08edef719158a5163a9b483bd065476c73/src/extern-post.js#L111 407 | framerate: Math.min(30, fps), 408 | }, 409 | ...options, 410 | }) 411 | } 412 | 413 | stopRecording = async () => { 414 | if (!this.isRecording) { 415 | return 416 | } 417 | 418 | for (let frame of this.#frames) { 419 | await this.#mp4Encoder.addFrame(frame) 420 | } 421 | const buffer = await this.#mp4Encoder.end() 422 | const blob = new Blob([buffer]) 423 | 424 | this.#mp4Encoder = undefined 425 | // dispose the graphical resources associated with the ImageBitmap 426 | this.#frames.forEach((frame) => frame.close()) 427 | this.#frames.length = 0 428 | 429 | // reset to default size 430 | this.resize() 431 | this.draw() 432 | 433 | downloadFile(`${this.#fileName}.mp4`, blob) 434 | } 435 | 436 | update = (dt, time, xrframe) => { 437 | if (this.orbitControls) { 438 | this.orbitControls.update() 439 | } 440 | 441 | // recursively tell all child objects to update 442 | this.scene.traverse((obj) => { 443 | if (typeof obj.update === 'function' && !obj.isTransformControls) { 444 | obj.update(dt, time, xrframe) 445 | } 446 | }) 447 | 448 | if (this.world) { 449 | // update the cannon-es physics engine 450 | this.world.step(1 / 60, dt) 451 | 452 | // update the debug wireframe renderer 453 | if (this.cannonDebugger) { 454 | this.cannonDebugger.update() 455 | } 456 | 457 | // recursively tell all child bodies to update 458 | this.world.bodies.forEach((body) => { 459 | if (typeof body.update === 'function') { 460 | body.update(dt, time) 461 | } 462 | }) 463 | } 464 | 465 | // call the update listeners 466 | this.#updateListeners.forEach((fn) => fn(dt, time, xrframe)) 467 | 468 | return this 469 | } 470 | 471 | onUpdate(fn) { 472 | this.#updateListeners.push(fn) 473 | } 474 | 475 | onPointerDown(fn) { 476 | this.#pointerdownListeners.push(fn) 477 | } 478 | 479 | onPointerMove(fn) { 480 | this.#pointermoveListeners.push(fn) 481 | } 482 | 483 | onPointerUp(fn) { 484 | this.#pointerupListeners.push(fn) 485 | } 486 | 487 | offUpdate(fn) { 488 | const index = this.#updateListeners.indexOf(fn) 489 | 490 | // return silently if the function can't be found 491 | if (index === -1) { 492 | return 493 | } 494 | 495 | this.#updateListeners.splice(index, 1) 496 | } 497 | 498 | offPointerDown(fn) { 499 | const index = this.#pointerdownListeners.indexOf(fn) 500 | 501 | // return silently if the function can't be found 502 | if (index === -1) { 503 | return 504 | } 505 | 506 | this.#pointerdownListeners.splice(index, 1) 507 | } 508 | 509 | offPointerMove(fn) { 510 | const index = this.#pointermoveListeners.indexOf(fn) 511 | 512 | // return silently if the function can't be found 513 | if (index === -1) { 514 | return 515 | } 516 | 517 | this.#pointermoveListeners.splice(index, 1) 518 | } 519 | 520 | offPointerUp(fn) { 521 | const index = this.#pointerupListeners.indexOf(fn) 522 | 523 | // return silently if the function can't be found 524 | if (index === -1) { 525 | return 526 | } 527 | 528 | this.#pointerupListeners.splice(index, 1) 529 | } 530 | 531 | draw = () => { 532 | // postprocessing doesn't currently work in WebXR 533 | const isXR = this.renderer.xr.enabled && this.renderer.xr.isPresenting 534 | 535 | if (this.composer && !isXR) { 536 | this.composer.render(this.dt) 537 | } else { 538 | this.renderer.render(this.scene, this.camera) 539 | } 540 | return this 541 | } 542 | 543 | start = () => { 544 | if (this.isRunning) return 545 | this.isRunning = true 546 | 547 | // draw immediately 548 | this.draw() 549 | 550 | this.renderer.setAnimationLoop(this.animate) 551 | return this 552 | } 553 | 554 | stop = () => { 555 | if (!this.isRunning) return 556 | this.renderer.setAnimationLoop(null) 557 | this.isRunning = false 558 | return this 559 | } 560 | 561 | animate = (now, xrframe) => { 562 | if (!this.isRunning) return 563 | 564 | if (this.stats) this.stats.begin() 565 | 566 | this.dt = Math.min(this.maxDeltaTime, (now - this.#lastTime) / 1000) 567 | this.time += this.dt 568 | this.#lastTime = now 569 | this.update(this.dt, this.time, xrframe) 570 | this.draw() 571 | 572 | // save the bitmap of the canvas for the recorder 573 | if (this.isRecording) { 574 | const index = this.#frames.length 575 | createImageBitmap(this.canvas).then((bitmap) => { 576 | this.#frames[index] = bitmap 577 | }) 578 | } 579 | 580 | if (this.stats) this.stats.end() 581 | } 582 | 583 | get cursor() { 584 | return this.canvas.style.cursor 585 | } 586 | 587 | set cursor(cursor) { 588 | if (cursor) { 589 | this.canvas.style.cursor = cursor 590 | } else { 591 | this.canvas.style.cursor = null 592 | } 593 | } 594 | } 595 | 596 | function downloadFile(name, blob) { 597 | const link = document.createElement('a') 598 | link.download = name 599 | link.href = URL.createObjectURL(blob) 600 | link.click() 601 | 602 | setTimeout(() => { 603 | URL.revokeObjectURL(blob) 604 | link.removeAttribute('href') 605 | }, 0) 606 | } 607 | 608 | // Rounds to the closest even number 609 | function roundEven(n) { 610 | return Math.round(n / 2) * 2 611 | } 612 | -------------------------------------------------------------------------------- /src/utils/customizeShader.js: -------------------------------------------------------------------------------- 1 | export function addDefines(material, defines) { 2 | prepareOnBeforeCompile(material) 3 | 4 | material.defines = defines 5 | 6 | material.addBeforeCompileListener((shader) => { 7 | material.defines = { 8 | ...material.defines, 9 | ...shader.defines, 10 | } 11 | 12 | shader.defines = material.defines 13 | }) 14 | 15 | constructOnBeforeCompile(material) 16 | } 17 | 18 | export function addUniforms(material, uniforms) { 19 | prepareOnBeforeCompile(material) 20 | 21 | material.uniforms = uniforms 22 | 23 | material.addBeforeCompileListener((shader) => { 24 | material.uniforms = { 25 | ...material.uniforms, 26 | ...shader.uniforms, 27 | } 28 | 29 | shader.uniforms = material.uniforms 30 | }) 31 | 32 | constructOnBeforeCompile(material) 33 | } 34 | 35 | export function customizeVertexShader(material, hooks) { 36 | prepareOnBeforeCompile(material) 37 | 38 | material.addBeforeCompileListener((shader) => { 39 | shader.vertexShader = monkeyPatch(shader.vertexShader, hooks) 40 | }) 41 | 42 | constructOnBeforeCompile(material) 43 | } 44 | 45 | export function customizeFragmentShader(material, hooks) { 46 | prepareOnBeforeCompile(material) 47 | 48 | material.addBeforeCompileListener((shader) => { 49 | shader.fragmentShader = monkeyPatch(shader.fragmentShader, hooks) 50 | }) 51 | 52 | constructOnBeforeCompile(material) 53 | } 54 | 55 | function prepareOnBeforeCompile(material) { 56 | if (material.beforeCompileListeners) { 57 | return 58 | } 59 | 60 | material.beforeCompileListeners = [] 61 | material.addBeforeCompileListener = (fn) => { 62 | material.beforeCompileListeners.push(fn) 63 | } 64 | } 65 | 66 | function constructOnBeforeCompile(material) { 67 | material.onBeforeCompile = (shader) => { 68 | material.beforeCompileListeners.forEach((fn) => fn(shader)) 69 | } 70 | } 71 | 72 | export function monkeyPatch( 73 | shader, 74 | { 75 | defines = '', 76 | head = '', 77 | main = '', 78 | transformed, 79 | objectNormal, 80 | transformedNormal, 81 | gl_Position, 82 | diffuse, 83 | emissive, 84 | gl_FragColor, 85 | ...replaces 86 | } 87 | ) { 88 | let patchedShader = shader 89 | 90 | const replaceAll = (str, find, rep) => str.split(find).join(rep) 91 | Object.keys(replaces).forEach((key) => { 92 | patchedShader = replaceAll(patchedShader, key, replaces[key]) 93 | }) 94 | 95 | patchedShader = patchedShader.replace( 96 | 'void main() {', 97 | ` 98 | ${head} 99 | void main() { 100 | ${main} 101 | ` 102 | ) 103 | 104 | if (transformed && patchedShader.includes('#include ')) { 105 | patchedShader = patchedShader.replace( 106 | '#include ', 107 | `#include 108 | ${transformed} 109 | ` 110 | ) 111 | } 112 | 113 | if (objectNormal && patchedShader.includes('#include ')) { 114 | patchedShader = patchedShader.replace( 115 | '#include ', 116 | `#include 117 | ${objectNormal} 118 | ` 119 | ) 120 | } 121 | 122 | if (transformedNormal && patchedShader.includes('#include ')) { 123 | patchedShader = patchedShader.replace( 124 | '#include ', 125 | `#include 126 | ${transformedNormal} 127 | ` 128 | ) 129 | } 130 | 131 | if (gl_Position && patchedShader.includes('#include ')) { 132 | patchedShader = patchedShader.replace( 133 | '#include ', 134 | `#include 135 | ${gl_Position} 136 | ` 137 | ) 138 | } 139 | 140 | if (diffuse && patchedShader.includes('vec4 diffuseColor = vec4( diffuse, opacity );')) { 141 | patchedShader = patchedShader.replace( 142 | 'vec4 diffuseColor = vec4( diffuse, opacity );', 143 | ` 144 | vec3 diffuse_; 145 | ${replaceAll(diffuse, 'diffuse =', 'diffuse_ =')} 146 | vec4 diffuseColor = vec4(diffuse_, opacity); 147 | ` 148 | ) 149 | } 150 | 151 | if (emissive && patchedShader.includes('vec3 totalEmissiveRadiance = emissive;')) { 152 | patchedShader = patchedShader.replace( 153 | 'vec3 totalEmissiveRadiance = emissive;', 154 | ` 155 | vec3 emissive_; 156 | ${replaceAll(emissive, 'emissive =', 'emissive_ =')} 157 | vec3 totalEmissiveRadiance = emissive_; 158 | ` 159 | ) 160 | } 161 | 162 | if (gl_FragColor && patchedShader.includes('#include ')) { 163 | patchedShader = patchedShader.replace( 164 | '#include ', 165 | ` 166 | #include 167 | ${gl_FragColor} 168 | ` 169 | ) 170 | } 171 | 172 | const stringDefines = Object.keys(defines) 173 | .map((d) => `#define ${d} ${defines[d]}`) 174 | .join('\n') 175 | 176 | return ` 177 | ${stringDefines} 178 | ${patchedShader} 179 | ` 180 | } 181 | -------------------------------------------------------------------------------- /src/utils/loadEnvMap.js: -------------------------------------------------------------------------------- 1 | import { 2 | CubeTextureLoader, 3 | EquirectangularReflectionMapping, 4 | PMREMGenerator, 5 | SRGBColorSpace, 6 | TextureLoader, 7 | UnsignedByteType, 8 | } from 'three' 9 | // TODO lazy load these, or put them in different files 10 | import { RGBELoader } from 'three/addons/loaders/RGBELoader.js' 11 | import { EXRLoader } from 'three/addons/loaders/EXRLoader.js' 12 | import { HDRCubeTextureLoader } from 'three/addons/loaders/HDRCubeTextureLoader.js' 13 | 14 | export default function loadEnvMap(url, { renderer, ...options }) { 15 | if (!renderer) { 16 | throw new Error(`Env map requires renderer to passed in the options for ${url}!`) 17 | } 18 | 19 | const isEquirectangular = !Array.isArray(url) 20 | 21 | let loader 22 | if (isEquirectangular) { 23 | const extension = url.slice(url.lastIndexOf('.') + 1) 24 | 25 | switch (extension) { 26 | case 'hdr': { 27 | loader = new RGBELoader().setDataType(UnsignedByteType).loadAsync(url) 28 | break 29 | } 30 | case 'exr': { 31 | loader = new EXRLoader().setDataType(UnsignedByteType).loadAsync(url) 32 | break 33 | } 34 | case 'png': 35 | case 'jpg': { 36 | loader = new TextureLoader().loadAsync(url).then((texture) => { 37 | if (renderer.outputColorSpace === SRGBColorSpace && options.gamma) { 38 | texture.colorSpace = SRGBColorSpace 39 | } 40 | return texture 41 | }) 42 | break 43 | } 44 | default: { 45 | throw new Error(`Extension ${extension} not supported`) 46 | } 47 | } 48 | 49 | loader = loader.then((texture) => { 50 | if (options.pmrem) { 51 | return equirectangularToPMREMCube(texture, renderer) 52 | } else { 53 | return equirectangularToCube(texture) 54 | } 55 | }) 56 | } else { 57 | const extension = url[0].slice(url.lastIndexOf('.') + 1) 58 | 59 | switch (extension) { 60 | case 'hdr': { 61 | loader = new HDRCubeTextureLoader().setDataType(UnsignedByteType).loadAsync(url) 62 | break 63 | } 64 | case 'png': 65 | case 'jpg': { 66 | loader = new CubeTextureLoader().loadAsync(url).then((texture) => { 67 | if (renderer.outputColorSpace === SRGBColorSpace && options.gamma) { 68 | texture.colorSpace = SRGBColorSpace 69 | } 70 | return texture 71 | }) 72 | break 73 | } 74 | default: { 75 | throw new Error(`Extension ${extension} not supported`) 76 | } 77 | } 78 | 79 | loader = loader.then((texture) => { 80 | if (options.pmrem) { 81 | return cubeToPMREMCube(texture, renderer) 82 | } else { 83 | return texture 84 | } 85 | }) 86 | } 87 | 88 | // apply eventual texture options, such as wrap, repeat... 89 | const textureOptions = Object.keys(options).filter( 90 | (option) => !['pmrem', 'linear'].includes(option) 91 | ) 92 | textureOptions.forEach((option) => { 93 | loader = loader.then((texture) => { 94 | texture[option] = options[option] 95 | return texture 96 | }) 97 | }) 98 | 99 | return loader 100 | } 101 | 102 | // prefilter the equirectangular environment map for irradiance 103 | function equirectangularToPMREMCube(texture, renderer) { 104 | const pmremGenerator = new PMREMGenerator(renderer) 105 | pmremGenerator.compileEquirectangularShader() 106 | 107 | const cubeRenderTarget = pmremGenerator.fromEquirectangular(texture) 108 | 109 | pmremGenerator.dispose() // dispose PMREMGenerator 110 | texture.dispose() // dispose original texture 111 | texture.image.data = null // remove image reference 112 | 113 | return cubeRenderTarget.texture 114 | } 115 | 116 | // prefilter the cubemap environment map for irradiance 117 | function cubeToPMREMCube(texture, renderer) { 118 | const pmremGenerator = new PMREMGenerator(renderer) 119 | pmremGenerator.compileCubemapShader() 120 | 121 | const cubeRenderTarget = pmremGenerator.fromCubemap(texture) 122 | 123 | pmremGenerator.dispose() // dispose PMREMGenerator 124 | texture.dispose() // dispose original texture 125 | texture.image.data = null // remove image reference 126 | 127 | return cubeRenderTarget.texture 128 | } 129 | 130 | // transform an equirectangular texture to a cubetexture that 131 | // can be used as an envmap or scene background 132 | function equirectangularToCube(texture) { 133 | texture.mapping = EquirectangularReflectionMapping 134 | return texture 135 | } 136 | -------------------------------------------------------------------------------- /src/utils/loadGLTF.js: -------------------------------------------------------------------------------- 1 | import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js' 2 | import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js' 3 | 4 | export default function loadGLTF(url, options = {}) { 5 | return new Promise((resolve, reject) => { 6 | const loader = new GLTFLoader() 7 | 8 | if (options.draco) { 9 | const dracoLoader = new DRACOLoader() 10 | dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/') 11 | loader.setDRACOLoader(dracoLoader) 12 | } 13 | 14 | loader.load(url, resolve, null, (err) => 15 | reject(new Error(`Could not load GLTF asset ${url}:\n${err}`)) 16 | ) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/loadTexture.js: -------------------------------------------------------------------------------- 1 | import { SRGBColorSpace, TextureLoader } from 'three' 2 | 3 | export default function loadTexture(url, { renderer, ...options }) { 4 | if (!renderer) { 5 | throw new Error(`Texture requires renderer to passed in the options for ${url}!`) 6 | } 7 | 8 | return new Promise((resolve, reject) => { 9 | new TextureLoader().load( 10 | url, 11 | (texture) => { 12 | // apply eventual gamma encoding 13 | if (renderer.outputColorSpace === SRGBColorSpace && options.gamma) { 14 | texture.colorSpace = SRGBColorSpace 15 | } 16 | 17 | // apply eventual texture options, such as wrap, repeat... 18 | const textureOptions = Object.keys(options).filter((option) => !['linear'].includes(option)) 19 | textureOptions.forEach((option) => { 20 | texture[option] = options[option] 21 | }) 22 | 23 | // Force texture to be uploaded to GPU immediately, 24 | // this will avoid "jank" on first rendered frame 25 | renderer.initTexture(texture) 26 | 27 | resolve(texture) 28 | }, 29 | null, 30 | (err) => reject(new Error(`Could not load texture ${url}:\n${err}`)) 31 | ) 32 | }) 33 | } 34 | --------------------------------------------------------------------------------