├── .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 | [](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 | 
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 |