├── .babelrc
├── .editorconfig
├── .eslintignore
├── .eslintrc
├── .eslintrc.cjs
├── .gitattributes
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .yarnrc.yml
├── LICENSE
├── README.md
├── contextual-dioramas.code-workspace
├── esbuild.js
├── logging-utils.js
├── package.json
├── public
├── css
│ └── style.css
├── index.html
├── models
│ ├── cactus_01.glb
│ └── house_swedish_01.glb
└── textures
│ └── equirectangular
│ └── cloud_layers_1k.hdr
├── src
├── components
│ ├── Biomes.js
│ ├── Building.js
│ ├── Cactus.js
│ ├── ContextQuadtree.js
│ ├── ContextualObject.js
│ ├── Diorama.js
│ ├── Rock.js
│ ├── Terrain.js
│ ├── Tree.js
│ └── Water.js
├── index.js
├── screenshot-record-buttons.js
├── ui
│ └── DioramaControls.js
└── utils
│ ├── AssetManager.js
│ ├── ExponentialNumberController.js
│ ├── WebGLApp.js
│ ├── customizeShader.js
│ ├── loadEnvMap.js
│ ├── loadGLTF.js
│ ├── loadTexture.js
│ └── meshUtils.js
├── webpack.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["accurapp"]
3 | }
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint-config-accurapp"
3 | }
4 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | /.yarn/** linguist-vendored
2 | /.yarn/releases/* binary
3 | /.yarn/plugins/**/* binary
4 | /.pnp.* binary linguist-generated
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build/
2 | public/app.js
3 | public/app.js.map
4 | node_modules/
5 | *.log
6 | .yarn/install-state.gz
7 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | package.json
2 | .*
3 | node_modules/
4 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "semi": false,
4 | "singleQuote": true
5 | }
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Paul Greveson
(http://greveson.co.uk)
4 |
5 | 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:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | 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.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Contextual Dioramas
2 |
3 | A [Three.js](https://threejs.org/) project that generates 3D dioramas procedurally using contextual information. You can visit the [latest interactive release on the Github Pages site here](https://moppius.github.io/contextual-dioramas/).
4 |
5 | It is built on [@marcofugaro](https://github.com/marcofugaro)'s [threejs-modern-app](https://github.com/marcofugaro/threejs-modern-app) framework.
6 |
7 | ## Prerequisites
8 |
9 | - [Yarn](https://yarnpkg.com/) - if you want to use [npm](https://npmjs.com), you will need to reconfigure the scripts in `package.json`
10 |
11 | **Note:** This project has only been tested on Windows 10 and none of the scripts are set up to be cross-platform yet.
12 |
13 | ## Usage
14 |
15 | Once you have installed the dependencies by running `yarn`, these are the available commands:
16 |
17 | - `yarn start` starts a server locally and launches the browser - any changes will be hot-reloaded, for rapid iteration
18 | - `yarn build` builds the project for production, ready to be deployed from the `build/` folder
19 | - `yarn deploy` builds and pushes the contents of build folder to a `gh-pages` branch on this repository
20 |
21 | All the build tools logic is in the `package.json` and `webpack.config.js`.
22 |
23 | Read the full [threejs-modern-app documentation](https://github.com/marcofugaro/threejs-modern-app) for a more in-depth guide.
24 |
25 | ## Credits
26 |
27 | - The environment reflection map is a free CC0 HDRI image from [hdrihaven.com](https://hdrihaven.com/)
28 |
--------------------------------------------------------------------------------
/contextual-dioramas.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": "."
5 | }
6 | ],
7 | "settings": {
8 | "editor.formatOnSave": true,
9 | "editor.formatOnPaste": true,
10 | "editor.tabSize": 2
11 | }
12 | }
--------------------------------------------------------------------------------
/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": "contextual-dioramas",
3 | "version": "0.0.0",
4 | "description": "Contextual Dioramas",
5 | "repository": "https://github.com/moppius/contextual-dioramas",
6 | "packageManager": "yarn@4.0.2",
7 | "author": {
8 | "name": "Paul Greveson",
9 | "email": "p.greveson@gmail.com",
10 | "url": "http://greveson.co.uk"
11 | },
12 | "license": "MIT",
13 | "type": "module",
14 | "scripts": {
15 | "start": "cross-env NODE_ENV=development node ./esbuild.js",
16 | "prebuild": "yarn clean; yarn copy-public",
17 | "build": "cross-env NODE_ENV=production node ./esbuild.js",
18 | "clean": "rimraf build/; rimraf public/app.js; rimraf public/app.js.map",
19 | "copy-public": "cpr public/ build/"
20 | },
21 | "dependencies": {
22 | "cannon-es": "^0.20.0",
23 | "cannon-es-debugger": "^1.0.0",
24 | "detect-gpu": "^5.0.37",
25 | "image-promise": "^7.0.1",
26 | "lil-gui": "^0.19.1",
27 | "lodash-es": "^4.17.21",
28 | "mp4-wasm": "marcofugaro/mp4-wasm#build-embedded",
29 | "p-map": "^6.0.0",
30 | "postprocessing": "^6.33.3",
31 | "pretty-ms": "^8.0.0",
32 | "seedrandom": "^3.0.5",
33 | "stats.js": "marcofugaro/stats.js",
34 | "three": "0.158.0"
35 | },
36 | "devDependencies": {
37 | "address": "^2.0.1",
38 | "browser-sync": "^2.29.3",
39 | "chalk": "4.1.2",
40 | "cpr": "^3.0.1",
41 | "cross-env": "^7.0.3",
42 | "esbuild": "0.16.17",
43 | "esbuild-plugin-glslify": "^1.0.1",
44 | "esbuild-plugin-glslify-inline": "^1.1.0",
45 | "eslint": "^8.54.0",
46 | "eslint-config-prettier": "^9.0.0",
47 | "glslify": "^7.1.1",
48 | "indent-string": "^5.0.0",
49 | "ora": "^7.0.1",
50 | "react-dev-utils": "^12.0.1",
51 | "rimraf": "^5.0.5",
52 | "tree-node-cli": "^1.6.0"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/public/css/style.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | margin: 0;
4 | padding: 0;
5 | color: #222;
6 | }
7 |
8 | canvas {
9 | display: block;
10 | position: fixed;
11 | top: 0;
12 | left: 0;
13 | }
14 |
15 | h1,
16 | h2,
17 | h3,
18 | h4,
19 | h5 {
20 | margin-top: 1rem;
21 | }
22 |
23 | #controls {
24 | position: absolute;
25 | top: 1rem;
26 | left: 1rem;
27 | }
28 |
29 | #controls label {
30 | display: inline-block;
31 | text-align: right;
32 | min-width: 50px;
33 | margin: 0.5rem;
34 | }
35 |
36 | #controls h2 select {
37 | margin-left: 0.5rem;
38 | }
39 |
40 | a#button {
41 | position: fixed;
42 | bottom: 16px;
43 | right: 16px;
44 | padding: 12px;
45 | border-radius: 50%;
46 | height: 3rem;
47 | width: 3rem;
48 | margin-bottom: 0px;
49 | background-color: #fff;
50 | opacity: 0.8;
51 | z-index: 999;
52 | box-shadow: 0 0 4px rgba(0, 0, 0, 0.15);
53 | text-decoration: none;
54 | font-weight: bold;
55 | }
56 |
57 | a#button:hover {
58 | opacity: 1;
59 | }
60 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Contextual Dioramas
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
20 | <>
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/public/models/cactus_01.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moppius/contextual-dioramas/b90bc6fbe6e6e82d2c689caae714ccdc361ff08c/public/models/cactus_01.glb
--------------------------------------------------------------------------------
/public/models/house_swedish_01.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moppius/contextual-dioramas/b90bc6fbe6e6e82d2c689caae714ccdc361ff08c/public/models/house_swedish_01.glb
--------------------------------------------------------------------------------
/public/textures/equirectangular/cloud_layers_1k.hdr:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moppius/contextual-dioramas/b90bc6fbe6e6e82d2c689caae714ccdc361ff08c/public/textures/equirectangular/cloud_layers_1k.hdr
--------------------------------------------------------------------------------
/src/components/Biomes.js:
--------------------------------------------------------------------------------
1 | import { Color } from 'three'
2 | import Building from './Building'
3 | import Cactus from './Cactus'
4 | import Rock from './Rock'
5 | import Tree from './Tree'
6 |
7 | export const BIOMES = {
8 | temperate: {
9 | assets: [Building, Tree, Rock],
10 | terrain: {
11 | ground: new Color(0.19, 0.35, 0.11),
12 | shoreline: new Color(0.41, 0.31, 0.15),
13 | cliff: new Color(0.28, 0.31, 0.29),
14 | underwater: new Color(0.05, 0.08, 0.08),
15 | side: new Color(0.42, 0.37, 0.26),
16 | },
17 | water: { color: new Color(0.05, 0.06, 0.04), opacity: 0.83 },
18 | },
19 | desert: {
20 | assets: [Cactus, Rock],
21 | terrain: {
22 | ground: new Color(0.44, 0.24, 0.14),
23 | shoreline: new Color(0.56, 0.37, 0.24),
24 | cliff: new Color(0.34, 0.19, 0.12),
25 | underwater: new Color(0.05, 0.08, 0.08),
26 | side: new Color(0.41, 0.28, 0.21),
27 | },
28 | water: { color: new Color(0.08, 0.15, 0.11), opacity: 0.75 },
29 | },
30 | arctic: {
31 | assets: [Tree, Rock],
32 | terrain: {
33 | ground: new Color(0.82, 0.84, 0.88),
34 | shoreline: new Color(0.55, 0.51, 0.47),
35 | cliff: new Color(0.24, 0.26, 0.26),
36 | underwater: new Color(0.05, 0.08, 0.08),
37 | side: new Color(0.31, 0.24, 0.18),
38 | },
39 | water: { color: new Color(0.04, 0.05, 0.03), opacity: 0.81 },
40 | },
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/Building.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import assets from '../utils/AssetManager'
3 | import ContextualObject from './ContextualObject'
4 |
5 | const houseGltfKey = assets.queue({
6 | url: 'models/house_swedish_01.glb',
7 | type: 'gltf',
8 | })
9 |
10 | export default class Building extends ContextualObject {
11 | static className = 'Building'
12 | static distributionOptions = { ground: 1 }
13 | static labels = ['building']
14 | static baseDensity = 0.001
15 | static randomAngle = new THREE.Vector3(0, 180, 0)
16 |
17 | constructor(webgl, options) {
18 | super(webgl, options)
19 |
20 | this.type = 'Building'
21 |
22 | const houseGltf = assets.get(houseGltfKey)
23 | const house = houseGltf.scene.clone()
24 |
25 | house.traverse((child) => {
26 | if (child.isMesh) {
27 | child.castShadow = true
28 | child.receiveShadow = true
29 | }
30 | })
31 |
32 | this.add(house)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/Cactus.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import assets from '../utils/AssetManager'
3 | import ContextualObject from './ContextualObject'
4 |
5 | const cactusGltfKey = assets.queue({ url: 'models/cactus_01.glb' })
6 |
7 | export default class Cactus extends ContextualObject {
8 | static className = 'Cactus'
9 | static distributionOptions = { ground: 1 }
10 | static labels = ['vegetation', 'cactus']
11 | static baseDensity = 0.015
12 | static randomAngle = new THREE.Vector3(4, 180, 4)
13 | static baseHeight = 5
14 |
15 | constructor(webgl, options) {
16 | super(webgl, options)
17 |
18 | this.type = 'Cactus'
19 |
20 | const cactusGltf = assets.get(cactusGltfKey)
21 | const cactus = cactusGltf.scene.clone()
22 |
23 | cactus.traverse((child) => {
24 | if (child.isMesh) {
25 | child.material = child.material.clone()
26 | child.material.color.r += (this.rng() - 0.5) * 0.1
27 | child.material.color.g += (this.rng() - 0.5) * 0.1
28 | child.material.color.b += (this.rng() - 0.5) * 0.1
29 | child.castShadow = true
30 | child.receiveShadow = true
31 | }
32 | })
33 |
34 | const scaleModifier = (this.rng() - 0.5) * this.constructor.sizeVariation
35 | this.scale.x += scaleModifier
36 | this.scale.y += scaleModifier
37 | this.scale.z += scaleModifier
38 |
39 | this.add(cactus)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/ContextQuadtree.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 |
3 | const MIN_QUAD_SIZE = 2
4 |
5 | export default class Quad {
6 | constructor(webgl, bounds) {
7 | this.webgl = webgl
8 |
9 | this.bounds = bounds
10 | this.boundsSize = new THREE.Vector2(0, 0)
11 | this.bounds.getSize(this.boundsSize)
12 |
13 | this.labels = {}
14 | this.color = null
15 |
16 | this.topLeft = null
17 | this.topRight = null
18 | this.bottomLeft = null
19 | this.bottomRight = null
20 | }
21 |
22 | isSmallestAllowedSize() {
23 | return this.boundsSize.x <= MIN_QUAD_SIZE && this.boundsSize.y <= MIN_QUAD_SIZE
24 | }
25 |
26 | setColor(color, position) {
27 | const point = this.getPoint(position)
28 | if (this.bounds.containsPoint(point) === false) {
29 | console.warn(`setColor: Bounds did not contain position [${position.x}, ${position.y}]!`)
30 | return
31 | }
32 |
33 | if (this.isSmallestAllowedSize() === true) {
34 | this.color = color
35 | return
36 | }
37 |
38 | const halfX = (this.bounds.min.x + this.bounds.max.x) / 2,
39 | halfY = (this.bounds.min.y + this.bounds.max.y) / 2
40 |
41 | if (halfX >= point.x) {
42 | // Top left
43 | if (halfY >= point.y) {
44 | if (this.topLeft === null) {
45 | this.topLeft = new Quad(
46 | this.webgl,
47 | new THREE.Box2(
48 | this.bounds.min,
49 | this.bounds.min.clone().add(this.bounds.max).multiplyScalar(0.5)
50 | )
51 | )
52 | }
53 | this.topLeft.setColor(color, point)
54 | }
55 | // Bottom left
56 | else {
57 | if (this.bottomLeft === null) {
58 | this.bottomLeft = new Quad(
59 | this.webgl,
60 | new THREE.Box2(
61 | new THREE.Vector2(this.bounds.min.x, (this.bounds.min.y + this.bounds.max.y) / 2),
62 | new THREE.Vector2((this.bounds.min.x + this.bounds.max.x) / 2, this.bounds.max.y)
63 | )
64 | )
65 | }
66 | this.bottomLeft.setColor(color, point)
67 | }
68 | } else {
69 | // Top right
70 | if (halfY >= point.y) {
71 | if (this.topRight === null) {
72 | this.topRight = new Quad(
73 | this.webgl,
74 | new THREE.Box2(
75 | new THREE.Vector2((this.bounds.min.x + this.bounds.max.x) / 2, this.bounds.min.y),
76 | new THREE.Vector2(this.bounds.max.x, (this.bounds.min.y + this.bounds.max.y) / 2)
77 | )
78 | )
79 | }
80 | this.topRight.setColor(color, point)
81 | }
82 | // Bottom right
83 | else {
84 | if (this.bottomRight === null) {
85 | this.bottomRight = new Quad(
86 | this.webgl,
87 | new THREE.Box2(
88 | this.bounds.min.clone().add(this.bounds.max).multiplyScalar(0.5),
89 | this.bounds.max
90 | )
91 | )
92 | }
93 | this.bottomRight.setColor(color, point)
94 | }
95 | }
96 | }
97 |
98 | getColor(position) {
99 | const point = this.getPoint(position)
100 | if (this.bounds.containsPoint(point) === true) {
101 | if (this.isSmallestAllowedSize() === true) {
102 | return this.color
103 | }
104 | const quads = [this.topLeft, this.topRight, this.bottomRight, this.bottomLeft]
105 | for (const quad of quads) {
106 | if (quad !== null && quad.bounds.containsPoint(point) === true) {
107 | return quad.getColor(point)
108 | }
109 | }
110 | } else {
111 | console.warn(`getColor: Bounds did not contain position [${point.x}, ${point.y}]!`)
112 | }
113 |
114 | if (window.DEBUG) {
115 | this.drawDebug(0xff00ff)
116 | }
117 |
118 | return null
119 | }
120 |
121 | getAverageColor(position) {
122 | // TODO: Make this recursive (fake mip-mapping with a "mip level" parameter)?
123 | const point = this.getPoint(position)
124 | const baseColor = this.getColor(point)
125 | if (baseColor === null) {
126 | return null
127 | }
128 |
129 | let r = 0,
130 | g = 0,
131 | b = 0,
132 | numSamples = 0
133 |
134 | const sample = [-1, 0, 1]
135 | for (const x of sample) {
136 | for (const y of sample) {
137 | let color = baseColor
138 | if (x != 0 && y != 0) {
139 | const tap = new THREE.Vector2(point.x + MIN_QUAD_SIZE * x, point.y + MIN_QUAD_SIZE * y)
140 | if (this.bounds.containsPoint(tap) === true) {
141 | color = this.getColor(tap)
142 | }
143 | if (color === null) {
144 | color = baseColor
145 | }
146 | }
147 | r += color.r
148 | g += color.g
149 | b += color.b
150 | numSamples++
151 | }
152 | }
153 |
154 | return new THREE.Color(
155 | (baseColor.r + r) / numSamples,
156 | (baseColor.g + g) / numSamples,
157 | (baseColor.b + b) / numSamples
158 | )
159 | }
160 |
161 | addLabels(labels, position) {
162 | const point = this.getPoint(position)
163 |
164 | if (this.bounds.containsPoint(point) === false) {
165 | console.warn(`addLabels: Bounds did not contain position [${point.x}, ${point.y}]!`)
166 | return
167 | }
168 |
169 | for (const label of labels) {
170 | if (label in this.labels) {
171 | this.labels[label] += 1
172 | } else {
173 | this.labels[label] = 1
174 | }
175 | }
176 |
177 | if (this.isSmallestAllowedSize()) {
178 | return
179 | }
180 |
181 | const halfX = (this.bounds.min.x + this.bounds.max.x) / 2,
182 | halfY = (this.bounds.min.y + this.bounds.max.y) / 2
183 |
184 | if (halfX >= point.x) {
185 | // Top left
186 | if (halfY >= point.y) {
187 | if (this.topLeft === null) {
188 | this.topLeft = new Quad(
189 | this.webgl,
190 | new THREE.Box2(
191 | this.bounds.min,
192 | this.bounds.min.clone().add(this.bounds.max).multiplyScalar(0.5)
193 | )
194 | )
195 | }
196 | this.topLeft.addLabels(labels, point)
197 | }
198 | // Bottom left
199 | else {
200 | if (this.bottomLeft === null) {
201 | this.bottomLeft = new Quad(
202 | this.webgl,
203 | new THREE.Box2(
204 | new THREE.Vector2(this.bounds.min.x, (this.bounds.min.y + this.bounds.max.y) / 2),
205 | new THREE.Vector2((this.bounds.min.x + this.bounds.max.x) / 2, this.bounds.max.y)
206 | )
207 | )
208 | }
209 | this.bottomLeft.addLabels(labels, point)
210 | }
211 | } else {
212 | // Top right
213 | if (halfY >= point.y) {
214 | if (this.topRight === null) {
215 | this.topRight = new Quad(
216 | this.webgl,
217 | new THREE.Box2(
218 | new THREE.Vector2((this.bounds.min.x + this.bounds.max.x) / 2, this.bounds.min.y),
219 | new THREE.Vector2(this.bounds.max.x, (this.bounds.min.y + this.bounds.max.y) / 2)
220 | )
221 | )
222 | }
223 | this.topRight.addLabels(labels, point)
224 | }
225 | // Bottom right
226 | else {
227 | if (this.bottomRight === null) {
228 | this.bottomRight = new Quad(
229 | this.webgl,
230 | new THREE.Box2(
231 | this.bounds.min.clone().add(this.bounds.max).multiplyScalar(0.5),
232 | this.bounds.max
233 | )
234 | )
235 | }
236 | this.bottomRight.addLabels(labels, point)
237 | }
238 | }
239 | }
240 |
241 | findQuadWithMinLabelDensity(label, minDensity) {
242 | let density = this.getLabelDensity(label)
243 | if (density <= 0) {
244 | return null
245 | }
246 | if (density >= minDensity) {
247 | return this
248 | }
249 |
250 | if (label in this.labels) {
251 | let bestQuad = null,
252 | bestDensity = 0,
253 | quadDensity = 0
254 | const quads = [this.topLeft, this.topRight, this.bottomRight, this.bottomLeft]
255 | quads.forEach((quad) => {
256 | if (quad !== null) {
257 | quadDensity = quad.getLabelDensity(label)
258 | if (quadDensity > bestDensity) {
259 | bestQuad = quad
260 | bestDensity = quadDensity
261 | }
262 | }
263 | })
264 | return bestQuad.findQuadWithMinLabelDensity(label, minDensity)
265 | }
266 | return null
267 | }
268 |
269 | findQuadWithMaxLabelDensity(label, maxDensity) {
270 | let density = this.getLabelDensity(label)
271 | if (density <= maxDensity) {
272 | console.log(`Found quad with density ${density} for ${label}`)
273 | return this
274 | }
275 | console.log(`This one had ${density} for ${label}, looking in children`)
276 |
277 | if (label in this.labels) {
278 | let bestQuad = null,
279 | bestDensity = maxDensity,
280 | quadDensity = 0
281 | const quads = [this.topLeft, this.topRight, this.bottomRight, this.bottomLeft]
282 | quads.forEach((quad) => {
283 | if (quad !== null) {
284 | quadDensity = quad.getLabelDensity(label)
285 | if (quadDensity < bestDensity) {
286 | bestQuad = quad
287 | bestDensity = quadDensity
288 | }
289 | }
290 | })
291 | return bestQuad.findQuadWithMaxLabelDensity(label, maxDensity)
292 | }
293 | return null
294 | }
295 |
296 | getLabelDensity(label) {
297 | if (label in this.labels === true) {
298 | return this.labels[label] / (this.boundsSize.x * this.boundsSize.y) // TODO: Divide by ratio of 1x1m
299 | }
300 | return 0
301 | }
302 |
303 | hasLabels(labels, requireAll = false) {
304 | for (const label of labels) {
305 | const hasLabel = label in this.labels === true
306 | if (requireAll === true && hasLabel === false) {
307 | return false
308 | } else if (requireAll === false && hasLabel === true) {
309 | return true
310 | }
311 | }
312 | return true
313 | }
314 |
315 | positionHasLabels(position, labels) {
316 | if (Array.isArray(labels) === false) {
317 | return false
318 | }
319 |
320 | let point = this.getPoint(position)
321 |
322 | if (this.bounds.containsPoint(point) === false || this.hasLabels(labels) === false) {
323 | return false
324 | }
325 |
326 | const quads = [this.topLeft, this.topRight, this.bottomRight, this.bottomLeft]
327 | for (const quad of quads) {
328 | if (quad !== null && quad.bounds.containsPoint(point)) {
329 | return quad.positionHasLabels(point, labels)
330 | }
331 | }
332 |
333 | return true
334 | }
335 |
336 | getLabels(position) {
337 | const point = this.getPoint(position)
338 |
339 | if (this.bounds.containsPoint(point) === false) {
340 | return []
341 | }
342 |
343 | if (this.isSmallestAllowedSize() === true) {
344 | return this.labels
345 | }
346 |
347 | const quads = [this.topLeft, this.topRight, this.bottomRight, this.bottomLeft]
348 | for (const quad of quads) {
349 | if (quad !== null && quad.bounds.containsPoint(point)) {
350 | return quad.getLabels(point)
351 | }
352 | }
353 |
354 | return this.labels
355 | }
356 |
357 | getPoint(position) {
358 | if (hasOwnProperty.call(position, 'z')) {
359 | return new THREE.Vector2(position.x, position.z)
360 | }
361 | return position
362 | }
363 |
364 | drawDebug(color) {
365 | const box = new THREE.Box3(),
366 | center = new THREE.Vector3(
367 | (this.bounds.min.x + this.bounds.max.x) / 2,
368 | 10,
369 | (this.bounds.min.y + this.bounds.max.y) / 2
370 | ),
371 | end = new THREE.Vector3(this.boundsSize.x, 1, this.boundsSize.y)
372 | box.setFromCenterAndSize(center, end)
373 | const helper = new THREE.Box3Helper(box, color)
374 | this.webgl.scene.add(helper)
375 | }
376 | }
377 |
--------------------------------------------------------------------------------
/src/components/ContextualObject.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 |
3 | export default class ContextualObject extends THREE.Group {
4 | static className = null
5 | static distributionOptions = {}
6 | static labels = []
7 | static baseDensity = 0
8 | static randomAngle = new THREE.Vector3(0, 0, 0)
9 | static sizeVariation = 0.5
10 | static allowUnderwater = false
11 |
12 | constructor(webgl, options) {
13 | super(options)
14 |
15 | this.type = 'ContextualObject'
16 |
17 | this.webgl = webgl
18 | this.options = options
19 |
20 | const seedrandom = require('seedrandom')
21 | this.rng = seedrandom(this.options.seed)
22 |
23 | if (this.constructor.hasOwnProperty('randomAngle') === true) {
24 | this.setRotationFromEuler(
25 | new THREE.Euler(
26 | (this.rng() - 0.5) * 2 * THREE.MathUtils.degToRad(this.constructor.randomAngle.x),
27 | (this.rng() - 0.5) * 2 * THREE.MathUtils.degToRad(this.constructor.randomAngle.y),
28 | (this.rng() - 0.5) * 2 * THREE.MathUtils.degToRad(this.constructor.randomAngle.z)
29 | )
30 | )
31 | }
32 | }
33 |
34 | addMesh(geometry, materialOptions, variation = 0.1) {
35 | materialOptions.color.r += (this.rng() - 0.5) * variation
36 | materialOptions.color.g += (this.rng() - 0.5) * variation
37 | materialOptions.color.b += (this.rng() - 0.5) * variation
38 | if (materialOptions.roughness === undefined) {
39 | materialOptions.roughness = 0.6
40 | }
41 | materialOptions.roughness += (this.rng() - 0.5) * variation
42 |
43 | const material = new THREE.MeshPhysicalMaterial(materialOptions)
44 |
45 | const mesh = new THREE.Mesh(geometry, material)
46 | mesh.castShadow = true
47 | mesh.receiveShadow = true
48 |
49 | this.add(mesh)
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/Diorama.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import assets from '../utils/AssetManager'
3 | import { BIOMES } from './Biomes'
4 | import ContextualObject from './ContextualObject'
5 | import Quad from './ContextQuadtree'
6 | import Terrain from './Terrain'
7 | import Water from './Water'
8 |
9 | const hdriKey = assets.queue({
10 | url: 'textures/equirectangular/cloud_layers_1k.hdr',
11 | pmrem: true,
12 | })
13 |
14 | export class Diorama extends THREE.Group {
15 | constructor(webgl, options) {
16 | const start = Date.now()
17 | super(options)
18 |
19 | this.webgl = webgl
20 | this.options = options
21 |
22 | this.type = 'Diorama'
23 |
24 | if (this.options.diorama.seed === undefined || this.options.diorama.seed === null) {
25 | this.options.diorama.seed = 0
26 | }
27 |
28 | console.log(
29 | `Creating new diorama (seed=${options.diorama.seed}, bounds=[${options.diorama.bounds.x}, ${options.diorama.bounds.y}, ${options.diorama.bounds.z}])`
30 | )
31 |
32 | const halfX = this.options.diorama.bounds.x / 2,
33 | halfZ = this.options.diorama.bounds.z / 2
34 | const maxSize = Math.max(halfX, halfZ)
35 | const quadBounds = new THREE.Box2(
36 | new THREE.Vector2(-maxSize, -maxSize),
37 | new THREE.Vector2(maxSize, maxSize)
38 | )
39 | this.options.diorama.contextQuadtree = new Quad(webgl, quadBounds)
40 |
41 | this.setupLights()
42 |
43 | let seedrandom = require('seedrandom')
44 | this.rng = seedrandom(this.options.diorama.seed)
45 |
46 | this.terrain = new Terrain(this.webgl, this.options.diorama)
47 | this.add(this.terrain)
48 |
49 | this.water = null
50 | if (this.options.diorama.water.enabled === true) {
51 | this.water = new Water(this.webgl, this.options.diorama, this.terrain.waterCurve)
52 | this.add(this.water)
53 |
54 | if (window.DEBUG) {
55 | const points = this.terrain.waterCurve.getPoints(
56 | Math.max(1, this.options.diorama.water.meander) * 10
57 | )
58 | const waterLineGeometry = new THREE.BufferGeometry().setFromPoints(points)
59 | const material = new THREE.LineBasicMaterial({ color: 0xff00ff })
60 | const waterLine = new THREE.Line(waterLineGeometry, material)
61 | this.add(waterLine)
62 | }
63 | }
64 |
65 | for (const asset of BIOMES[this.options.diorama.biome.name].assets) {
66 | this.distributeObjects(asset)
67 | }
68 |
69 | this.createBase()
70 |
71 | if (window.DEBUG) {
72 | let geometry = new THREE.BoxGeometry(
73 | this.options.diorama.bounds.x,
74 | this.options.diorama.bounds.y,
75 | this.options.diorama.bounds.z
76 | )
77 | let wireframe = new THREE.WireframeGeometry(geometry)
78 |
79 | this.wireframe = new THREE.LineSegments(wireframe)
80 | this.wireframe.material.depthTest = true
81 | this.wireframe.material.opacity = 0.2
82 | this.wireframe.material.transparent = true
83 | this.add(this.wireframe)
84 | }
85 |
86 | const elapsed = Date.now() - start
87 | console.log(`Generated in ${elapsed / 1000}s`)
88 | }
89 |
90 | update(dt, time) { }
91 |
92 | setupLights() {
93 | this.skylight = new THREE.HemisphereLight(0xffffff, 0xffffff, 0.2)
94 | this.skylight.color.setHSL(0.56, 0.7, 0.6)
95 | this.skylight.groundColor.setHSL(0.095, 0.5, 0.5)
96 | this.skylight.position.set(0, 50, 0)
97 | this.add(this.skylight)
98 |
99 | const size =
100 | Math.max(
101 | this.options.diorama.bounds.x,
102 | this.options.diorama.bounds.y,
103 | this.options.diorama.bounds.z
104 | ) / 2
105 |
106 | this.sun = new THREE.DirectionalLight(0xffffff, 1)
107 | this.sun.color.setHSL(0.1, 1, 0.95)
108 | this.sun.position.set(-0.5, 1, 0.375)
109 | this.sun.position.multiplyScalar(size)
110 | this.sun.castShadow = true
111 | this.sun.shadow.mapSize.width = 1024
112 | this.sun.shadow.mapSize.height = 1024
113 | this.sun.shadow.camera.left = -size * 1.5
114 | this.sun.shadow.camera.right = size * 1.5
115 | this.sun.shadow.camera.top = size * 1.5
116 | this.sun.shadow.camera.bottom = -size * 1.5
117 | this.sun.shadow.camera.near = 0.1
118 | this.sun.shadow.camera.far = size * 3
119 | this.add(this.sun)
120 |
121 | if (window.DEBUG) {
122 | const helper = new THREE.CameraHelper(this.sun.shadow.camera)
123 | this.add(helper)
124 | }
125 |
126 | this.webgl.scene.environment = assets.get(hdriKey)
127 |
128 | this.webgl.renderer.toneMapping = THREE.ACESFilmicToneMapping
129 | this.webgl.renderer.toneMappingExposure = 0.5
130 | this.webgl.renderer.outputColorSpace = THREE.SRGBColorSpace
131 | }
132 |
133 | createBase() {
134 | // Base
135 | const basePadding = 4
136 | const baseHeight = 2
137 | const geometry = new THREE.BoxGeometry(
138 | this.options.diorama.bounds.x + basePadding,
139 | baseHeight,
140 | this.options.diorama.bounds.z + basePadding
141 | )
142 | const material = new THREE.MeshPhysicalMaterial({
143 | color: new THREE.Color(0.2, 0.2, 0.2),
144 | roughness: 0.25,
145 | })
146 | const base = new THREE.Mesh(geometry, material)
147 | base.translateY(-this.options.diorama.bounds.y / 2 - baseHeight / 2)
148 | base.castShadow = true
149 | base.receiveShadow = true
150 | this.add(base)
151 |
152 | // Floor plane
153 | const floorSize = Math.max(this.options.diorama.bounds.x, this.options.diorama.bounds.z) * 2
154 | const floorGeometry = new THREE.PlaneGeometry(floorSize, floorSize)
155 | floorGeometry.rotateX(-Math.PI / 2)
156 | floorGeometry.translate(0, -this.options.diorama.bounds.y / 2 - baseHeight, 0)
157 | const floorMaterial = new THREE.ShadowMaterial({ opacity: 0.25 })
158 | const floor = new THREE.Mesh(floorGeometry, floorMaterial)
159 | floor.receiveShadow = true
160 | this.add(floor)
161 | }
162 |
163 | /**
164 | * Distributes objects based on their contextual requirements.
165 | * @param {ContextualObject} classToDistribute The class of Contextual Object to distribute
166 | * @param {THREE.Mesh} onMesh Mesh to distribute objects on - if not specified, the terrain mesh is used
167 | */
168 | distributeObjects(classToDistribute, onMesh = undefined) {
169 | if (onMesh === undefined) {
170 | onMesh = this.terrain.mesh
171 | }
172 |
173 | const bounds = new THREE.Box3().setFromObject(onMesh),
174 | className = classToDistribute.prototype.constructor.className
175 |
176 | let numObjects = (bounds.max.x - bounds.min.x) * (bounds.max.z - bounds.min.z)
177 |
178 | if (
179 | this.options.diorama.biome.assets !== undefined &&
180 | className in this.options.diorama.biome.assets
181 | ) {
182 | numObjects *= this.options.diorama.biome.assets[className]
183 | }
184 |
185 | if (hasOwnProperty.call(classToDistribute, 'baseDensity')) {
186 | numObjects *= classToDistribute.baseDensity
187 | } else {
188 | console.warn(`Class '${className}' had no baseDensity property!`)
189 | }
190 |
191 | numObjects = Math.floor(numObjects)
192 |
193 | const seedrandom = require('seedrandom'),
194 | rng = seedrandom(this.options.diorama.seed),
195 | raycaster = new THREE.Raycaster()
196 |
197 | const options = { bounds: this.options.diorama.bounds }
198 | for (let i = 0; i < numObjects; i++) {
199 | let position = new THREE.Vector3(rng(), 0, rng())
200 | position.multiply(new THREE.Vector3().subVectors(bounds.max, bounds.min))
201 | position.add(new THREE.Vector3(-0.5, 1, -0.5).multiply(this.options.diorama.bounds))
202 | raycaster.set(position, new THREE.Vector3(0, -1, 0))
203 |
204 | const labels = this.options.diorama.contextQuadtree.getLabels(position)
205 | if (this.meetsDistributionRequirements(classToDistribute, rng, labels) === false) {
206 | // This location doesn't meet our object's requirements
207 | continue
208 | }
209 |
210 | let intersects = raycaster.intersectObjects(this.children, true)
211 | let rayColor = 0xff0000
212 | if (intersects.length > 0) {
213 | rayColor = 0x0000ff
214 |
215 | options.isUnderwater = false
216 | if (classToDistribute.allowUnderwater === true && this.water !== null) {
217 | if (intersects[0].object === this.water.mesh) {
218 | // TODO: Maybe need a bounds against water level check for if the object is *entirely* underwater?
219 | options.isUnderwater = true
220 | intersects[0] = intersects[1] // Skip the water hit
221 | }
222 | }
223 |
224 | if (intersects[0].object === onMesh) {
225 | rayColor = 0x00ff00
226 | position = intersects[0].point
227 |
228 | options.seed = this.options.diorama.seed + i
229 | options.labels = this.options.diorama.contextQuadtree.getLabels(position)
230 | options.terrainColor = this.options.diorama.contextQuadtree.getAverageColor(position)
231 | const newObject = new classToDistribute(this.webgl, options)
232 | newObject.position.set(position.x, position.y, position.z)
233 | newObject.updateMatrixWorld()
234 | this.add(newObject)
235 |
236 | if (hasOwnProperty.call(classToDistribute, 'labels')) {
237 | if (classToDistribute.labels.length > 0) {
238 | this.options.diorama.contextQuadtree.addLabels(classToDistribute.labels, position)
239 | } else {
240 | console.warn(`No labels defined for class ${newObject.type}`)
241 | }
242 | } else {
243 | console.warn(`Failed to find labels property on class ${newObject.type}`)
244 | }
245 | }
246 | }
247 |
248 | if (window.DEBUG) {
249 | const arrow = new THREE.ArrowHelper(
250 | raycaster.ray.direction,
251 | raycaster.ray.origin,
252 | this.options.diorama.bounds.y * 1.5,
253 | rayColor,
254 | 1,
255 | 0.5
256 | )
257 | this.add(arrow)
258 | }
259 | }
260 | }
261 |
262 | /**
263 | * Returns true if a label array meets requirements set by the ContextualObject class, otherwise false
264 | * @param {ContextualObject} objectClass The class of Contextual Object to check
265 | * @param {seedrandom.prng} rng Seeded random number generator object
266 | * @param {string[]} labels Array of labels representing the context to check against
267 | * @returns Value representing if the requirements were met
268 | */
269 | meetsDistributionRequirements(object, rng, labels) {
270 | for (const [key, value] of Object.entries(object.distributionOptions)) {
271 | if (Object.keys(labels).includes(key) === true) {
272 | if (rng() <= value) {
273 | return true
274 | }
275 | }
276 | }
277 | return false
278 | }
279 | }
280 |
281 | export function getDefaultDioramaOptions() {
282 | const defaultDioramaOptions = {
283 | seed: 2655,
284 | bounds: new THREE.Vector3(60, 16, 32),
285 | biome: { name: Object.keys(BIOMES)[0] },
286 | water: {
287 | enabled: true,
288 | level: 0.25,
289 | depth: 1,
290 | width: 2,
291 | falloff: 4,
292 | meander: 2,
293 | shoreline: {
294 | enabled: true,
295 | width: 1,
296 | falloff: 2,
297 | },
298 | },
299 | }
300 |
301 | function getAssetClassDefaults() {
302 | const result = {}
303 | for (const assetClass of defaultDioramaOptions.biome) {
304 | result[assetClass.prototype.constructor.className] = 1
305 | }
306 | return result
307 | }
308 |
309 | // Load previous session settings if available
310 | const sessionOptions = sessionStorage.getItem('dioramaOptions')
311 | if (sessionOptions !== null) {
312 | let parsedOptions = {}
313 | try {
314 | parsedOptions = JSON.parse(sessionOptions)
315 | } catch (err) {
316 | console.log('Error when parsing session options!')
317 | }
318 | Object.assign(defaultDioramaOptions, parsedOptions)
319 | }
320 |
321 | return defaultDioramaOptions
322 | }
323 |
--------------------------------------------------------------------------------
/src/components/Rock.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import ContextualObject from './ContextualObject'
3 |
4 | export default class Rock extends ContextualObject {
5 | static className = 'Rock'
6 | static distributionOptions = { shoreline: 1, ground: 0.2 }
7 | static labels = ['rock']
8 | static baseDensity = 0.1
9 | static randomAngle = new THREE.Vector3(15, 90, 15)
10 | static baseSize = 1
11 | static allowUnderwater = true
12 |
13 | constructor(webgl, options) {
14 | super(webgl, options)
15 |
16 | this.type = 'Rock'
17 |
18 | this.size =
19 | this.constructor.baseSize +
20 | (this.rng() - 0.5) * this.constructor.baseSize * this.constructor.sizeVariation
21 |
22 | const geometry = new THREE.SphereGeometry(this.size, 4, 4)
23 | const color = new THREE.Color(0.4, 0.36, 0.32)
24 | if (this.options.terrainColor !== null) {
25 | color.lerp(this.options.terrainColor, 0.5)
26 | }
27 | const roughness = this.options.isUnderwater === true ? 0.4 : 0.6
28 | this.addMesh(geometry, { color: color, roughness: roughness }, 0.05)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/Terrain.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import { ImprovedNoise } from 'three/examples/jsm/math/ImprovedNoise.js'
3 | import generateSideMeshes from '../utils/meshUtils'
4 | import { BIOMES } from './Biomes'
5 |
6 | export default class Terrain extends THREE.Group {
7 | #bottomPadding = 2
8 |
9 | constructor(webgl, options) {
10 | super(options)
11 |
12 | this.type = 'Terrain'
13 |
14 | this.webgl = webgl
15 | this.options = options
16 |
17 | this.generateHeight(this.options.bounds.x, this.options.bounds.z)
18 |
19 | const geometry = new THREE.PlaneGeometry(
20 | this.options.bounds.x,
21 | this.options.bounds.z,
22 | this.options.bounds.x - 1,
23 | this.options.bounds.z - 1
24 | )
25 | geometry.rotateX(-Math.PI / 2)
26 |
27 | let vertices = geometry.getAttribute('position').array
28 | for (let i = 0, l = vertices.length / 3; i < l; i++) {
29 | vertices[i * 3 + 1] = this.heightData[i]
30 | }
31 |
32 | if (this.options.water.enabled === true) {
33 | this.waterCurve = this.getWaterCurve(geometry)
34 | this.modifyGeometryForWater(geometry)
35 | }
36 | geometry.computeVertexNormals()
37 |
38 | const texture = new THREE.CanvasTexture(
39 | this.generateTexture(geometry, this.options.bounds.x, this.options.bounds.z)
40 | )
41 | texture.wrapS = THREE.ClampToEdgeWrapping
42 | texture.wrapT = THREE.ClampToEdgeWrapping
43 |
44 | const material = new THREE.MeshPhysicalMaterial({
45 | map: texture,
46 | roughness: 0.7,
47 | })
48 |
49 | this.mesh = new THREE.Mesh(geometry, material)
50 |
51 | this.mesh.castShadow = true
52 | this.mesh.receiveShadow = true
53 |
54 | this.add(this.mesh)
55 |
56 | const sideMaterial = new THREE.MeshPhysicalMaterial({
57 | color: BIOMES[this.options.biome.name].terrain.side,
58 | roughness: 0.6,
59 | })
60 | generateSideMeshes(
61 | this,
62 | geometry,
63 | sideMaterial,
64 | this.options.bounds.x - 1,
65 | this.options.bounds.z - 1,
66 | -this.options.bounds.y / 2
67 | )
68 | }
69 |
70 | generateHeight(width, height) {
71 | let size = width * height,
72 | perlin = new ImprovedNoise(),
73 | quality = 1,
74 | seedrandom = require('seedrandom'),
75 | rng = seedrandom(this.options.seed),
76 | z = rng()
77 |
78 | this.heightData = new Float32Array(size)
79 |
80 | for (let j = 0; j < 4; j++) {
81 | for (let i = 0; i < size; i++) {
82 | let x = i % width,
83 | y = ~~(i / width)
84 | this.heightData[i] += Math.abs(perlin.noise(x / quality, y / quality, z) * quality * 1.75)
85 | }
86 |
87 | quality *= 5
88 | }
89 |
90 | // Normalize height
91 | let min = 10000,
92 | max = -10000
93 | for (let i = 0; i < size; i++) {
94 | if (this.heightData[i] < min) {
95 | min = this.heightData[i]
96 | }
97 | if (this.heightData[i] > max) {
98 | max = this.heightData[i]
99 | }
100 | }
101 |
102 | const heightScale = (this.options.bounds.y - this.#bottomPadding - 2) / this.options.bounds.y,
103 | multiplier = this.options.bounds.y / max
104 | for (let i = 0; i < size; i++) {
105 | this.heightData[i] =
106 | ((this.heightData[i] - min) * multiplier -
107 | this.options.bounds.y * 0.5 +
108 | this.#bottomPadding) *
109 | heightScale
110 | }
111 | }
112 |
113 | generateTexture(geometry, width, height) {
114 | let canvas, context, image, imageData
115 |
116 | canvas = document.createElement('canvas')
117 | canvas.width = width
118 | canvas.height = height
119 |
120 | context = canvas.getContext('2d')
121 | context.fillStyle = '#000'
122 | context.fillRect(0, 0, width, height)
123 |
124 | image = context.getImageData(0, 0, canvas.width, canvas.height)
125 | imageData = image.data
126 |
127 | const seedrandom = require('seedrandom'),
128 | rng = seedrandom(this.options.seed)
129 |
130 | const halfHeight = this.options.bounds.y / 2,
131 | waterLevel = THREE.MathUtils.lerp(-halfHeight, halfHeight, this.options.water.level),
132 | vertices = geometry.getAttribute('position').array,
133 | normals = geometry.getAttribute('normal').array,
134 | ground = BIOMES[this.options.biome.name].terrain.ground
135 | .clone()
136 | .add(new THREE.Color(rng() * 0.05, rng() * 0.05, rng() * 0.05)),
137 | shoreline = BIOMES[this.options.biome.name].terrain.shoreline
138 | .clone()
139 | .add(new THREE.Color(rng() * 0.05, rng() * 0.05, rng() * 0.05)),
140 | cliff = BIOMES[this.options.biome.name].terrain.cliff
141 | .clone()
142 | .add(new THREE.Color(rng() * 0.05, rng() * 0.05, rng() * 0.05)),
143 | underwater = BIOMES[this.options.biome.name].terrain.underwater
144 | .clone()
145 | .add(new THREE.Color(rng() * 0.05, rng() * 0.05, rng() * 0.05)),
146 | upVector = new THREE.Vector3(0, 1, 0),
147 | minCliffSteepness = 0.15,
148 | maxCliffSteepness = 0.2
149 |
150 | for (let i = 0, l = vertices.length / 3; i < l; i++) {
151 | let color = ground.clone(),
152 | labels = ['ground']
153 | if (this.options.water.enabled) {
154 | const height = vertices[i * 3 + 1]
155 | if (height < waterLevel) {
156 | color = underwater.clone()
157 | labels = ['shoreline']
158 | }
159 | if (this.options.water.shoreline.enabled === true) {
160 | const sandMax = this.options.water.shoreline.width + this.options.water.shoreline.falloff
161 | if (height <= waterLevel - this.options.water.shoreline.width) {
162 | // UNDERWATER TO SHORELINE
163 | const t = THREE.MathUtils.clamp(height / (waterLevel - height), 0, 1)
164 | color.lerp(shoreline, t)
165 | } else if (sandMax > 0.001) {
166 | if (height <= waterLevel + this.options.water.shoreline.width) {
167 | // SOLID SHORELINE
168 | color = shoreline.clone()
169 | labels = ['shoreline']
170 | } else if (this.options.water.shoreline.falloff > 0.001) {
171 | // SHORELINE TO GROUND
172 | const t = THREE.MathUtils.clamp(
173 | (height - waterLevel - this.options.water.shoreline.width) /
174 | this.options.water.shoreline.falloff,
175 | 0,
176 | 1
177 | )
178 | color = shoreline.clone().lerp(ground, t)
179 | if (t <= 0.33) {
180 | labels = ['shoreline']
181 | } else if (t <= 0.67) {
182 | labels.push('shoreline')
183 | }
184 | }
185 | }
186 | }
187 | }
188 |
189 | const normal = new THREE.Vector3(normals[i * 3], normals[i * 3 + 1], normals[i * 3 + 2]),
190 | cliffSteepness = 1 - Math.abs(normal.dot(upVector))
191 | // 1 = vertical, 0 = horizontal
192 | if (cliffSteepness > minCliffSteepness) {
193 | const blend = Math.min(
194 | 1,
195 | (cliffSteepness - minCliffSteepness) / (maxCliffSteepness - minCliffSteepness)
196 | )
197 | color.lerp(cliff, blend)
198 | if (blend >= 0.67) {
199 | labels = ['cliff']
200 | } else if (blend > 0.33) {
201 | labels.push('cliff')
202 | }
203 | }
204 |
205 | if (height < waterLevel) {
206 | labels.push = ['water']
207 | }
208 |
209 | imageData[i * 4] = color.r * 255 * (1 - rng() * 0.1)
210 | imageData[i * 4 + 1] = color.g * 255 * (1 - rng() * 0.1)
211 | imageData[i * 4 + 2] = color.b * 255 * (1 - rng() * 0.1)
212 |
213 | // Add terrain context labels
214 | const position = new THREE.Vector2(vertices[i * 3], vertices[i * 3 + 2])
215 | this.options.contextQuadtree.addLabels(labels, position)
216 | this.options.contextQuadtree.setColor(color, position)
217 | }
218 |
219 | context.putImageData(image, 0, 0)
220 |
221 | const canvasScaled = document.createElement('canvas')
222 | canvasScaled.width = width * 4
223 | canvasScaled.height = height * 4
224 |
225 | context = canvasScaled.getContext('2d')
226 | context.scale(4, 4)
227 | context.drawImage(canvas, 0, 0)
228 |
229 | image = context.getImageData(0, 0, canvasScaled.width, canvasScaled.height)
230 | imageData = image.data
231 |
232 | // Add some noise
233 | for (let i = 0, l = imageData.length; i < l; i += 4) {
234 | let v = ~~((rng() - 0.5) * 8)
235 |
236 | imageData[i] += v
237 | imageData[i + 1] += v
238 | imageData[i + 2] += v
239 | }
240 |
241 | context.putImageData(image, 0, 0)
242 |
243 | return canvasScaled
244 | }
245 |
246 | modifyGeometryForWater(geometry) {
247 | const points = this.waterCurve.getPoints(Math.max(1, this.options.water.meander) * 10)
248 | let vertices = geometry.getAttribute('position').array
249 | let test = new THREE.Vector3(0, 0, 0),
250 | target = new THREE.Vector3(0, 0, 0)
251 | for (let i = 0, l = vertices.length / 3; i < l; i++) {
252 | const vertex = new THREE.Vector3(vertices[i * 3], vertices[i * 3 + 1], vertices[i * 3 + 2])
253 | const vertex2D = new THREE.Vector2(vertex.x, vertex.z)
254 | let closest = 100000
255 | for (let p = 0; p < points.length - 1; p++) {
256 | const line = new THREE.Line3(points[p], points[p + 1])
257 | line.closestPointToPoint(vertex, true, test)
258 | let distance = vertex2D.distanceTo(new THREE.Vector2(test.x, test.z))
259 | if (distance < closest) {
260 | closest = distance
261 | target = test.clone()
262 | }
263 | }
264 | const distance = vertex2D.distanceTo(new THREE.Vector2(target.x, target.z))
265 | if (distance < this.options.water.width / 2 + this.options.water.falloff) {
266 | vertices[i * 3 + 1] = this.getWaterVertexHeight(distance, target.y, vertices[i * 3 + 1])
267 | }
268 | }
269 | }
270 |
271 | getWaterVertexHeight(distance, target, current) {
272 | const t = THREE.MathUtils.clamp(
273 | (distance - this.options.water.width / 2) / this.options.water.falloff,
274 | 0,
275 | 1
276 | )
277 | const calculated = THREE.MathUtils.lerp(target - this.options.water.depth, current, t)
278 | const lowest = -this.options.bounds.y / 2 + 1
279 | return Math.max(lowest, calculated)
280 | }
281 |
282 | getRandomPointOnTerrain() {
283 | const raycaster = new THREE.Raycaster()
284 | let position = new THREE.Vector3(this.rng(), 0, this.rng())
285 | position.multiply(this.options.bounds)
286 | position.y = this.options.bounds.y
287 | position.sub(new THREE.Vector3(0.5, 0.5, 0.5).multiply(this.options.bounds))
288 | raycaster.set(position, new THREE.Vector3(0, -1, 0))
289 | const intersects = raycaster.intersectObject(this.mesh)
290 | if (intersects.length > 0) {
291 | return { hit: true, position: intersects[0].point }
292 | }
293 | return { hit: false, position: null }
294 | }
295 |
296 | getWaterCurve(geometry) {
297 | let points = [new THREE.Vector3(0, 100000, 0), new THREE.Vector3(0, 100000, 0)],
298 | tempVec = new THREE.Vector3()
299 | const vertices = geometry.getAttribute('position').array,
300 | minDist = Math.min(this.options.bounds.x, this.options.bounds.z) / 4
301 | for (let i = 0; i < vertices.length / 3; i++) {
302 | for (let j = 0; j < points.length; j++) {
303 | tempVec.x = vertices[i * 3]
304 | tempVec.y = vertices[i * 3 + 1]
305 | tempVec.z = vertices[i * 3 + 2]
306 | if (tempVec.y < points[j].y) {
307 | if (j == 0 || tempVec.distanceTo(points[j - 1]) > minDist) {
308 | points[j].x = tempVec.x
309 | points[j].y = tempVec.y
310 | points[j].z = tempVec.z
311 | break
312 | }
313 | }
314 | }
315 | }
316 |
317 | const xTest = this.options.bounds.x / 2,
318 | zTest = this.options.bounds.z / 2,
319 | maxDist = Math.max(this.options.bounds.x, this.options.bounds.y),
320 | direction = points[1].clone().sub(points[0]).normalize(),
321 | offset = direction.clone().multiplyScalar(maxDist)
322 | let line = new THREE.Line3(points[0].clone().add(offset), points[0].clone().sub(offset))
323 |
324 | let meander = this.options.water.meander
325 | if (Math.abs(direction.dot(new THREE.Vector3(1, 0, 0))) >= 0.5) {
326 | meander *= xTest / 20
327 | } else {
328 | meander *= zTest / 20
329 | }
330 | meander = Math.ceil(meander)
331 |
332 | let xPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), zTest)
333 | let zPlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), xTest)
334 |
335 | let endPoint = new THREE.Vector3(0, 0, 0)
336 | if (
337 | xPlane.intersectLine(line, endPoint) ||
338 | zPlane.intersectLine(line, endPoint)
339 | ) {
340 | points[1] = endPoint
341 | }
342 |
343 | xPlane = new THREE.Plane(new THREE.Vector3(0, 0, -1), zTest)
344 | zPlane = new THREE.Plane(new THREE.Vector3(-1, 0, 0), xTest)
345 |
346 | let startPoint = new THREE.Vector3(0, 0, 0)
347 | if (
348 | xPlane.intersectLine(line, startPoint) ||
349 | zPlane.intersectLine(line, startPoint)
350 | ) {
351 | points[0] = startPoint
352 | }
353 |
354 | // Flatten the water
355 | if (points[0].y > points[1].y) {
356 | points[0].y = THREE.MathUtils.lerp(points[0].y, points[1].y, 0.75)
357 | } else {
358 | points[1].y = THREE.MathUtils.lerp(points[1].y, points[0].y, 0.75)
359 | }
360 |
361 | // Don't let the curve go below the floor
362 | const lowest = -this.options.bounds.y / 2 + 1
363 | for (let p = 0; p < points.length; p++) {
364 | points[p].y = Math.max(lowest, points[p].y)
365 | }
366 |
367 | // Add some meandering points
368 | const seedrandom = require('seedrandom'),
369 | rng = seedrandom(this.options.seed),
370 | finalPoints = [points[0], points[1]],
371 | meanderDistance = 1.0 / (meander + 1)
372 | line = new THREE.Line3(points[0], points[1])
373 | for (let i = 0; i < meander; i++) {
374 | let newVec = new THREE.Vector3()
375 | line.at((i + 1) * meanderDistance, newVec)
376 | newVec.x += ((rng() - 0.5) * maxDist) / 4
377 | newVec.z += ((rng() - 0.5) * maxDist) / 4
378 | finalPoints.splice(i + 1, 0, newVec)
379 | }
380 |
381 | return new THREE.CatmullRomCurve3(finalPoints)
382 | }
383 | }
384 |
--------------------------------------------------------------------------------
/src/components/Tree.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import ContextualObject from './ContextualObject'
3 |
4 | export default class Tree extends ContextualObject {
5 | static className = 'Tree'
6 | static distributionOptions = { ground: 1 }
7 | static labels = ['vegetation', 'tree']
8 | static baseDensity = 0.025
9 | static randomAngle = new THREE.Vector3(4, 0, 4)
10 | static baseHeight = 7
11 |
12 | constructor(webgl, options) {
13 | super(webgl, options)
14 |
15 | this.type = 'Tree'
16 |
17 | this.height =
18 | this.constructor.baseHeight +
19 | (this.rng() - 0.5) * this.constructor.baseHeight * this.constructor.sizeVariation
20 |
21 | // Foliage
22 | const trunkVerticalOffset = 0.1 + this.rng() * 0.1
23 | let geometry = new THREE.CylinderGeometry(0.01, this.height * 0.25, this.height, 32, 4, false)
24 | geometry.translate(0, this.height * (0.5 + trunkVerticalOffset), 0)
25 |
26 | let color = new THREE.Color(0.15, 0.24, 0.12)
27 | this.addMesh(geometry, { color: color }, 0.05)
28 |
29 | // Trunk
30 | const trunkWidth = this.height * 0.05
31 | geometry = new THREE.CylinderGeometry(trunkWidth, trunkWidth, this.height / 4, 32, 1, true)
32 | geometry.translate(0, (this.height * trunkVerticalOffset) / 2, 0)
33 |
34 | color = new THREE.Color(0.28, 0.21, 0.12)
35 | this.addMesh(geometry, { color: color })
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/Water.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 | import generateSideMeshes from '../utils/meshUtils'
3 | import { BIOMES } from './Biomes'
4 |
5 | export default class Water extends THREE.Group {
6 | constructor(webgl, options, waterCurve) {
7 | super(options)
8 |
9 | this.type = 'WaterMesh'
10 |
11 | this.webgl = webgl
12 | this.options = options
13 | this.waterCurve = waterCurve
14 |
15 | if (this.options.water.level <= 0.001) {
16 | return
17 | }
18 |
19 | const seedrandom = require('seedrandom')
20 | this.rng = seedrandom(this.options.seed)
21 | const waterOptions = BIOMES[this.options.biome.name].water
22 | const color = waterOptions.color
23 | .clone()
24 | .add(new THREE.Color(this.rng() * 0.05, this.rng() * 0.05, this.rng() * 0.05)),
25 | material = new THREE.MeshPhysicalMaterial({
26 | color: color,
27 | transparent: true,
28 | opacity: waterOptions.opacity,
29 | roughness: 0.01,
30 | reflectivity: 1,
31 | }),
32 | geometry = this.generateMesh()
33 |
34 | this.mesh = new THREE.Mesh(geometry, material)
35 |
36 | this.mesh.castShadow = false
37 | this.mesh.receiveShadow = false
38 |
39 | this.add(this.mesh)
40 |
41 | generateSideMeshes(this, geometry, material, 1, 1, -this.options.bounds.y / 2)
42 | }
43 |
44 | generateMesh() {
45 | const geometry = new THREE.PlaneGeometry(
46 | this.options.bounds.x,
47 | this.options.bounds.z,
48 | 1,
49 | 1
50 | )
51 | geometry.rotateX(-Math.PI / 2)
52 |
53 | const points = this.waterCurve.getPoints(Math.max(1, this.options.water.meander) * 10)
54 | const halfHeight = this.options.bounds.y / 2
55 | let vertices = geometry.getAttribute('position').array
56 | for (let i = 0, j = 0, l = vertices.length / 3; i < l; i++, j += 3) {
57 | vertices[j] *= 0.999
58 | vertices[j + 1] = THREE.MathUtils.lerp(-halfHeight, halfHeight, this.options.water.level)
59 | vertices[j + 2] *= 0.999
60 | }
61 |
62 | return geometry
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import WebGLApp from './utils/WebGLApp'
2 | import assets from './utils/AssetManager'
3 | import { Diorama, getDefaultDioramaOptions } from './components/Diorama'
4 | import DioramaControls from './ui/DioramaControls'
5 | import { addScreenshotButton, addRecordButton } from './screenshot-record-buttons'
6 | import { Vector3, PCFSoftShadowMap } from 'three'
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 | alpha: true,
18 | background: '#ABC',
19 | backgroundAlpha: 1,
20 | // show the GUI and FPS if we're in debug mode
21 | showFps: window.DEBUG,
22 | gui: window.DEBUG,
23 | orbitControls: { enabled: true, maxDistance: 250 },
24 | cameraPosition: new Vector3(30, 20, 60),
25 | fov: 40,
26 | near: 1,
27 | far: 500,
28 | })
29 |
30 | webgl.renderer.shadowMap.enabled = true
31 | webgl.renderer.shadowMap.type = PCFSoftShadowMap
32 |
33 | // attach it to the window to inspect in the console
34 | if (window.DEBUG) {
35 | window.webgl = webgl
36 | }
37 |
38 | const defaultDioramaOptions = getDefaultDioramaOptions()
39 | // hide canvas
40 | webgl.canvas.style.visibility = 'hidden'
41 |
42 | new DioramaControls(webgl, defaultDioramaOptions)
43 |
44 | // Hide canvas until assets are loaded
45 | webgl.canvas.style.visibility = 'hidden'
46 |
47 | // Load any queued assets
48 | await assets.load({ renderer: webgl.renderer });
49 |
50 | const dioramaOptions = {
51 | diorama: defaultDioramaOptions,
52 | }
53 | webgl.scene.diorama = new Diorama(webgl, dioramaOptions)
54 | webgl.scene.add(webgl.scene.diorama)
55 |
56 | // add the save screenshot and save gif buttons
57 | if (window.DEBUG) {
58 | addScreenshotButton(webgl)
59 | addRecordButton(webgl)
60 | }
61 |
62 | // Rotate camera target slightly below the center
63 | webgl.orbitControls.target.set(0, -defaultDioramaOptions.bounds.y / 5, 0)
64 |
65 | // show canvas
66 | webgl.canvas.style.visibility = ''
67 |
68 | // start animation loop
69 | webgl.start()
70 |
--------------------------------------------------------------------------------
/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/ui/DioramaControls.js:
--------------------------------------------------------------------------------
1 | import { Diorama } from '../components/Diorama'
2 | import { BIOMES } from '../components/Biomes'
3 | import { isEmpty } from 'lodash'
4 |
5 | /** Default ranges for option sliders */
6 | const ranges = {
7 | options: {
8 | seed: { max: 10000 },
9 | },
10 | bounds: {
11 | x: { min: 5, max: 80 },
12 | y: { min: 5, max: 40 },
13 | z: { min: 5, max: 80 },
14 | },
15 | water: {
16 | level: { max: 1, step: 0.01 },
17 | depth: { max: 10, step: 0.5 },
18 | width: { min: 0.5, max: 20, step: 0.5 },
19 | falloff: { min: 0.5, max: 20, step: 0.5 },
20 | meander: { max: 5 },
21 | },
22 | shoreline: {
23 | width: { max: 10, step: 0.5 },
24 | falloff: { max: 10, step: 0.5 },
25 | },
26 | }
27 |
28 | class InputControl {
29 | constructor(owner, options) {
30 | this.owner = owner
31 | this.options = options
32 |
33 | this.field = document.createElement('div')
34 |
35 | this.label = document.createElement('label')
36 | this.label.innerText = toDisplayString(options.name)
37 | this.field.appendChild(this.label)
38 |
39 | this.input = document.createElement('input')
40 | this.input.onchange = () => this.propertyChanged()
41 | this.field.appendChild(this.input)
42 |
43 | options.parent.appendChild(this.field)
44 | }
45 |
46 | propertyChanged() {
47 | this.owner.propertyChanged()
48 | }
49 |
50 | setVisibility(visible) {
51 | this.field.style.display = visible ? 'block' : 'none'
52 | }
53 | }
54 |
55 | class CheckBoxControl extends InputControl {
56 | constructor(owner, options) {
57 | super(owner, options)
58 |
59 | this.input.setAttribute('type', 'checkbox')
60 | this.input.checked = options.defaultValue == true
61 | }
62 |
63 | getValue() {
64 | return this.input.checked
65 | }
66 | }
67 |
68 | class SliderControl extends InputControl {
69 | constructor(owner, options) {
70 | super(owner, options)
71 |
72 | this.input.setAttribute('type', 'range')
73 | this.input.setAttribute('min', options.min)
74 | this.input.setAttribute('max', options.max)
75 | this.input.setAttribute('step', options.step)
76 | this.input.value = options.defaultValue
77 | }
78 |
79 | getValue() {
80 | return parseFloat(this.input.value)
81 | }
82 | }
83 |
84 | class BiomeGroup {
85 | constructor(owner, options) {
86 | this.owner = owner
87 | this.options = options
88 |
89 | this.biomeName = options.defaultValue.name
90 |
91 | this.div = document.createElement('div')
92 | this.heading = document.createElement('h' + options.depth)
93 | this.heading.innerText = 'Biome'
94 | this.div.appendChild(this.heading)
95 |
96 | this.biomeSelect = document.createElement('select')
97 | for (const biomeName of Object.keys(BIOMES)) {
98 | const option = document.createElement('option')
99 | option.value = biomeName
100 | option.innerText = toDisplayString(biomeName)
101 | if (biomeName === this.biomeName) {
102 | option.selected = true
103 | }
104 | this.biomeSelect.appendChild(option)
105 | }
106 | this.biomeSelect.onchange = () => this.biomeChanged()
107 | this.heading.appendChild(this.biomeSelect)
108 |
109 | this.controls = {}
110 | this.updateControls()
111 |
112 | options.parent.appendChild(this.div)
113 | }
114 |
115 | biomeChanged() {
116 | this.biomeName = this.biomeSelect.options[this.biomeSelect.selectedIndex].value
117 | this.updateControls()
118 | this.propertyChanged()
119 | }
120 |
121 | updateControls() {
122 | for (const control of Object.keys(this.controls)) {
123 | this.div.removeChild(this.controls[control].div)
124 | }
125 |
126 | this.controls = {}
127 | let childOptions = {
128 | name: 'assets',
129 | optionsObject: BIOMES[this.biomeName].assets,
130 | valueOverrides: this.options.optionsObject.assets,
131 | parent: this.div,
132 | depth: this.options.depth + 1,
133 | }
134 | this.controls['assets'] = new AssetArrayGroup(this, childOptions)
135 | }
136 |
137 | propertyChanged() {
138 | this.options.optionsObject.assets = this.controls['assets'].getValue()
139 | this.owner.propertyChanged()
140 | }
141 |
142 | getValue() {
143 | const options = { name: this.biomeName }
144 | for (const [key, value] of Object.entries(this.controls)) {
145 | options[key] = value.getValue()
146 | }
147 | return options
148 | }
149 | }
150 |
151 | class AssetArrayGroup {
152 | constructor(owner, options) {
153 | this.owner = owner
154 | this.options = options
155 |
156 | this.div = document.createElement('div')
157 |
158 | this.controls = {}
159 |
160 | const childOptions = {
161 | parent: this.div,
162 | min: 0,
163 | max: 3,
164 | step: 0.1,
165 | }
166 |
167 | for (const object of options.optionsObject) {
168 | const name = object.prototype.constructor.className
169 | let defaultValue = 1
170 | if (
171 | this.options.valueOverrides !== undefined &&
172 | Object.keys(this.options.valueOverrides).indexOf(name) > -1
173 | ) {
174 | defaultValue = this.options.valueOverrides[name]
175 | }
176 | Object.assign(childOptions, {
177 | name: name,
178 | defaultValue: defaultValue,
179 | })
180 | this.controls[name] = new SliderControl(this, childOptions)
181 | }
182 |
183 | options.parent.appendChild(this.div)
184 | }
185 |
186 | propertyChanged() {
187 | this.owner.propertyChanged()
188 | }
189 |
190 | getValue() {
191 | const options = {}
192 | for (const [key, value] of Object.entries(this.controls)) {
193 | options[key] = value.getValue()
194 | }
195 | return options
196 | }
197 | }
198 |
199 | class ControlGroup {
200 | constructor(owner, options) {
201 | this.owner = owner
202 | this.options = options
203 |
204 | this.div = document.createElement('div')
205 | this.heading = document.createElement('h' + options.depth)
206 | this.heading.innerText = toDisplayString(options.name)
207 | this.div.appendChild(this.heading)
208 |
209 | this.controls = {}
210 |
211 | for (const [key, value] of Object.entries(options.optionsObject)) {
212 | let childOptions = { name: key, defaultValue: value, parent: this.div }
213 | switch (typeof value) {
214 | case 'boolean':
215 | this.controls[key] = new CheckBoxControl(this, childOptions)
216 | break
217 | case 'number':
218 | Object.assign(childOptions, { min: 0, max: 100, step: 1 })
219 | if (options.name in ranges && key in ranges[options.name]) {
220 | Object.assign(childOptions, ranges[options.name][key])
221 | }
222 | this.controls[key] = new SliderControl(this, childOptions)
223 | break
224 | case 'object':
225 | if (isEmpty(value) === false) {
226 | Object.assign(childOptions, { optionsObject: value, depth: options.depth + 1 })
227 | if (key === 'biome') {
228 | this.controls[key] = new BiomeGroup(this, childOptions)
229 | } else {
230 | this.controls[key] = new ControlGroup(this, childOptions)
231 | }
232 | }
233 | break
234 | }
235 | }
236 |
237 | options.parent.appendChild(this.div)
238 | }
239 |
240 | propertyChanged() {
241 | if ('enabled' in this.controls) {
242 | this.setVisibility(this.controls.enabled.getValue())
243 | }
244 | this.owner.propertyChanged()
245 | }
246 |
247 | getValue() {
248 | let optionValues = {}
249 | for (const [key, control] of Object.entries(this.controls)) {
250 | optionValues[key] = control.getValue()
251 | }
252 | return optionValues
253 | }
254 |
255 | setVisibility(visible) {
256 | for (const [key, control] of Object.entries(this.controls)) {
257 | if (key !== 'enabled') {
258 | this.controls[key].setVisibility(visible)
259 | }
260 | }
261 | }
262 | }
263 |
264 | export default class DioramaControls {
265 | constructor(webgl, options) {
266 | this.webgl = webgl
267 | this.options = options
268 |
269 | const controlsDiv = document.getElementById('controls')
270 |
271 | this.controls = new ControlGroup(this, {
272 | name: 'options',
273 | optionsObject: this.options,
274 | depth: 1,
275 | parent: controlsDiv,
276 | })
277 | }
278 |
279 | propertyChanged() {
280 | if (this.webgl.scene.diorama !== undefined) {
281 | this.webgl.scene.remove(this.webgl.scene.diorama)
282 | this.webgl.scene.diorama = undefined
283 | }
284 |
285 | const dioramaOptions = this.controls.getValue()
286 | sessionStorage.setItem('dioramaOptions', JSON.stringify(dioramaOptions))
287 |
288 | this.webgl.scene.diorama = new Diorama(this.webgl, { diorama: dioramaOptions })
289 | this.webgl.scene.add(this.webgl.scene.diorama)
290 | }
291 | }
292 |
293 | function toDisplayString(name) {
294 | return isEmpty(name) ? name : name[0].toUpperCase() + name.slice(1)
295 | }
296 |
--------------------------------------------------------------------------------
/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 /\.(exr|hdri?)$/i.test(ext):
97 | return 'envmap'
98 | case /\.json$/i.test(ext):
99 | return 'json'
100 | case /\.svg$/i.test(ext):
101 | return 'svg'
102 | case /\.(jpe?g|png|gif|bmp|tga|tif)$/i.test(ext):
103 | return 'image'
104 | case /\.(wav|mp3)$/i.test(ext):
105 | return 'audio'
106 | case /\.(mp4|webm|ogg|ogv)$/i.test(ext):
107 | return 'video'
108 | default:
109 | throw new Error(`Could not load ${url}, unknown file extension!`)
110 | }
111 | }
112 |
113 | // Fetch a loaded asset by URL
114 | get = (key) => {
115 | if (!key) throw new TypeError('Must specify an URL for AssetManager.get()')
116 |
117 | return this.#loaded[key]
118 | }
119 |
120 | // Fetch a loaded MeshStandardMaterial object
121 | getStandardMaterial = (keys) => {
122 | return mapValues(keys, (key) => this.get(key))
123 | }
124 |
125 | // Loads a single asset on demand.
126 | async loadSingle({ renderer, ...item }) {
127 | // renderer is used to load textures and env maps,
128 | // but require it always since it is an extensible pattern
129 | if (!renderer) {
130 | throw new Error('You must provide a renderer to the loadSingle function.')
131 | }
132 |
133 | try {
134 | const itemLoadingStart = performance.now()
135 |
136 | const key = item.key || item.url
137 | if (!(key in this.#loaded)) {
138 | this.#loaded[key] = await this._loadItem({ renderer, ...item })
139 | }
140 |
141 | if (window.DEBUG) {
142 | console.log(
143 | `📦 Loaded single asset %c${item.url}%c in ${prettyMs(
144 | performance.now() - itemLoadingStart
145 | )}`,
146 | 'color:blue',
147 | 'color:black'
148 | )
149 | }
150 |
151 | return key
152 | } catch (err) {
153 | console.error(`📦 Asset ${item.url} was not loaded:\n${err}`)
154 | }
155 | }
156 |
157 | // Loads all queued assets
158 | async load({ renderer }) {
159 | // renderer is used to load textures and env maps,
160 | // but require it always since it is an extensible pattern
161 | if (!renderer) {
162 | throw new Error('You must provide a renderer to the load function.')
163 | }
164 |
165 | const queue = this.#queue.slice()
166 | this.#queue.length = 0 // clear queue
167 |
168 | const total = queue.length
169 | if (total === 0) {
170 | // resolve first this functions and then call the progress listeners
171 | setTimeout(() => this.#onProgressListeners.forEach((fn) => fn(1)), 0)
172 | return
173 | }
174 |
175 | const loadingStart = performance.now()
176 |
177 | await pMap(
178 | queue,
179 | async (item, i) => {
180 | try {
181 | const itemLoadingStart = performance.now()
182 |
183 | const key = item.key || item.url
184 | if (!(key in this.#loaded)) {
185 | this.#loaded[key] = await this._loadItem({ renderer, ...item })
186 | }
187 |
188 | if (window.DEBUG) {
189 | this.log(
190 | `Loaded %c${item.url}%c in ${prettyMs(performance.now() - itemLoadingStart)}`,
191 | 'color:blue',
192 | 'color:black'
193 | )
194 | }
195 | } catch (err) {
196 | this.logError(`Asset ${item.url} was not loaded:\n${err}`)
197 | }
198 |
199 | const percent = (i + 1) / total
200 | this.#onProgressListeners.forEach((fn) => fn(percent))
201 | },
202 | { concurrency: this.#asyncConcurrency }
203 | )
204 |
205 | if (window.DEBUG) {
206 | const errors = this.#logs.filter((log) => log.type === 'error')
207 |
208 | if (errors.length === 0) {
209 | this.groupLog(`📦 Assets loaded in ${prettyMs(performance.now() - loadingStart)} ⏱`)
210 | } else {
211 | this.groupLog(
212 | `📦 %c Could not load ${errors.length} asset${errors.length > 1 ? 's' : ''} `,
213 | 'color:white;background:red;'
214 | )
215 | }
216 | }
217 | }
218 |
219 | // Loads a single asset.
220 | _loadItem({ url, type, renderer, ...options }) {
221 | switch (type) {
222 | case 'gltf':
223 | return loadGLTF(url, options)
224 | case 'json':
225 | return fetch(url).then((response) => response.json())
226 | case 'envmap':
227 | case 'envMap':
228 | case 'env-map':
229 | return loadEnvMap(url, { renderer, ...options })
230 | case 'svg':
231 | case 'image':
232 | return loadImage(url, { crossorigin: 'anonymous' })
233 | case 'texture':
234 | return loadTexture(url, { renderer, ...options })
235 | case 'audio':
236 | // You might not want to load big audio files and
237 | // store them in memory, that might be inefficient.
238 | // Rather load them outside of the queue
239 | return fetch(url).then((response) => response.arrayBuffer())
240 | case 'video':
241 | // You might not want to load big video files and
242 | // store them in memory, that might be inefficient.
243 | // Rather load them outside of the queue
244 | return fetch(url).then((response) => response.blob())
245 | default:
246 | throw new Error(`Could not load ${url}, the type ${type} is unknown!`)
247 | }
248 | }
249 |
250 | log(...text) {
251 | this.#logs.push({ type: 'log', text })
252 | }
253 |
254 | logError(...text) {
255 | this.#logs.push({ type: 'error', text })
256 | }
257 |
258 | groupLog(...text) {
259 | console.groupCollapsed(...text)
260 | this.#logs.forEach((log) => {
261 | console[log.type](...log.text)
262 | })
263 | console.groupEnd()
264 |
265 | this.#logs.length = 0 // clear logs
266 | }
267 | }
268 |
269 | // asset manager is a singleton, you can require it from
270 | // different files and use the same instance.
271 | // A plain js object would have worked just fine,
272 | // fucking java patterns
273 | export default new AssetManager()
274 |
--------------------------------------------------------------------------------
/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 | HalfFloatType,
5 | PMREMGenerator,
6 | SRGBColorSpace,
7 | TextureLoader,
8 | UnsignedByteType,
9 | } from 'three'
10 | // TODO lazy load these, or put them in different files
11 | import { RGBELoader } from 'three/addons/loaders/RGBELoader.js'
12 | import { EXRLoader } from 'three/addons/loaders/EXRLoader.js'
13 | import { HDRCubeTextureLoader } from 'three/addons/loaders/HDRCubeTextureLoader.js'
14 |
15 | export default function loadEnvMap(url, { renderer, ...options }) {
16 | if (!renderer) {
17 | throw new Error(`Env map requires renderer to passed in the options for ${url}!`)
18 | }
19 |
20 | const isEquirectangular = !Array.isArray(url)
21 |
22 | let loader
23 | if (isEquirectangular) {
24 | const extension = url.slice(url.lastIndexOf('.') + 1)
25 |
26 | switch (extension) {
27 | case 'hdr': {
28 | loader = new RGBELoader().setDataType(HalfFloatType).loadAsync(url)
29 | break
30 | }
31 | case 'exr': {
32 | loader = new EXRLoader().setDataType(UnsignedByteType).loadAsync(url)
33 | break
34 | }
35 | case 'png':
36 | case 'jpg': {
37 | loader = new TextureLoader().loadAsync(url).then((texture) => {
38 | if (renderer.outputColorSpace === SRGBColorSpace && options.gamma) {
39 | texture.colorSpace = SRGBColorSpace
40 | }
41 | return texture
42 | })
43 | break
44 | }
45 | default: {
46 | throw new Error(`Extension ${extension} not supported`)
47 | }
48 | }
49 |
50 | loader = loader.then((texture) => {
51 | if (options.pmrem) {
52 | return equirectangularToPMREMCube(texture, renderer)
53 | } else {
54 | return equirectangularToCube(texture)
55 | }
56 | })
57 | } else {
58 | const extension = url[0].slice(url.lastIndexOf('.') + 1)
59 |
60 | switch (extension) {
61 | case 'hdr': {
62 | loader = new HDRCubeTextureLoader().setDataType(UnsignedByteType).loadAsync(url)
63 | break
64 | }
65 | case 'png':
66 | case 'jpg': {
67 | loader = new CubeTextureLoader().loadAsync(url).then((texture) => {
68 | if (renderer.outputColorSpace === SRGBColorSpace && options.gamma) {
69 | texture.colorSpace = SRGBColorSpace
70 | }
71 | return texture
72 | })
73 | break
74 | }
75 | default: {
76 | throw new Error(`Extension ${extension} not supported`)
77 | }
78 | }
79 |
80 | loader = loader.then((texture) => {
81 | if (options.pmrem) {
82 | return cubeToPMREMCube(texture, renderer)
83 | } else {
84 | return texture
85 | }
86 | })
87 | }
88 |
89 | // apply eventual texture options, such as wrap, repeat...
90 | const textureOptions = Object.keys(options).filter(
91 | (option) => !['pmrem', 'linear'].includes(option)
92 | )
93 | textureOptions.forEach((option) => {
94 | loader = loader.then((texture) => {
95 | texture[option] = options[option]
96 | return texture
97 | })
98 | })
99 |
100 | return loader
101 | }
102 |
103 | // prefilter the equirectangular environment map for irradiance
104 | function equirectangularToPMREMCube(texture, renderer) {
105 | const pmremGenerator = new PMREMGenerator(renderer)
106 | pmremGenerator.compileEquirectangularShader()
107 |
108 | const cubeRenderTarget = pmremGenerator.fromEquirectangular(texture)
109 |
110 | pmremGenerator.dispose() // dispose PMREMGenerator
111 | texture.dispose() // dispose original texture
112 | texture.image.data = null // remove image reference
113 |
114 | return cubeRenderTarget.texture
115 | }
116 |
117 | // prefilter the cubemap environment map for irradiance
118 | function cubeToPMREMCube(texture, renderer) {
119 | const pmremGenerator = new PMREMGenerator(renderer)
120 | pmremGenerator.compileCubemapShader()
121 |
122 | const cubeRenderTarget = pmremGenerator.fromCubemap(texture)
123 |
124 | pmremGenerator.dispose() // dispose PMREMGenerator
125 | texture.dispose() // dispose original texture
126 | texture.image.data = null // remove image reference
127 |
128 | return cubeRenderTarget.texture
129 | }
130 |
131 | // transform an equirectangular texture to a cubetexture that
132 | // can be used as an envmap or scene background
133 | function equirectangularToCube(texture) {
134 | texture.mapping = EquirectangularReflectionMapping
135 | return texture
136 | }
137 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/utils/meshUtils.js:
--------------------------------------------------------------------------------
1 | import * as THREE from 'three'
2 |
3 | export default function generateSideMeshes(
4 | group,
5 | planeGeometry,
6 | material,
7 | xSegments,
8 | zSegments,
9 | lowestPoint
10 | ) {
11 | const topVertices = planeGeometry.getAttribute('position').array
12 | planeGeometry.computeBoundingBox()
13 | let bounds = new THREE.Vector3(0, 0, 0)
14 | planeGeometry.boundingBox.getSize(bounds)
15 | let newHeight = 0
16 |
17 | // +X, +Z, -X, -Z
18 | for (let side = 0; side < 4; side++) {
19 | const xSide = side % 2 == 1
20 | const sideGeometry = new THREE.PlaneGeometry(
21 | xSide ? bounds.x : bounds.z,
22 | xSide ? bounds.z : bounds.x,
23 | xSide ? xSegments : zSegments,
24 | 1
25 | )
26 | if (xSide) {
27 | sideGeometry.translate(0, 0, bounds.z / 2)
28 | } else {
29 | sideGeometry.rotateY(-Math.PI / 2)
30 | sideGeometry.translate(-bounds.x / 2, 0, 0)
31 | }
32 |
33 | const vertices = sideGeometry.getAttribute('position').array,
34 | stride = xSide ? xSegments : zSegments
35 | for (let i = 0, l = vertices.length / 3 / 2; i < l; i++) {
36 | // Top row, match plane geometry
37 | if (xSide) {
38 | if (side < 2) {
39 | newHeight = topVertices[(i + (xSegments + 1) * zSegments) * 3 + 1]
40 | } else {
41 | newHeight = topVertices[i * 3 + 1]
42 | }
43 | } else {
44 | if (side < 2) {
45 | newHeight = topVertices[i * (xSegments + 1) * 3 + 1]
46 | } else {
47 | newHeight = topVertices[(i * (xSegments + 1) + xSegments) * 3 + 1]
48 | }
49 | }
50 | vertices[i * 3 + 1] = newHeight
51 |
52 | // Bottom row, lowestPoint
53 | vertices[(i + stride + 1) * 3 + 1] = lowestPoint
54 | }
55 |
56 | const mesh = new THREE.Mesh(sideGeometry, material)
57 | if (side > 1) {
58 | if (xSide) {
59 | mesh.scale.z = -1
60 | } else {
61 | mesh.scale.x = -1
62 | }
63 | }
64 |
65 | mesh.castShadow = true
66 | mesh.receiveShadow = true
67 |
68 | group.add(mesh)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const { execSync } = require('child_process')
3 | const merge = require('webpack-merge')
4 | const TerserJsPlugin = require('terser-webpack-plugin')
5 | const HtmlWebpackPlugin = require('html-webpack-plugin')
6 | const openBrowser = require('react-dev-utils/openBrowser')
7 | const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin')
8 | const { prepareUrls } = require('react-dev-utils/WebpackDevServerUtils')
9 | const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages')
10 | const prettyMs = require('pretty-ms')
11 | const EventHooksPlugin = require('event-hooks-webpack-plugin')
12 | const chalk = require('chalk')
13 | const indentString = require('indent-string')
14 | const _ = require('lodash')
15 |
16 | const PROTOCOL = 'http'
17 | const HOST = '0.0.0.0'
18 | // check if the port is already in use, if so use the next port
19 | const DEFAULT_PORT = '8080'
20 | const PORT = execSync(`detect-port ${DEFAULT_PORT}`).toString().replace(/\D/g, '')
21 | const urls = prepareUrls(PROTOCOL, HOST, PORT)
22 |
23 | module.exports = merge.smart(
24 | {
25 | module: {
26 | rules: [
27 | {
28 | test: /\.js$/,
29 | exclude: /node_modules/,
30 | loader: 'babel-loader',
31 | options: {
32 | cacheDirectory: true,
33 | },
34 | },
35 | {
36 | test: /\.(glsl|frag|vert)$/,
37 | use: ['raw-loader', 'glslify-loader'],
38 | },
39 | ],
40 | },
41 | plugins: [
42 | // Generates an `index.html` file with the