├── .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