├── .github ├── FUNDING.yml └── workflows │ └── gh-pages.yml ├── .gitignore ├── .gitmodules ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── example ├── package-lock.json ├── package.json ├── public │ ├── CNAME │ ├── index.html │ ├── models │ │ └── bot.glb │ ├── screenshot.png │ ├── sounds │ │ ├── ambient.ogg │ │ ├── blast.ogg │ │ ├── rain.ogg │ │ └── shot.ogg │ └── textures │ │ ├── atlas1.png │ │ └── atlas2.png ├── rollup.config.js └── src │ ├── app.css │ ├── app.js │ ├── core │ ├── actors.js │ ├── assets.js │ ├── input.js │ ├── postprocessing.js │ ├── projectiles.js │ ├── sfx.js │ └── toolbar.js │ ├── gameplay.js │ └── renderables │ ├── actor.js │ ├── dome.js │ ├── explosion.js │ ├── projectile.js │ └── rain.js ├── package-lock.json ├── package.json ├── rollup.config.js └── src ├── chunk.js ├── chunkmaterial.js ├── compile.sh ├── module.js ├── volume.c ├── volume.js ├── volume.wasm ├── world.js ├── worldgen.c ├── worldgen.js ├── worldgen.wasm └── worldgen.worker.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: danielesteban 2 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: gh-pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Dependencies 14 | run: cd example && npm ci 15 | - name: Build 16 | run: cd example && NODE_ENV=production npm run build 17 | - name: Deploy 18 | uses: peaceiris/actions-gh-pages@v3 19 | with: 20 | github_token: ${{ secrets.GITHUB_TOKEN }} 21 | publish_dir: 'example/dist' 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "vendor/wasi-libc"] 2 | path = vendor/wasi-libc 3 | url = https://github.com/WebAssembly/wasi-libc.git 4 | [submodule "vendor/AStar"] 5 | path = vendor/AStar 6 | url = https://github.com/BigZaphod/AStar.git 7 | [submodule "vendor/FastNoiseLite"] 8 | path = vendor/FastNoiseLite 9 | url = https://github.com/Auburn/FastNoiseLite 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "files.eol": "\n", 4 | "search.exclude": { 5 | "**/.vscode": true, 6 | "**/dist": true, 7 | "**/node_modules": true, 8 | }, 9 | "files.exclude": { 10 | "**/.vscode": true, 11 | "**/dist": true, 12 | "**/node_modules": true, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Daniel Esteban Nombela. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [cubitos](https://github.com/danielesteban/cubitos/) 2 | [![npm-version](https://img.shields.io/npm/v/cubitos.svg)](https://www.npmjs.com/package/cubitos) 3 | == 4 | 5 | [![screenshot](example/public/screenshot.png)](https://github.com/danielesteban/cubitos) 6 | 7 | ### Examples 8 | 9 | * World: 10 | * Demo: [cubitos.gatunes.com](https://cubitos.gatunes.com) 11 | * Source: [example/src/gameplay.js](example/src/gameplay.js) 12 | 13 | * Random walkers: 14 | * Demo: [cubitos-walkers.glitch.me](https://cubitos-walkers.glitch.me) 15 | * Source: [glitch.com/edit/#!/cubitos-walkers](https://glitch.com/edit/#!/cubitos-walkers) 16 | * Demo (react-three-fiber): [bp2ljx.csb.app](https://bp2ljx.csb.app) 17 | * Source (react-three-fiber): [codesandbox.io/s/cubitos-bp2ljx](https://codesandbox.io/s/cubitos-bp2ljx) 18 | 19 | ### Installation 20 | 21 | ```bash 22 | npm install cubitos 23 | ``` 24 | 25 | ### Basic usage 26 | 27 | ```js 28 | import { ChunkMaterial, Volume, World } from 'cubitos'; 29 | import { PerspectiveCamera, Scene, sRGBEncoding, WebGLRenderer } from 'three'; 30 | 31 | const aspect = window.innerWidth / window.innerHeight; 32 | const camera = new PerspectiveCamera(70, aspect, 0.1, 1000); 33 | const renderer = new WebGLRenderer({ antialias: true }); 34 | const scene = new Scene(); 35 | camera.position.set(64, 64, 64); 36 | renderer.outputEncoding = sRGBEncoding; 37 | renderer.setSize(window.innerWidth, window.innerHeight); 38 | renderer.setAnimationLoop(() => renderer.render(scene, camera)); 39 | 40 | const volume = new Volume({ 41 | width: 128, 42 | height: 128, 43 | depth: 128, 44 | onLoad: () => { 45 | const world = new World({ 46 | material: new ChunkMaterial({ light: false }), 47 | volume, 48 | }); 49 | world.update({ x: 64, y: 64, z: 60 }, 2, 1); 50 | scene.add(world); 51 | }, 52 | }); 53 | ``` 54 | 55 | ### ChunkMaterial 56 | 57 | ```js 58 | new ChunkMaterial({ 59 | // A DataArrayTexture or Texture to use as the atlas 60 | atlas: new Texture(), 61 | // Light === max( 62 | // ambientColor, 63 | // light1Color * light1 64 | // + light2Color * light2 65 | // + light3Color * light3 66 | // + sunlightColor * sunlight 67 | // ); 68 | ambientColor = new Color(0, 0, 0), 69 | light1Color = new Color(1, 1, 1), 70 | light2Color = new Color(1, 1, 1), 71 | light3Color = new Color(1, 1, 1), 72 | sunlightColor = new Color(1, 1, 1), 73 | // Enable/Disable lighting (default: true) 74 | light: true, 75 | }); 76 | ``` 77 | 78 | ### Volume 79 | 80 | ```js 81 | const volume = new Volume({ 82 | // Volume width 83 | width: 128, 84 | // Volume height 85 | height: 128, 86 | // Volume depth 87 | depth: 128, 88 | // Render chunks size (default: 32) 89 | chunkSize: 32, 90 | // Maximum light distance (default: 24) 91 | maxLight: 24, 92 | // Will be called to determine if a voxel emits light on a channel (optional) 93 | emission: (value) => (0), 94 | // Will be called by the mesher to determine a texture from the atlas (optional) 95 | mapping: (face, value, x, y, z) => (value - 1), 96 | // Will be called when the volume has allocated the memory and is ready. (optional) 97 | onLoad: () => { 98 | // Generates terrain in a worker 99 | Worldgen({ 100 | // Generate grass 101 | grass: true, 102 | // Generate lights 103 | lights: true, 104 | // Noise frequency (default: 0.01) 105 | frequency: 0.01, 106 | // Noise gain (default: 0.5) 107 | gain: 0.5, 108 | // Noise lacunarity (default: 2) 109 | lacunarity: 2, 110 | // Noise octaves (default: 3) 111 | octaves: 3, 112 | // Noise seed (default: random) 113 | seed = 1337, 114 | // Volume instance 115 | volume, 116 | }) 117 | .then(() => { 118 | // Runs the initial light propagation 119 | volume.propagate(); 120 | }) 121 | }, 122 | // Will be called if there's an error loading the volume. (optional) 123 | onError: () => {}, 124 | }); 125 | 126 | // Returns the closest ground 127 | // to a position where the height fits 128 | const ground = volume.ground( 129 | // Position 130 | new Vector3(0, 0, 0), 131 | // Height (default: 1) 132 | 4 133 | ); 134 | 135 | // Returns a list of positions 136 | // to move an actor from A to B 137 | const path = volume.pathfind({ 138 | // Starting position 139 | from: new Vector3(0, 0, 0), 140 | // Destination 141 | to: new Vector3(0, 10, 0), 142 | // Minimum height it can go through (default: 1) 143 | height: 4, 144 | // Maximum nodes it can visit before it bails (default: 4096) 145 | maxVisited: 2048, 146 | // Minimum Y it can step at (default: 0) 147 | minY: 0, 148 | // Maximum Y it can step at (default: Infinity) 149 | maxY: Infinity, 150 | }); 151 | ``` 152 | 153 | ### World 154 | 155 | ```js 156 | const world = new World({ 157 | // ChunkMaterial (or compatible material) 158 | material, 159 | // Volume instance 160 | volume, 161 | }); 162 | 163 | world.update( 164 | // Position 165 | new Vector3(0, 0, 0), 166 | // Radius 167 | 1, 168 | // Value 169 | 1 170 | ); 171 | ``` 172 | 173 | ### Modifying the WASM programs 174 | 175 | To build the C code, you'll need to install LLVM: 176 | 177 | * Win: [https://chocolatey.org/packages/llvm](https://chocolatey.org/packages/llvm) 178 | * Mac: [https://formulae.brew.sh/formula/llvm](https://formulae.brew.sh/formula/llvm) 179 | * Linux: [https://releases.llvm.org/download.html](https://releases.llvm.org/download.html) 180 | 181 | On the first build, it will complain about a missing file that you can get here: 182 | [libclang_rt.builtins-wasm32-wasi-16.0.tar.gz](https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-16/libclang_rt.builtins-wasm32-wasi-16.0.tar.gz). Just put it on the same path that the error specifies and you should be good to go. 183 | 184 | To build [wasi-libc](https://github.com/WebAssembly/wasi-libc), you'll need to install [GNU make](https://chocolatey.org/packages/make) 185 | 186 | ```bash 187 | # clone this repo and it's submodules 188 | git clone --recursive https://github.com/danielesteban/cubitos.git 189 | cd cubitos 190 | # build wasi-libc 191 | cd vendor/wasi-libc && make -j8 && cd ../.. 192 | # install dev dependencies 193 | npm install 194 | # start the dev environment: 195 | npm start 196 | # open http://localhost:8080/ in your browser 197 | ``` 198 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cubitos-example", 3 | "author": "Daniel Esteban Nombela", 4 | "license": "MIT", 5 | "scripts": { 6 | "build": "rollup -c rollup.config.js", 7 | "start": "npm run build -- -w" 8 | }, 9 | "dependencies": { 10 | "cubitos": "^0.0.22", 11 | "three": "^0.142.0" 12 | }, 13 | "devDependencies": { 14 | "@rollup/plugin-alias": "^3.1.9", 15 | "@rollup/plugin-node-resolve": "^13.3.0", 16 | "rollup": "^2.77.0", 17 | "rollup-plugin-copy": "^3.4.0", 18 | "rollup-plugin-livereload": "^2.0.5", 19 | "rollup-plugin-postcss": "^4.0.2", 20 | "rollup-plugin-serve": "^2.0.0", 21 | "rollup-plugin-terser": "^7.0.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example/public/CNAME: -------------------------------------------------------------------------------- 1 | cubitos.gatunes.com 2 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | cubitos 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
Loading...
19 |
20 |
21 | cubitos - controls - ··· 22 |
23 |
24 |
25 | dani@gatunes © 2022 26 |
27 |
28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 |
46 | 47 | 56 | 60 | 62 | 63 |
64 |
65 | 66 | ♥ Become a sponsor 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /example/public/models/bot.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielesteban/cubitos/581a0056f65b75fa944c2c52900bf3da35c255d3/example/public/models/bot.glb -------------------------------------------------------------------------------- /example/public/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielesteban/cubitos/581a0056f65b75fa944c2c52900bf3da35c255d3/example/public/screenshot.png -------------------------------------------------------------------------------- /example/public/sounds/ambient.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielesteban/cubitos/581a0056f65b75fa944c2c52900bf3da35c255d3/example/public/sounds/ambient.ogg -------------------------------------------------------------------------------- /example/public/sounds/blast.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielesteban/cubitos/581a0056f65b75fa944c2c52900bf3da35c255d3/example/public/sounds/blast.ogg -------------------------------------------------------------------------------- /example/public/sounds/rain.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielesteban/cubitos/581a0056f65b75fa944c2c52900bf3da35c255d3/example/public/sounds/rain.ogg -------------------------------------------------------------------------------- /example/public/sounds/shot.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielesteban/cubitos/581a0056f65b75fa944c2c52900bf3da35c255d3/example/public/sounds/shot.ogg -------------------------------------------------------------------------------- /example/public/textures/atlas1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielesteban/cubitos/581a0056f65b75fa944c2c52900bf3da35c255d3/example/public/textures/atlas1.png -------------------------------------------------------------------------------- /example/public/textures/atlas2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielesteban/cubitos/581a0056f65b75fa944c2c52900bf3da35c255d3/example/public/textures/atlas2.png -------------------------------------------------------------------------------- /example/rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import alias from '@rollup/plugin-alias'; 3 | import copy from 'rollup-plugin-copy'; 4 | import livereload from 'rollup-plugin-livereload'; 5 | import postcss from 'rollup-plugin-postcss'; 6 | import resolve from '@rollup/plugin-node-resolve'; 7 | import serve from 'rollup-plugin-serve'; 8 | import { terser } from 'rollup-plugin-terser'; 9 | 10 | const outputPath = path.resolve(__dirname, 'dist'); 11 | const production = !process.env.ROLLUP_WATCH; 12 | 13 | export default { 14 | input: path.join(__dirname, 'src', 'app.js'), 15 | output: { 16 | dir: outputPath, 17 | format: 'iife', 18 | }, 19 | plugins: [ 20 | ...(!production ? [ 21 | alias({ 22 | entries: { 'cubitos': path.join(__dirname, '..', 'dist') }, 23 | }), 24 | ] : []), 25 | copy({ 26 | targets: [{ src: 'public/*', dest: 'dist' }], 27 | }), 28 | resolve({ 29 | browser: true, 30 | moduleDirectories: [path.join(__dirname, 'node_modules')], 31 | }), 32 | postcss({ 33 | extract: 'app.css', 34 | minimize: production, 35 | }), 36 | ...(production ? [ 37 | terser({ format: { comments: false } }), 38 | ] : [ 39 | serve({ 40 | contentBase: outputPath, 41 | port: 8080, 42 | }), 43 | livereload({ 44 | watch: outputPath, 45 | delay: 100, 46 | }), 47 | ]), 48 | ], 49 | watch: { clearScreen: false }, 50 | }; 51 | -------------------------------------------------------------------------------- /example/src/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-size: 16px; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | background: #000; 8 | color: #fff; 9 | cursor: default; 10 | user-select: none; 11 | overflow: hidden; 12 | font-family: 'VT323', monospace; 13 | font-size: 1rem; 14 | line-height: 1.125rem; 15 | } 16 | 17 | canvas { 18 | vertical-align: middle; 19 | } 20 | 21 | #debug { 22 | user-select: all; 23 | } 24 | 25 | #fps { 26 | display: inline-block; 27 | width: 32px; 28 | text-align: center; 29 | } 30 | 31 | #info { 32 | position: absolute; 33 | bottom: 1rem; 34 | right: 1rem; 35 | text-align: right; 36 | color: #fff; 37 | text-shadow: 0 0 4px rgba(0, 0, 0, .5); 38 | opacity: 0.8; 39 | } 40 | 41 | #info a { 42 | color: inherit; 43 | cursor: pointer; 44 | outline: none; 45 | text-decoration: underline; 46 | } 47 | 48 | #loading { 49 | display: none; 50 | position: absolute; 51 | top: 50%; 52 | left: 50%; 53 | transform: translate(-50%, -50%); 54 | text-align: center; 55 | } 56 | 57 | #loading.enabled { 58 | display: block; 59 | } 60 | 61 | #options { 62 | position: absolute; 63 | bottom: 1rem; 64 | left: 1rem; 65 | opacity: 0.8; 66 | display: flex; 67 | gap: 0.5rem; 68 | } 69 | 70 | #light > svg, #rain > svg { 71 | box-shadow: 0 0 4px rgba(0, 0, 0, .5); 72 | padding: 0.5rem 1rem; 73 | width: 24px; 74 | height: 24px; 75 | background-color: #000; 76 | cursor: pointer; 77 | transition: background-color ease-out .3s; 78 | } 79 | 80 | #light { 81 | display: flex; 82 | } 83 | 84 | #light > svg:nth-child(1) { 85 | border-radius: 16px 0 0 16px; 86 | padding-right: 0.75rem; 87 | } 88 | 89 | #light > svg:nth-child(2) { 90 | border-radius: 0 16px 16px 0; 91 | padding-left: 0.75rem; 92 | } 93 | 94 | #light.day > svg:nth-child(1) { 95 | background: #aa0; 96 | cursor: default; 97 | } 98 | 99 | #light.night > svg:nth-child(2) { 100 | background: #00a; 101 | cursor: default; 102 | } 103 | 104 | #rain > svg { 105 | border-radius: 16px; 106 | } 107 | 108 | #rain.enabled > svg { 109 | background-color: #55a; 110 | } 111 | 112 | #toolbar { 113 | position: absolute; 114 | bottom: 1rem; 115 | left: 50%; 116 | transform: translate(-50%, 0); 117 | opacity: 0.8; 118 | display: flex; 119 | gap: 0.25rem; 120 | } 121 | 122 | #toolbar > button { 123 | display: flex; 124 | flex-direction: column; 125 | align-items: center; 126 | justify-content: center; 127 | gap: 0.25rem; 128 | padding: 0.25rem; 129 | border: 0; 130 | outline: none; 131 | font-family: inherit; 132 | font-weight: inherit; 133 | box-shadow: 0 0 4px rgba(0, 0, 0, .5); 134 | width: 42px; 135 | height: 42px; 136 | border-radius: 4px; 137 | background-color: #000; 138 | color: #fff; 139 | cursor: pointer; 140 | transition: background-color ease-out .3s; 141 | } 142 | 143 | #toolbar > button.enabled { 144 | background-color: #5a5; 145 | cursor: default; 146 | } 147 | 148 | #renderer { 149 | position: relative; 150 | width: 100vw; 151 | height: 100vh; 152 | } 153 | 154 | #ribbon { 155 | width: 12.1em; 156 | height: 12.1em; 157 | position: absolute; 158 | overflow: hidden; 159 | top: 0; 160 | right: 0; 161 | pointer-events: none; 162 | font-size: 13px; 163 | text-decoration: none; 164 | text-indent: -999999px; 165 | } 166 | 167 | #ribbon:before, #ribbon:after { 168 | position: absolute; 169 | display: block; 170 | width: 15.38em; 171 | height: 1.54em; 172 | top: 3.23em; 173 | right: -3.23em; 174 | box-sizing: content-box; 175 | transform: rotate(45deg); 176 | } 177 | 178 | #ribbon:before { 179 | content: ""; 180 | padding: .38em 0; 181 | background-color: #393; 182 | background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.15)); 183 | box-shadow: 0 .15em .23em 0 rgba(0, 0, 0, 0.5); 184 | pointer-events: auto; 185 | } 186 | 187 | #ribbon:after { 188 | content: attr(data-ribbon); 189 | color: #fff; 190 | font: 700 1em monospace; 191 | line-height: 1.54em; 192 | text-decoration: none; 193 | text-shadow: 0 -.08em rgba(0, 0, 0, 0.5); 194 | text-align: center; 195 | text-indent: 0; 196 | padding: .15em 0; 197 | margin: .15em 0; 198 | border-width: .08em 0; 199 | border-style: dotted; 200 | border-color: #fff; 201 | border-color: rgba(255, 255, 255, 0.7); 202 | } 203 | 204 | body.pointerlock #credits, body.pointerlock #debug, body.pointerlock #light, body.pointerlock #rain, body.pointerlock #ribbon, body.pointerlock #toolbar { 205 | display: none; 206 | } 207 | 208 | @keyframes fade { 209 | from { 210 | backdrop-filter: blur(0px); 211 | background: rgba(0, 0, 0, 0); 212 | } 213 | to { 214 | backdrop-filter: blur(8px); 215 | background: rgba(0, 0, 0, 0.5); 216 | } 217 | } 218 | 219 | .dialog { 220 | animation: fade 0.25s forwards; 221 | position: fixed; 222 | top: 0; 223 | bottom: 0; 224 | left: 0; 225 | right: 0; 226 | z-index: 10001; 227 | } 228 | 229 | .dialog > div { 230 | position: absolute; 231 | top: 50%; 232 | left: 50%; 233 | transform: translate(-50%, -50%); 234 | background: #111; 235 | color: #fff; 236 | border-radius: 8px; 237 | display: flex; 238 | align-items: center; 239 | flex-direction: column; 240 | padding: 3rem 4rem; 241 | gap: 1rem; 242 | font-size: 1.5rem; 243 | } 244 | 245 | .dialog h1 { 246 | margin: 0; 247 | padding: 1rem 0 2rem; 248 | font-size: 3rem; 249 | } 250 | 251 | .dialog.controls { 252 | display: none; 253 | } 254 | 255 | .dialog.controls.enabled { 256 | display: block; 257 | } 258 | 259 | .dialog.controls > div { 260 | align-items: flex-start; 261 | flex-direction: row; 262 | white-space: nowrap; 263 | gap: 4rem; 264 | } 265 | 266 | .dialog.controls > div > div { 267 | display: flex; 268 | flex-direction: column; 269 | gap: 1rem; 270 | } 271 | 272 | .dialog.controls > div > div > div { 273 | display: flex; 274 | } 275 | 276 | .dialog.controls > div > div > div > div:first-child { 277 | width: 10rem; 278 | } 279 | -------------------------------------------------------------------------------- /example/src/app.js: -------------------------------------------------------------------------------- 1 | import { 2 | Clock, 3 | PerspectiveCamera, 4 | sRGBEncoding, 5 | WebGLRenderer, 6 | } from 'three'; 7 | import PostProcessing from './core/postprocessing.js'; 8 | import Gameplay from './gameplay.js'; 9 | import './app.css'; 10 | 11 | const camera = new PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 12 | const clock = new Clock(); 13 | const fps = { 14 | dom: document.getElementById('fps'), 15 | count: 0, 16 | lastTick: clock.oldTime / 1000, 17 | }; 18 | const renderer = new WebGLRenderer({ 19 | antialias: true, 20 | powerPreference: 'high-performance', 21 | stencil: false, 22 | }); 23 | const postprocessing = new PostProcessing({ samples: 4 }); 24 | const scene = new Gameplay({ camera, clock, postprocessing, renderer }); 25 | renderer.outputEncoding = sRGBEncoding; 26 | renderer.setSize(window.innerWidth, window.innerHeight); 27 | renderer.setAnimationLoop(() => { 28 | const delta = Math.min(clock.getDelta(), 1); 29 | const time = clock.oldTime / 1000; 30 | scene.onAnimationTick(delta, time); 31 | postprocessing.render(renderer, scene, camera); 32 | fps.count++; 33 | if (time >= fps.lastTick + 1) { 34 | const count = Math.round(fps.count / (time - fps.lastTick)); 35 | if (fps.lastCount !== count) { 36 | fps.lastCount = count; 37 | fps.dom.innerText = `${count}fps`; 38 | } 39 | fps.lastTick = time; 40 | fps.count = 0; 41 | } 42 | }); 43 | document.getElementById('renderer').appendChild(renderer.domElement); 44 | 45 | window.addEventListener('resize', () => { 46 | renderer.setSize(window.innerWidth, window.innerHeight); 47 | postprocessing.onResize(window.innerWidth, window.innerHeight); 48 | camera.aspect = window.innerWidth / window.innerHeight; 49 | camera.updateProjectionMatrix(); 50 | }, false); 51 | document.addEventListener('visibilitychange', () => { 52 | const isVisible = document.visibilityState === 'visible'; 53 | if (isVisible) { 54 | clock.start(); 55 | fps.count = -1; 56 | fps.lastTick = clock.oldTime / 1000; 57 | } 58 | }, false); 59 | 60 | { 61 | const controls = document.createElement('div'); 62 | controls.classList.add('dialog', 'controls'); 63 | const toggleControls = () => controls.classList.toggle('enabled'); 64 | document.getElementById('controls').addEventListener('click', toggleControls, false); 65 | controls.addEventListener('click', toggleControls, false); 66 | const wrapper = document.createElement('div'); 67 | controls.appendChild(wrapper); 68 | document.body.appendChild(controls); 69 | [ 70 | [ 71 | "Mouse & Keyboard", 72 | [ 73 | ["Mouse", "Look"], 74 | ["W A S D", "Move"], 75 | ["Shift", "Run"], 76 | ["Left click", "Shoot"], 77 | ["Right click", "Swap atlas"], 78 | ["E", "Walk/Fly"], 79 | ["Wheel", "Set speed"], 80 | ], 81 | ], 82 | [ 83 | "Gamepad", 84 | [ 85 | ["Right stick", "Look"], 86 | ["Left stick", "Move (press to run)"], 87 | ["Right trigger", "Shoot"], 88 | ["Left trigger", "Swap atlas"], 89 | ["A", "Walk/Fly"], 90 | ], 91 | ] 92 | ].forEach(([name, maps]) => { 93 | const group = document.createElement('div'); 94 | const heading = document.createElement('h1'); 95 | heading.innerText = name; 96 | group.appendChild(heading); 97 | maps.forEach((map) => { 98 | const item = document.createElement('div'); 99 | map.forEach((map, i) => { 100 | const text = document.createElement('div'); 101 | text.innerText = `${map}${i === 0 ? ':' : ''}`; 102 | item.appendChild(text); 103 | }); 104 | group.appendChild(item); 105 | }); 106 | wrapper.appendChild(group); 107 | }); 108 | } 109 | 110 | { 111 | const GL = renderer.getContext(); 112 | const ext = GL.getExtension('WEBGL_debug_renderer_info'); 113 | if (ext) { 114 | document.getElementById('debug').innerText = GL.getParameter(ext.UNMASKED_RENDERER_WEBGL); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /example/src/core/actors.js: -------------------------------------------------------------------------------- 1 | import { Color, Group, Vector3 } from 'three'; 2 | import Actor from '../renderables/actor.js'; 3 | 4 | const _from = new Vector3(); 5 | const _to = new Vector3(); 6 | const _offset = new Vector3(); 7 | 8 | class Actors extends Group { 9 | constructor({ count, model, world }) { 10 | super(); 11 | this.matrixAutoUpdate = false; 12 | this.model = model; 13 | this.world = world; 14 | Actor.setupMaterial(); 15 | ['ambientColor', 'light1Color', 'light2Color', 'light3Color', 'sunlightColor'].forEach((uniform) => { 16 | Actor.material.uniforms[uniform].value = world.material.uniforms[uniform].value; 17 | }); 18 | for (let i = 0; i < count; i++) { 19 | this.spawn(); 20 | } 21 | } 22 | 23 | onAnimationTick(delta, frustum) { 24 | const { children, world } = this; 25 | children.forEach((actor) => { 26 | actor.onAnimationTick(delta, frustum); 27 | if (actor.path) { 28 | return; 29 | } 30 | if (actor.waiting > 0) { 31 | actor.waiting -= delta; 32 | return; 33 | } 34 | _from.copy(actor.position).divide(world.scale).floor(); 35 | const ground = world.volume.ground(_from, 4); 36 | if (ground !== -1 && ground !== _from.y) { 37 | _from.y = ground; 38 | actor.position.copy(_from); 39 | world.volume.obstacle(actor.obstacle, false, 4); 40 | world.volume.obstacle(actor.obstacle.copy(_from), true, 4); 41 | actor.position.x += 0.5; 42 | actor.position.z += 0.5; 43 | actor.position.multiply(world.scale); 44 | actor.setLight(actor.position); 45 | } 46 | _to.copy(_from).addScaledVector(_offset.set(Math.random() - 0.5, Math.random() - 0.25, Math.random() - 0.5), 32).floor(); 47 | _to.y = Math.min(_to.y, world.volume.height - 1); 48 | _to.y = world.volume.ground(_to, 4); 49 | if (_to.y <= 0) { 50 | actor.waiting = Math.random(); 51 | return; 52 | } 53 | const result = world.volume.pathfind({ 54 | from: _from, 55 | to: _to, 56 | maxVisited: 2048, 57 | height: 4, 58 | }); 59 | if (result.length <= 3) { 60 | actor.waiting = Math.random(); 61 | return; 62 | } 63 | actor.setPath( 64 | result, 65 | world.scale, 66 | () => { 67 | actor.waiting = 3 + Math.random() * 3; 68 | }, 69 | ); 70 | world.volume.obstacle(actor.obstacle, false, 4); 71 | world.volume.obstacle(actor.obstacle.copy(_to), true, 4); 72 | }); 73 | } 74 | 75 | light(position, target) { 76 | const { world } = this; 77 | _from.copy(position).divide(world.scale).floor(); 78 | _from.y += 2; 79 | const voxel = world.volume.voxel(_from); 80 | if (voxel === -1) { 81 | target.set(1, 0, 0, 0); 82 | } else if (world.volume.memory.voxels.view[voxel]) { 83 | target.set(0, 0, 0, 0); 84 | } else { 85 | target.fromArray(world.volume.memory.light.view, voxel * 4).divideScalar(world.volume.maxLight); 86 | } 87 | return target; 88 | } 89 | 90 | spawn() { 91 | const { model: { animations, model }, world } = this; 92 | const actor = new Actor({ 93 | animations, 94 | colors: { 95 | Joints: new Color(0.4, 0.4, 0.4), 96 | Surface: (new Color()).setHSL(Math.random(), 0.4 + Math.random() * 0.2, 0.5 + Math.random() * 0.2), 97 | }, 98 | light: this.light.bind(this), 99 | model: model(), 100 | }); 101 | actor.position 102 | .set( 103 | world.volume.width * 0.5 + (Math.random() - 0.5) * world.volume.width * 0.5, 104 | world.volume.height - 1, 105 | world.volume.depth * 0.5 + (Math.random() - 0.5) * world.volume.depth * 0.5 106 | ) 107 | .floor(); 108 | actor.position.y = world.volume.ground(actor.position, 4); 109 | actor.obstacle = actor.position.clone(); 110 | world.volume.obstacle(actor.obstacle, true, 4); 111 | actor.position.x += 0.5; 112 | actor.position.z += 0.5; 113 | actor.position.multiply(world.scale); 114 | actor.setLight(actor.position); 115 | this.add(actor); 116 | } 117 | } 118 | 119 | export default Actors; 120 | -------------------------------------------------------------------------------- /example/src/core/assets.js: -------------------------------------------------------------------------------- 1 | import { TextureLoader } from 'three'; 2 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; 3 | import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js'; 4 | 5 | const load = (loader, format = (a) => (a)) => { 6 | const cache = new Map(); 7 | const loading = new Map(); 8 | return (url) => new Promise((resolve, reject) => { 9 | if (cache.has(url)) { 10 | resolve(cache.get(url)); 11 | return; 12 | } 13 | if (loading.has(url)) { 14 | loading.get(url).push({ resolve, reject }); 15 | return; 16 | } 17 | const promises = [{ resolve, reject }]; 18 | loading.set(url, promises); 19 | loader.load(url, (loaded) => { 20 | loading.delete(url); 21 | const asset = format(loaded); 22 | cache.set(url, asset); 23 | promises.forEach(({ resolve }) => resolve(asset)); 24 | }, () => {}, (err) => ( 25 | promises.forEach(({ reject }) => reject(err)) 26 | )); 27 | }); 28 | }; 29 | 30 | export const loadModel = load( 31 | new GLTFLoader(), 32 | ({ animations, scene }) => ({ 33 | animations, 34 | model: () => cloneSkeleton(scene), 35 | }) 36 | ); 37 | 38 | export const loadTexture = load(new TextureLoader()); 39 | -------------------------------------------------------------------------------- /example/src/core/input.js: -------------------------------------------------------------------------------- 1 | import { Vector2 } from 'three'; 2 | 3 | class Input { 4 | constructor(target) { 5 | this.attachments = []; 6 | this.buttons = { 7 | primary: false, 8 | secondary: false, 9 | tertiary: false, 10 | interact: false, 11 | run: false, 12 | }; 13 | this.buttonState = { ...this.buttons }; 14 | this.gamepad = false; 15 | this.look = new Vector2(); 16 | this.movement = new Vector2(); 17 | this.mouse = new Vector2(); 18 | this.keyboard = new Vector2(); 19 | this.pointer = new Vector2(); 20 | this.speed = 4; 21 | this.target = target; 22 | this.onContextMenu = this.onContextMenu.bind(this); 23 | this.onGamepadDisconnected = this.onGamepadDisconnected.bind(this); 24 | this.onGamepadConnected = this.onGamepadConnected.bind(this); 25 | this.onKeyDown = this.onKeyDown.bind(this); 26 | this.onKeyUp = this.onKeyUp.bind(this); 27 | this.onMouseDown = this.onMouseDown.bind(this); 28 | this.onMouseUp = this.onMouseUp.bind(this); 29 | this.onMouseMove = this.onMouseMove.bind(this); 30 | this.onMouseWheel = this.onMouseWheel.bind(this); 31 | this.onPointerLock = this.onPointerLock.bind(this); 32 | target.addEventListener('contextmenu', this.onContextMenu, false); 33 | window.addEventListener('gamepaddisconnected', this.onGamepadDisconnected, false); 34 | window.addEventListener('gamepadconnected', this.onGamepadConnected, false); 35 | window.addEventListener('keydown', this.onKeyDown, false); 36 | window.addEventListener('keyup', this.onKeyUp, false); 37 | target.addEventListener('mousedown', this.onMouseDown, false); 38 | window.addEventListener('mouseup', this.onMouseUp, false); 39 | window.addEventListener('mousemove', this.onMouseMove, false); 40 | window.addEventListener('wheel', this.onMouseWheel, { passive: false }); 41 | document.addEventListener('pointerlockchange', this.onPointerLock, false); 42 | } 43 | 44 | dispose() { 45 | target.removeEventListener('contextmenu', this.onContextMenu); 46 | window.removeEventListener('gamepaddisconnected', this.onGamepadDisconnected); 47 | window.removeEventListener('gamepadconnected', this.onGamepadConnected); 48 | window.removeEventListener('keydown', this.onKeyDown); 49 | window.removeEventListener('keyup', this.onKeyUp); 50 | this.target.removeEventListener('mousedown', this.onMouseDown); 51 | window.removeEventListener('mouseup', this.onMouseUp); 52 | window.removeEventListener('mousemove', this.onMouseMove); 53 | window.removeEventListener('wheel', this.onMouseWheel); 54 | document.removeEventListener('pointerlockchange', this.onPointerLock); 55 | document.body.classList.remove('pointerlock'); 56 | } 57 | 58 | lock() { 59 | const { isLocked, target } = this; 60 | if (!isLocked) { 61 | target.requestPointerLock(); 62 | } 63 | } 64 | 65 | unlock() { 66 | const { isLocked } = this; 67 | if (isLocked) { 68 | document.exitPointerLock(); 69 | } 70 | } 71 | 72 | onAnimationTick() { 73 | const { buttons, buttonState, gamepad, keyboard, look, mouse, movement } = this; 74 | look.copy(mouse); 75 | mouse.set(0, 0); 76 | movement.copy(keyboard); 77 | let gamepadState = {}; 78 | if (gamepad !== false) { 79 | const { axes, buttons } = navigator.getGamepads()[gamepad]; 80 | if (Math.max(Math.abs(axes[0]), Math.abs(axes[1])) > 0.1) { 81 | movement.set(axes[0], -axes[1]); 82 | } 83 | if (Math.max(Math.abs(axes[2]), Math.abs(axes[3])) > 0.1) { 84 | look.set(-axes[2] * 0.03, axes[3] * 0.03); 85 | } 86 | gamepadState = { 87 | primary: buttons[7] && buttons[7].pressed, 88 | secondary: buttons[6] && buttons[6].pressed, 89 | tertiary: false, 90 | interact: buttons[0] && buttons[0].pressed, 91 | run: buttons[10] && buttons[10].pressed, 92 | }; 93 | } 94 | ['primary', 'secondary', 'tertiary', 'interact', 'run'].forEach((button) => { 95 | const state = buttonState[button] || gamepadState[button]; 96 | buttons[`${button}Down`] = state && buttons[button] !== state; 97 | buttons[`${button}Up`] = !state && buttons[button] !== state; 98 | buttons[button] = state; 99 | }); 100 | } 101 | 102 | onContextMenu(e) { 103 | e.preventDefault(); 104 | } 105 | 106 | onGamepadDisconnected({ gamepad: { index } }) { 107 | const { gamepad } = this; 108 | if (gamepad === index) { 109 | this.gamepad = false; 110 | } 111 | } 112 | 113 | onGamepadConnected({ gamepad: { index } }) { 114 | this.gamepad = index; 115 | } 116 | 117 | onKeyDown({ key, repeat, target }) { 118 | const { buttonState, isLocked, keyboard } = this; 119 | if (!isLocked || repeat || target.tagName === 'INPUT') { 120 | return; 121 | } 122 | switch (key.toLowerCase()) { 123 | case 'w': 124 | keyboard.y = 1; 125 | break; 126 | case 's': 127 | keyboard.y = -1; 128 | break; 129 | case 'a': 130 | keyboard.x = -1; 131 | break; 132 | case 'd': 133 | keyboard.x = 1; 134 | break; 135 | case 'e': 136 | buttonState.interact = true; 137 | break; 138 | case 'shift': 139 | buttonState.run = true; 140 | break; 141 | default: 142 | break; 143 | } 144 | } 145 | 146 | onKeyUp({ key }) { 147 | const { buttonState, isLocked, keyboard } = this; 148 | if (!isLocked) { 149 | return; 150 | } 151 | switch (key.toLowerCase()) { 152 | case 'w': 153 | if (keyboard.y > 0) keyboard.y = 0; 154 | break; 155 | case 's': 156 | if (keyboard.y < 0) keyboard.y = 0; 157 | break; 158 | case 'a': 159 | if (keyboard.x < 0) keyboard.x = 0; 160 | break; 161 | case 'd': 162 | if (keyboard.x > 0) keyboard.x = 0; 163 | break; 164 | case 'e': 165 | buttonState.interact = false; 166 | break; 167 | case 'shift': 168 | buttonState.run = false; 169 | break; 170 | default: 171 | break; 172 | } 173 | } 174 | 175 | onMouseDown({ button }) { 176 | const { buttonState, isLocked } = this; 177 | if (!isLocked) { 178 | this.lock(); 179 | return; 180 | } 181 | switch (button) { 182 | case 0: 183 | buttonState.primary = true; 184 | break; 185 | case 1: 186 | buttonState.tertiary = true; 187 | break; 188 | case 2: 189 | buttonState.secondary = true; 190 | break; 191 | default: 192 | break; 193 | } 194 | } 195 | 196 | onMouseUp({ button }) { 197 | const { buttonState, isLocked } = this; 198 | if (!isLocked) { 199 | return; 200 | } 201 | switch (button) { 202 | case 0: 203 | buttonState.primary = false; 204 | break; 205 | case 1: 206 | buttonState.tertiary = false; 207 | break; 208 | case 2: 209 | buttonState.secondary = false; 210 | break; 211 | default: 212 | break; 213 | } 214 | } 215 | 216 | onMouseMove({ clientX, clientY, movementX, movementY }) { 217 | const { isLocked, mouse, pointer } = this; 218 | if (!isLocked) { 219 | return; 220 | } 221 | mouse.x -= movementX * 0.003; 222 | mouse.y -= movementY * 0.003; 223 | pointer.set( 224 | (clientX / window.innerWidth) * 2 - 1, 225 | -(clientY / window.innerHeight) * 2 + 1 226 | ); 227 | } 228 | 229 | onMouseWheel(e) { 230 | if (e.ctrlKey) { 231 | e.preventDefault(); 232 | } 233 | const { minSpeed, speedRange } = Input; 234 | const { speed } = this; 235 | const logSpeed = Math.min( 236 | Math.max( 237 | ((Math.log(speed) - minSpeed) / speedRange) - (e.deltaY * 0.0003), 238 | 0 239 | ), 240 | 1 241 | ); 242 | this.speed = Math.exp(minSpeed + logSpeed * speedRange); 243 | } 244 | 245 | onPointerLock() { 246 | const { buttonState, keyboard } = this; 247 | this.isLocked = !!document.pointerLockElement; 248 | document.body.classList[this.isLocked ? 'add' : 'remove']('pointerlock'); 249 | if (!this.isLocked) { 250 | buttonState.primary = false; 251 | buttonState.secondary = false; 252 | buttonState.tertiary = false; 253 | buttonState.interact = false; 254 | buttonState.run = false; 255 | keyboard.set(0, 0); 256 | } 257 | } 258 | } 259 | 260 | Input.minSpeed = Math.log(1); 261 | Input.maxSpeed = Math.log(10); 262 | Input.speedRange = Input.maxSpeed - Input.minSpeed; 263 | 264 | export default Input; 265 | -------------------------------------------------------------------------------- /example/src/core/postprocessing.js: -------------------------------------------------------------------------------- 1 | import { 2 | DepthTexture, 3 | FloatType, 4 | GLSL3, 5 | Mesh, 6 | PlaneGeometry, 7 | RawShaderMaterial, 8 | Vector2, 9 | WebGLMultipleRenderTargets, 10 | } from 'three'; 11 | 12 | class PostProcessing { 13 | constructor({ samples }) { 14 | const plane = new PlaneGeometry(2, 2, 1, 1); 15 | plane.deleteAttribute('normal'); 16 | plane.deleteAttribute('uv'); 17 | this.target = new WebGLMultipleRenderTargets(window.innerWidth, window.innerHeight, 2, { 18 | depthTexture: new DepthTexture(window.innerWidth, window.innerHeight, FloatType), 19 | samples, 20 | type: FloatType, 21 | }); 22 | this.screen = new Mesh( 23 | plane, 24 | new RawShaderMaterial({ 25 | glslVersion: GLSL3, 26 | uniforms: { 27 | colorTexture: { value: this.target.texture[0] }, 28 | depthTexture: { value: this.target.depthTexture }, 29 | normalTexture: { value: this.target.texture[1] }, 30 | resolution: { value: new Vector2(this.target.width, this.target.height) }, 31 | cameraNear: { value: 0 }, 32 | cameraFar: { value: 0 }, 33 | intensity: { value: 0.5 }, 34 | thickness: { value: 0.5 }, 35 | depthBias: { value: 1 }, 36 | depthScale: { value: 1 }, 37 | normalBias: { value: 1 }, 38 | normalScale: { value: 0.5 }, 39 | }, 40 | vertexShader: [ 41 | 'precision highp float;', 42 | 'in vec3 position;', 43 | 'out vec2 uv;', 44 | 'void main() {', 45 | ' gl_Position = vec4(position.xy, 0, 1);', 46 | ' uv = position.xy * 0.5 + 0.5;', 47 | '}', 48 | ].join('\n'), 49 | fragmentShader: [ 50 | 'precision highp float;', 51 | 'in vec2 uv;', 52 | 'out vec4 fragColor;', 53 | 'uniform sampler2D colorTexture;', 54 | 'uniform sampler2D depthTexture;', 55 | 'uniform sampler2D normalTexture;', 56 | 'uniform vec2 resolution;', 57 | 'uniform float cameraNear;', 58 | 'uniform float cameraFar;', 59 | 'uniform float intensity;', 60 | 'uniform float thickness;', 61 | 'uniform float depthBias;', 62 | 'uniform float depthScale;', 63 | 'uniform float normalBias;', 64 | 'uniform float normalScale;', 65 | '#define saturate(a) clamp(a, 0.0, 1.0)', 66 | 'float LinearizeDepth(const in float depth) {', 67 | ' float z = depth * 2.0 - 1.0;', 68 | ' return (2.0 * cameraNear * cameraFar) / (cameraFar + cameraNear - z * (cameraFar - cameraNear));', 69 | '}', 70 | 'vec3 LinearToSRGB(const in vec3 value) {', 71 | ' return vec3(mix(pow(value.rgb, vec3(0.41666)) * 1.055 - vec3(0.055), value.rgb * 12.92, vec3(lessThanEqual(value.rgb, vec3(0.0031308)))));', 72 | '}', 73 | 'vec3 SobelSample(const in sampler2D tex, const in vec2 uv, const in vec3 offset) {', 74 | ' vec3 pixelCenter = texture(tex, uv).rgb;', 75 | ' vec3 pixelLeft = texture(tex, uv - offset.xz).rgb;', 76 | ' vec3 pixelRight = texture(tex, uv + offset.xz).rgb;', 77 | ' vec3 pixelUp = texture(tex, uv + offset.zy).rgb;', 78 | ' vec3 pixelDown = texture(tex, uv - offset.zy).rgb;', 79 | ' return (', 80 | ' abs(pixelLeft - pixelCenter)', 81 | ' + abs(pixelRight - pixelCenter)', 82 | ' + abs(pixelUp - pixelCenter)', 83 | ' + abs(pixelDown - pixelCenter)', 84 | ' );', 85 | '}', 86 | 'float SobelSampleDepth(const in sampler2D tex, const in vec2 uv, const in vec3 offset) {', 87 | ' float pixelCenter = LinearizeDepth(texture(tex, uv).r);', 88 | ' float pixelLeft = LinearizeDepth(texture(tex, uv - offset.xz).r);', 89 | ' float pixelRight = LinearizeDepth(texture(tex, uv + offset.xz).r);', 90 | ' float pixelUp = LinearizeDepth(texture(tex, uv + offset.zy).r);', 91 | ' float pixelDown = LinearizeDepth(texture(tex, uv - offset.zy).r);', 92 | ' return (', 93 | ' abs(pixelLeft - pixelCenter)', 94 | ' + abs(pixelRight - pixelCenter)', 95 | ' + abs(pixelUp - pixelCenter)', 96 | ' + abs(pixelDown - pixelCenter)', 97 | ' );', 98 | '}', 99 | 'vec3 composite(const in vec2 uv) {', 100 | ' vec3 offset = vec3((1.0 / resolution.x), (1.0 / resolution.y), 0.0) * thickness;', 101 | ' float sobelDepth = SobelSampleDepth(depthTexture, uv, offset);', 102 | ' sobelDepth = pow(saturate(sobelDepth) * depthScale, depthBias);', 103 | ' vec3 sobelNormalVec = SobelSample(normalTexture, uv, offset);', 104 | ' float sobelNormal = sobelNormalVec.x + sobelNormalVec.y + sobelNormalVec.z;', 105 | ' sobelNormal = pow(sobelNormal * normalScale, normalBias);', 106 | ' vec3 color = texture(colorTexture, uv).rgb;', 107 | ' return mix(color, vec3(0.0), saturate(max(sobelDepth, sobelNormal)) * intensity);', 108 | '}', 109 | 'void main() {', 110 | ' fragColor = vec4(LinearToSRGB(composite(uv)), 1.0);', 111 | // ' gl_FragDepth = texture(depthTexture, uv).r;', 112 | '}', 113 | ].join('\n'), 114 | }) 115 | ); 116 | this.screen.frustumCulled = false; 117 | this.screen.matrixAutoUpdate = false; 118 | } 119 | 120 | onResize(width, height) { 121 | const { screen, target } = this; 122 | target.setSize(width, height); 123 | screen.material.uniforms.resolution.value.set(width, height); 124 | } 125 | 126 | render(renderer, scene, camera) { 127 | const { screen, target } = this; 128 | renderer.setRenderTarget(target); 129 | renderer.render(scene, camera); 130 | renderer.setRenderTarget(null); 131 | screen.material.uniforms.cameraNear.value = camera.near; 132 | screen.material.uniforms.cameraFar.value = camera.far; 133 | renderer.render(screen, camera); 134 | } 135 | } 136 | 137 | export default PostProcessing; 138 | -------------------------------------------------------------------------------- /example/src/core/projectiles.js: -------------------------------------------------------------------------------- 1 | import { Group, Vector3 } from 'three'; 2 | import Explosion from '../renderables/explosion.js'; 3 | import Projectile from '../renderables/projectile.js'; 4 | 5 | const _voxel = new Vector3(); 6 | 7 | class Projectiles extends Group { 8 | constructor({ sfx, world }) { 9 | super(); 10 | this.matrixAutoUpdate = false; 11 | this.sfx = sfx; 12 | this.pools = { 13 | explosions: [], 14 | projectiles: [], 15 | }; 16 | this.world = world; 17 | this.explosions = []; 18 | this.projectiles = []; 19 | this.targets = []; 20 | } 21 | 22 | onAnimationTick(delta) { 23 | const { explosions, pools, projectiles, targets, world } = this; 24 | const iterations = 3; 25 | const step = (60 / iterations) * delta; 26 | for (let p = 0, l = projectiles.length; p < l; p++) { 27 | const projectile = projectiles[p]; 28 | for (let i = 0; i < iterations; i++) { 29 | projectile.onAnimationTick(step); 30 | _voxel.copy(projectile.position).divide(world.scale).floor(); 31 | let hit = this.test(_voxel) && world; 32 | if (!hit && i !== 0) { 33 | hit = targets.find((target) => ( 34 | target.visible && projectile.position.distanceTo(target.head || target.position) < target.scale.x 35 | )); 36 | } 37 | if (hit || projectile.distance > 200) { 38 | this.remove(projectile); 39 | projectiles.splice(p, 1); 40 | pools.projectiles.push(projectile); 41 | p--; 42 | l--; 43 | if (hit) { 44 | const point = hit === world ? ( 45 | _voxel 46 | .multiplyVectors(projectile.direction, world.scale) 47 | .negate() 48 | .add(projectile.position) 49 | ) : ( 50 | hit.position 51 | ); 52 | this.blast({ 53 | color: hit.color || projectile.color, 54 | origin: point, 55 | }); 56 | this.dispatchEvent({ 57 | type: 'hit', 58 | color: hit.color || projectile.color, 59 | object: hit, 60 | owner: projectile.owner, 61 | point, 62 | }); 63 | } 64 | break; 65 | } 66 | } 67 | } 68 | for (let i = 0, l = explosions.length; i < l; i++) { 69 | const explosion = explosions[i]; 70 | if (explosion.onAnimationTick(delta)) { 71 | this.remove(explosion); 72 | explosions.splice(i, 1); 73 | pools.explosions.push(explosion); 74 | i--; 75 | l--; 76 | } 77 | } 78 | } 79 | 80 | blast({ color, origin }) { 81 | const { explosions, pools, sfx } = this; 82 | sfx.playAt('blast', origin, 'lowpass', 1000 + Math.random() * 1000); 83 | const explosion = pools.explosions.pop() || new Explosion(); 84 | explosion.color.copy(color); 85 | explosion.position.copy(origin); 86 | explosion.rotation.set((Math.random() - 0.5) * Math.PI * 2, (Math.random() - 0.5) * Math.PI * 2, (Math.random() - 0.5) * Math.PI * 2); 87 | explosion.updateMatrix(); 88 | explosion.step = 0; 89 | explosions.push(explosion); 90 | this.add(explosion); 91 | } 92 | 93 | shoot({ color, direction, offset = 1, origin, owner }) { 94 | const { pools, projectiles, sfx } = this; 95 | sfx.playAt('shot', _voxel.addVectors(origin, direction), 'highpass', 1000 + Math.random() * 1000); 96 | const projectile = pools.projectiles.pop() || new Projectile(); 97 | projectile.color.copy(color); 98 | projectile.direction.copy(direction); 99 | projectile.distance = 0; 100 | projectile.position.copy(origin).addScaledVector(direction, offset); 101 | projectile.owner = owner; 102 | projectiles.push(projectile); 103 | this.add(projectile); 104 | } 105 | 106 | test(position) { 107 | const { world } = this; 108 | const voxel = world.volume.voxel(position); 109 | return voxel !== -1 && world.volume.memory.voxels.view[voxel] !== 0; 110 | } 111 | } 112 | 113 | export default Projectiles; 114 | -------------------------------------------------------------------------------- /example/src/core/sfx.js: -------------------------------------------------------------------------------- 1 | import { 2 | Audio, 3 | AudioListener, 4 | AudioLoader, 5 | Group, 6 | MathUtils, 7 | PositionalAudio, 8 | } from 'three'; 9 | 10 | class SFX extends Group { 11 | constructor() { 12 | super(); 13 | const loader = new AudioLoader(); 14 | this.pools = {}; 15 | Promise.all([ 16 | ...['ambient', 'blast', 'shot', 'rain'].map((sound) => ( 17 | new Promise((resolve, reject) => loader.load(`/sounds/${sound}.ogg`, resolve, null, reject)) 18 | )), 19 | new Promise((resolve) => { 20 | const onFirstInteraction = () => { 21 | window.removeEventListener('keydown', onFirstInteraction); 22 | window.removeEventListener('mousedown', onFirstInteraction); 23 | resolve(); 24 | }; 25 | window.addEventListener('keydown', onFirstInteraction, false); 26 | window.addEventListener('mousedown', onFirstInteraction, false); 27 | }), 28 | ]) 29 | .then(([ambient, blast, shot, rain]) => { 30 | const listener = new AudioListener(); 31 | document.addEventListener('visibilitychange', () => ( 32 | listener.setMasterVolume(document.visibilityState === 'visible' ? 1 : 0) 33 | ), false); 34 | this.listener = listener; 35 | 36 | const getPool = (buffer, pool) => ( 37 | Array.from({ length: pool }, () => { 38 | const sound = new PositionalAudio(listener); 39 | sound.matrixAutoUpdate = false; 40 | sound.setBuffer(buffer); 41 | sound.filter = new BiquadFilterNode(listener.context); 42 | sound.setFilter(sound.filter); 43 | sound.setRefDistance(32); 44 | sound.setVolume(0.4); 45 | this.add(sound); 46 | return sound; 47 | }) 48 | ); 49 | this.pools.blast = getPool(blast, 16); 50 | this.pools.shot = getPool(shot, 16); 51 | 52 | const filter = new BiquadFilterNode(listener.context, { 53 | type: 'lowpass', 54 | frequency: 1000, 55 | }); 56 | const dry = new GainNode(listener.context, { gain: 1 }); 57 | const wet = new GainNode(listener.context, { gain: 0 }); 58 | filter.connect(listener.getInput()); 59 | dry.connect(listener.getInput()); 60 | wet.connect(filter); 61 | this.filterAmbient = (delta, light) => { 62 | const d = MathUtils.damp(wet.gain.value, (1 - light) * 0.8, 10, delta); 63 | wet.gain.value = d; 64 | dry.gain.value = 1 - d; 65 | }; 66 | const getAmbient = (buffer) => { 67 | const sound = new Audio(listener); 68 | sound.gain.disconnect(listener.getInput()); 69 | sound.gain.connect(dry); 70 | sound.gain.connect(wet); 71 | sound.setBuffer(buffer); 72 | sound.setLoop(true); 73 | sound.setVolume(0.4); 74 | return sound; 75 | }; 76 | this.ambient = getAmbient(ambient); 77 | this.ambient.play(); 78 | this.rain = getAmbient(rain, 0); 79 | }); 80 | } 81 | 82 | onAnimationTick(delta, camera, light, isRaining) { 83 | const { listener, rain } = this; 84 | if (!listener) { 85 | return; 86 | } 87 | this.filterAmbient(delta, light); 88 | if (isRaining && !rain.isPlaying) { 89 | rain.play(); 90 | } else if (!isRaining && rain.isPlaying) { 91 | rain.pause(); 92 | } 93 | camera.matrixWorld.decompose(listener.position, listener.quaternion, listener.scale); 94 | listener.updateMatrixWorld(); 95 | } 96 | 97 | playAt(id, position, filter, frequency) { 98 | const { pools } = this; 99 | const pool = pools[id]; 100 | if (!pool) { 101 | return; 102 | } 103 | const sound = pools[id].find(({ isPlaying }) => !isPlaying); 104 | if (!sound) { 105 | return; 106 | } 107 | sound.filter.type = filter; 108 | sound.filter.frequency.value = Math.round(frequency); 109 | sound.position.copy(position); 110 | sound.updateMatrix(); 111 | sound.play(sound.listener.timeDelta); 112 | } 113 | } 114 | 115 | export default SFX; 116 | -------------------------------------------------------------------------------- /example/src/core/toolbar.js: -------------------------------------------------------------------------------- 1 | class Toolbar { 2 | constructor() { 3 | const dom = document.createElement('div'); 4 | dom.id = 'toolbar'; 5 | document.body.appendChild(dom); 6 | this.buttons = ['blast', 'light1', 'light2', 'light3'].map((id, index) => { 7 | const button = document.createElement('button'); 8 | [`[${index + 1}]`, `${id}`].forEach((text) => { 9 | const span = document.createElement('span'); 10 | span.innerText = text; 11 | button.appendChild(span); 12 | }); 13 | button.addEventListener('click', () => this.setTool(index), false); 14 | dom.appendChild(button); 15 | return button; 16 | }); 17 | this.setTool(0); 18 | this.onKeyDown = this.onKeyDown.bind(this); 19 | window.addEventListener('keydown', this.onKeyDown, false); 20 | } 21 | 22 | onKeyDown({ key, repeat, target }) { 23 | if (repeat || target.tagName === 'INPUT') { 24 | return; 25 | } 26 | switch (key.toLowerCase()) { 27 | case '1': 28 | case '2': 29 | case '3': 30 | case '4': 31 | this.setTool(parseInt(key, 10) - 1); 32 | break; 33 | default: 34 | break; 35 | } 36 | } 37 | 38 | setTool(tool) { 39 | const { buttons } = this; 40 | this.tool = tool; 41 | buttons.forEach((button, index) => { 42 | button.classList.remove('enabled'); 43 | if (tool === index) { 44 | button.classList.add('enabled'); 45 | } 46 | }); 47 | } 48 | } 49 | 50 | export default Toolbar; 51 | -------------------------------------------------------------------------------- /example/src/gameplay.js: -------------------------------------------------------------------------------- 1 | import { 2 | Color, 3 | Frustum, 4 | Group, 5 | MathUtils, 6 | Matrix4, 7 | Scene, 8 | Vector3, 9 | Vector4, 10 | } from 'three'; 11 | import { ChunkMaterial, Volume, World, Worldgen } from 'cubitos'; 12 | import { loadModel, loadTexture } from './core/assets.js'; 13 | import Actors from './core/actors.js'; 14 | import Input from './core/input.js'; 15 | import Projectiles from './core/projectiles.js'; 16 | import SFX from './core/sfx.js'; 17 | import Toolbar from './core/toolbar.js'; 18 | import Dome from './renderables/dome.js'; 19 | import Rain from './renderables/rain.js'; 20 | 21 | const _color = new Color(); 22 | const _grid = [ 23 | [0, 0], 24 | [-1, -1], [0, -1], [1, -1], 25 | [-1, 0], [1, 0], 26 | [-1, 1], [0, 1], [1, 1], 27 | ].map(([x, z]) => new Vector3(x * 0.25, 0, z * 0.25)); 28 | const _origin = new Vector3(); 29 | const _direction = new Vector3(); 30 | const _forward = new Vector3(); 31 | const _projection = new Matrix4(); 32 | const _right = new Vector3(); 33 | const _worldUp = new Vector3(0, 1, 0); 34 | 35 | class Gameplay extends Scene { 36 | constructor({ camera, clock, postprocessing, renderer }) { 37 | super(); 38 | 39 | this.dome = new Dome(); 40 | this.add(this.dome); 41 | this.input = new Input(renderer.domElement); 42 | this.loading = document.getElementById('loading'); 43 | this.loading.classList.add('enabled'); 44 | this.postprocessing = postprocessing; 45 | this.sfx = new SFX(); 46 | this.add(this.sfx); 47 | this.toolbar = new Toolbar(); 48 | 49 | camera.position.set(0, 1.6, 0); 50 | camera.rotation.set(0, 0, 0, 'YXZ'); 51 | this.player = new Group(); 52 | this.player.camera = camera; 53 | this.player.frustum = new Frustum(); 54 | this.player.isWalking = true; 55 | this.player.lastShot = 0; 56 | this.player.light = new Vector4(); 57 | this.player.targetFloor = 0; 58 | this.player.targetPosition = this.player.position.clone(); 59 | this.player.targetRotation = this.player.camera.rotation.clone(); 60 | this.player.add(camera); 61 | this.add(this.player); 62 | 63 | Promise.all([ 64 | loadModel('/models/bot.glb'), 65 | Promise.all([ 66 | loadTexture('/textures/atlas1.png'), 67 | new Promise((resolve, reject) => { 68 | const volume = new Volume({ 69 | width: 192, 70 | height: 128, 71 | depth: 192, 72 | emission: (value) => { 73 | if (value >= 4 && value <= 6) { 74 | return value - 3; 75 | } 76 | return 0; 77 | }, 78 | mapping: (face, value) => { 79 | if (value === 2) { 80 | return 1; 81 | } 82 | if (value === 3 && face !== 2) { 83 | return face === 1 ? 2 : 3; 84 | } 85 | if (value >= 4 && value <= 6) { 86 | return value; 87 | } 88 | return 0; 89 | }, 90 | onLoad: () => resolve( 91 | Worldgen({ frequency: 0.006, volume }) 92 | .then(() => volume.propagate()) 93 | ), 94 | onError: (err) => reject(err), 95 | }); 96 | }), 97 | ]) 98 | .then(([atlas, volume]) => { 99 | this.world = new World({ 100 | material: new ChunkMaterial({ 101 | atlas, 102 | ambientColor: new Color(0.01, 0.01, 0.01), 103 | light1Color: new Color(0.8, 0.2, 0.2), 104 | light2Color: new Color(0.2, 0.2, 0.8), 105 | light3Color: new Color(0.8, 0.8, 0.2), 106 | }), 107 | volume, 108 | }); 109 | this.world.scale.setScalar(0.5); 110 | this.world.updateMatrix(); 111 | this.add(this.world); 112 | 113 | this.sunlight = { 114 | background: new Color(0x224466), 115 | color: new Color(0.7, 0.7, 0.5), 116 | target: 1, 117 | value: 0, 118 | }; 119 | { 120 | const toggle = document.getElementById('light'); 121 | const [day, night] = toggle.getElementsByTagName('svg'); 122 | day.addEventListener('click', () => { 123 | toggle.classList.remove('night'); 124 | toggle.classList.add('day'); 125 | this.sunlight.target = 1; 126 | }, false); 127 | night.addEventListener('click', () => { 128 | toggle.classList.remove('day'); 129 | toggle.classList.add('night'); 130 | this.sunlight.target = 0; 131 | }, false); 132 | } 133 | 134 | this.dome.position 135 | .set(volume.width * 0.5, 0, volume.depth * 0.5) 136 | .multiply(this.world.scale); 137 | this.dome.updateMatrix(); 138 | 139 | this.player.position.set( 140 | Math.floor(volume.width * 0.5), 141 | volume.height - 1, 142 | Math.floor(volume.depth * 0.5) 143 | ); 144 | this.player.position.y = volume.ground(this.player.position, 4); 145 | this.player.position.x += 0.5; 146 | this.player.position.z += 0.5; 147 | this.player.position.multiply(this.world.scale); 148 | this.player.targetFloor = this.player.position.y; 149 | this.player.targetPosition.copy(this.player.position); 150 | 151 | this.projectiles = new Projectiles({ sfx: this.sfx, world: this.world }); 152 | this.projectiles.addEventListener('hit', ({ object, point }) => { 153 | if (object === this.world) { 154 | _origin.copy(point).divide(this.world.scale).floor(); 155 | const radius = this.toolbar.tool === 0 ? (3 + Math.floor(Math.random() * 2)) : 1; 156 | const value = this.toolbar.tool === 0 ? 0 : 3 + this.toolbar.tool; 157 | this.world.update(_origin, radius, (d, v, p) => p.y === 0 ? -1 : value); 158 | } 159 | }); 160 | this.add(this.projectiles); 161 | 162 | this.rain = new Rain({ world: this.world }); 163 | this.add(this.rain); 164 | { 165 | const toggle = document.getElementById('rain'); 166 | toggle.addEventListener('click', () => { 167 | toggle.classList.toggle('enabled'); 168 | this.rain.visible = !this.rain.visible; 169 | if (this.rain.visible) { 170 | this.rain.reset(this.player.position); 171 | } 172 | }, false); 173 | } 174 | 175 | this.world.atlasIndex = 0; 176 | this.loading.classList.remove('enabled'); 177 | clock.start(); 178 | }), 179 | ]) 180 | .then(([bot]) => { 181 | this.actors = new Actors({ count: 20, model: bot, world: this.world }); 182 | this.add(this.actors); 183 | }) 184 | .catch((e) => console.error(e)); 185 | } 186 | 187 | onAnimationTick(delta, time) { 188 | const { actors, input, player, projectiles, rain, sfx, world } = this; 189 | if (!world) { 190 | return; 191 | } 192 | input.onAnimationTick(); 193 | this.processPlayerMovement(delta); 194 | this.processPlayerInput(time); 195 | if (actors) { 196 | actors.onAnimationTick(delta, player.frustum); 197 | } 198 | projectiles.onAnimationTick(delta); 199 | rain.onAnimationTick(delta, player.position); 200 | sfx.onAnimationTick(delta, player.camera, actors.light(player.position, player.light).x, rain.visible); 201 | this.updateSunlight(delta); 202 | } 203 | 204 | processPlayerMovement(delta) { 205 | const { input, player, world } = this; 206 | 207 | if (input.look.x || input.look.y) { 208 | player.targetRotation.y += input.look.x; 209 | player.targetRotation.x += input.look.y; 210 | player.targetRotation.x = Math.min(Math.max(player.targetRotation.x, Math.PI * -0.5), Math.PI * 0.5); 211 | } 212 | player.camera.rotation.y = MathUtils.damp(player.camera.rotation.y, player.targetRotation.y, 20, delta); 213 | player.camera.rotation.x = MathUtils.damp(player.camera.rotation.x, player.targetRotation.x, 20, delta); 214 | 215 | if (input.movement.x || input.movement.y) { 216 | player.camera.getWorldDirection(_forward); 217 | if (player.isWalking) { 218 | _forward.y = 0; 219 | _forward.normalize(); 220 | } 221 | _right.crossVectors(_forward, _worldUp).normalize(); 222 | _direction 223 | .set(0, 0, 0) 224 | .addScaledVector(_right, input.movement.x) 225 | .addScaledVector(_forward, input.movement.y); 226 | const length = _direction.length(); 227 | if (length > 1) { 228 | _direction.divideScalar(length); 229 | } 230 | const step = input.speed * (input.buttons.run ? 2 : 1) * delta; 231 | if (player.isWalking) { 232 | let canMove = true; 233 | let floor = player.targetFloor; 234 | player.camera.getWorldPosition(_forward) 235 | .sub(player.position) 236 | .add(player.targetPosition) 237 | .addScaledVector(_direction, step); 238 | for (let i = 0, l = _grid.length; i < l; i++) { 239 | _origin 240 | .copy(_forward) 241 | .add(_grid[i]) 242 | .divide(world.scale) 243 | .floor(); 244 | if (i === 0) { 245 | _origin.y = Math.max(world.volume.ground(_origin, 4), 1); 246 | floor = _origin.y * world.scale.y; 247 | if (Math.abs(floor - player.targetPosition.y) > 2) { 248 | canMove = false; 249 | break; 250 | } 251 | } 252 | const voxel = world.volume.voxel(_origin); 253 | if (voxel !== -1 && world.volume.memory.voxels.view[voxel]) { 254 | canMove = false; 255 | break; 256 | } 257 | } 258 | if (canMove) { 259 | player.targetPosition.addScaledVector(_direction, step); 260 | player.targetFloor = floor; 261 | } 262 | } else { 263 | player.targetPosition.addScaledVector(_direction, step); 264 | } 265 | } 266 | 267 | if (player.isWalking) { 268 | player.targetPosition.y = MathUtils.damp(player.targetPosition.y, player.targetFloor, 10, delta); 269 | } 270 | player.position.x = MathUtils.damp(player.position.x, player.targetPosition.x, 10, delta); 271 | player.position.y = MathUtils.damp(player.position.y, player.targetPosition.y, 10, delta); 272 | player.position.z = MathUtils.damp(player.position.z, player.targetPosition.z, 10, delta); 273 | 274 | player.updateMatrixWorld(); 275 | _projection.multiplyMatrices(player.camera.projectionMatrix, player.camera.matrixWorldInverse); 276 | player.frustum.setFromProjectionMatrix(_projection); 277 | } 278 | 279 | processPlayerInput(time) { 280 | const { input, player, postprocessing, projectiles, world } = this; 281 | if (input.buttons.primary && time >= player.lastShot + 0.06) { 282 | player.lastShot = time; 283 | _origin.setFromMatrixPosition(player.camera.matrixWorld); 284 | _direction.set(0, 0, 0.5).unproject(player.camera).sub(_origin).normalize(); 285 | projectiles.shoot({ 286 | color: _color.setHSL(Math.random(), 0.4 + Math.random() * 0.2, 0.6 + Math.random() * 0.2), 287 | direction: _direction, 288 | offset: 1, 289 | origin: _origin, 290 | owner: player, 291 | }); 292 | } 293 | if (input.buttons.secondaryDown) { 294 | world.atlasIndex = (world.atlasIndex + 1) % 2; 295 | loadTexture(`/textures/atlas${1 + world.atlasIndex}.png`) 296 | .then((atlas) => { 297 | world.material.setAtlas(atlas); 298 | postprocessing.screen.material.uniforms.intensity.value = ( 299 | world.atlasIndex === 0 ? 0.5 : 0.8 300 | ); 301 | }); 302 | } 303 | if (input.buttons.interactDown) { 304 | player.isWalking = !player.isWalking; 305 | if (player.isWalking) { 306 | const y = world.volume.ground(_origin.copy(player.targetPosition).divide(world.scale).floor(), 4); 307 | if (y !== -1) { 308 | player.targetFloor = y * world.scale.y; 309 | } 310 | } 311 | } 312 | } 313 | 314 | updateSunlight(delta) { 315 | const { sunlight, dome, rain, world } = this; 316 | if (sunlight.target === sunlight.value) { 317 | return; 318 | } 319 | sunlight.value += Math.min(Math.max(sunlight.target - sunlight.value, delta * -2), delta * 2); 320 | sunlight.value = Math.min(Math.max(sunlight.value, 0), 1); 321 | dome.material.uniforms.diffuse.value.copy(sunlight.background).multiplyScalar(Math.max(sunlight.value, 0.02)); 322 | rain.material.uniforms.diffuse.value.copy(sunlight.background).multiplyScalar(Math.max(sunlight.value, 0.1)); 323 | world.material.uniforms.sunlightColor.value.copy(sunlight.color).multiplyScalar(sunlight.value); 324 | } 325 | } 326 | 327 | export default Gameplay; 328 | -------------------------------------------------------------------------------- /example/src/renderables/actor.js: -------------------------------------------------------------------------------- 1 | import { 2 | AnimationMixer, 3 | Box3, 4 | Color, 5 | Group, 6 | MathUtils, 7 | Vector3, 8 | ShaderMaterial, 9 | ShaderLib, 10 | UniformsUtils, 11 | Vector4, 12 | } from 'three'; 13 | 14 | const _box = new Box3(); 15 | const _vector = new Vector3(); 16 | 17 | class Actor extends Group { 18 | static setupMaterial() { 19 | const { uniforms, vertexShader, fragmentShader } = ShaderLib.basic; 20 | Actor.material = new ShaderMaterial({ 21 | uniforms: { 22 | ...UniformsUtils.clone(uniforms), 23 | light: { value: new Vector4(1, 0, 0, 0) }, 24 | ambientColor: { value: new Color(0, 0, 0) }, 25 | light1Color: { value: new Color(1, 1, 1) }, 26 | light2Color: { value: new Color(1, 1, 1) }, 27 | light3Color: { value: new Color(1, 1, 1) }, 28 | sunlightColor: { value: new Color(1, 1, 1) }, 29 | }, 30 | vertexShader: vertexShader 31 | .replace( 32 | '#include ', 33 | [ 34 | '#include ', 35 | 'uniform vec4 light;', 36 | 'uniform vec3 ambientColor;', 37 | 'uniform vec3 light1Color;', 38 | 'uniform vec3 light2Color;', 39 | 'uniform vec3 light3Color;', 40 | 'uniform vec3 sunlightColor;', 41 | 'varying vec3 fragLight;', 42 | 'varying vec3 fragNormal;', 43 | ].join('\n') 44 | ) 45 | .replace( 46 | '#if defined ( USE_ENVMAP ) || defined ( USE_SKINNING )', 47 | '#if 1' 48 | ) 49 | .replace( 50 | '#include ', 51 | [ 52 | '#include ', 53 | 'vec3 lightColor = sunlightColor * light.x + light1Color * light.y + light2Color * light.z + light3Color * light.w;', 54 | 'fragLight = max(ambientColor, lightColor);', 55 | 'fragNormal = transformedNormal;', 56 | ].join('\n') 57 | ), 58 | fragmentShader: fragmentShader 59 | .replace( 60 | '#include ', 61 | [ 62 | '#include ', 63 | 'layout(location = 1) out vec4 pc_fragNormal;', 64 | 'varying vec3 fragLight;', 65 | 'varying vec3 fragNormal;', 66 | ].join('\n') 67 | ) 68 | .replace( 69 | '#include ', 70 | [ 71 | '#include ', 72 | 'diffuseColor.rgb *= fragLight;', 73 | ].join('\n'), 74 | ) 75 | .replace( 76 | '#include ', 77 | [ 78 | '#include ', 79 | 'pc_fragNormal = vec4(normalize(fragNormal), 0.0);', 80 | ].join('\n') 81 | ), 82 | }); 83 | } 84 | 85 | constructor({ 86 | animations, 87 | colors, 88 | model, 89 | light, 90 | walkingBaseSpeed = 3, 91 | }) { 92 | if (!Actor.material) { 93 | Actor.setupMaterial(); 94 | } 95 | super(); 96 | this.mixer = new AnimationMixer(model); 97 | this.actions = animations.reduce((actions, clip) => { 98 | const action = this.mixer.clipAction(clip); 99 | action.play(); 100 | action.enabled = false; 101 | actions[clip.name] = action; 102 | return actions; 103 | }, {}); 104 | this.actions.walking.baseSpeed = walkingBaseSpeed; 105 | this.action = this.actions.idle; 106 | this.action.enabled = true; 107 | this.collider = new Box3(new Vector3(-0.25, 0, -0.25), new Vector3(0.25, 2, 0.25)); 108 | this.colors = colors; 109 | this.light = { 110 | get: light, 111 | target: new Vector4(), 112 | value: new Vector4(), 113 | }; 114 | this.rotation.set(0, 0, 0, 'YXZ'); 115 | this.targetRotation = 0; 116 | this.setWalkingSpeed(3); 117 | model.traverse((child) => { 118 | if (child.isMesh) { 119 | const material = child.material.name; 120 | child.material = Actor.material; 121 | child.onBeforeRender = () => { 122 | const { colors, light } = this; 123 | child.material.uniforms.diffuse.value.copy(colors[material]); 124 | child.material.uniforms.light.value.copy(light.value); 125 | child.material.uniformsNeedUpdate = true; 126 | }; 127 | child.frustumCulled = false; 128 | } 129 | }); 130 | this.add(model); 131 | } 132 | 133 | getCollider() { 134 | const { collider, matrixWorld } = this; 135 | this.updateWorldMatrix(true, false); 136 | return _box.copy(collider).applyMatrix4(matrixWorld); 137 | } 138 | 139 | onAnimationTick(delta, frustum) { 140 | const { 141 | actions, 142 | mixer, 143 | light, 144 | rotation, 145 | targetRotation, 146 | walkingSpeed, 147 | } = this; 148 | mixer.update(delta); 149 | if (this.actionTimer) { 150 | this.actionTimer -= delta; 151 | if (this.actionTimer <= 0) { 152 | this.setAction(actions.idle); 153 | } 154 | } 155 | ['x', 'y', 'z', 'w'].forEach((l) => { 156 | if (Math.abs(light.target[l] - light.value[l]) > 0.01) { 157 | light.value[l] = MathUtils.damp(light.value[l], light.target[l], walkingSpeed * 1.5, delta); 158 | } 159 | }); 160 | if (Math.abs(targetRotation - rotation.y) > 0.01) { 161 | rotation.y = MathUtils.damp(rotation.y, targetRotation, walkingSpeed * 1.5, delta); 162 | } 163 | this.processMovement(delta); 164 | this.visible = frustum.intersectsBox(this.getCollider()); 165 | } 166 | 167 | processMovement(delta) { 168 | const { 169 | actions, 170 | onDestination, 171 | onStep, 172 | position, 173 | path, 174 | step, 175 | walkingSpeed, 176 | } = this; 177 | if (!path) { 178 | return; 179 | } 180 | const from = path[step]; 181 | const to = path[step + 1]; 182 | const isAscending = from.y < to.y; 183 | const isDescending = from.readyToDescent; 184 | const isBeforeDescending = !isDescending && from.y > to.y; 185 | this.interpolation = Math.min( 186 | this.interpolation + delta * walkingSpeed * (isAscending || isDescending ? 1.5 : 1), 187 | 1.0 188 | ); 189 | const { interpolation } = this; 190 | const destination = _vector.copy(to); 191 | if (isAscending) { 192 | destination.copy(from); 193 | destination.y = to.y; 194 | } else if (isBeforeDescending) { 195 | destination.y = from.y; 196 | } 197 | position.lerpVectors(from, destination, interpolation); 198 | if (this.interpolation < 1) { 199 | return; 200 | } 201 | this.interpolation = 0; 202 | if (isAscending || isBeforeDescending) { 203 | if (isAscending) { 204 | from.y = to.y; 205 | } 206 | if (isBeforeDescending) { 207 | from.x = to.x; 208 | from.z = to.z; 209 | from.readyToDescent = true; 210 | } 211 | return; 212 | } 213 | this.step++; 214 | const isLast = this.step >= path.length - 1; 215 | if (onStep && (isLast || (step % 2 === 0))) { 216 | onStep(); 217 | } 218 | if (isLast) { 219 | delete this.onDestination; 220 | delete this.path; 221 | let action; 222 | if (onDestination) { 223 | action = onDestination(); 224 | } 225 | this.setAction(action || actions.idle); 226 | } else { 227 | const next = path[this.step + 1]; 228 | this.setLight(next); 229 | this.setTarget(next); 230 | } 231 | } 232 | 233 | setAction(action, timer) { 234 | const { actions, action: current } = this; 235 | this.actionTimer = timer; 236 | if (action === current) { 237 | return; 238 | } 239 | this.action = action; 240 | action 241 | .reset() 242 | .crossFadeFrom( 243 | current, 244 | [action, current].includes(actions.walking) ? 0.25 : 0.4, 245 | false 246 | ); 247 | } 248 | 249 | setLight(position) { 250 | const { light } = this; 251 | light.get(position, light.target); 252 | } 253 | 254 | setPath(results, scale, onDestination) { 255 | const { actions, position } = this; 256 | const path = [position.clone()]; 257 | for (let i = 3, l = results.length; i < l; i += 3) { 258 | const isDestination = i === l - 3; 259 | path.push(new Vector3( 260 | (results[i] + 0.25 + (isDestination ? 0.25 : (Math.random() * 0.5))) * scale.x, 261 | results[i + 1] * scale.y, 262 | (results[i + 2] + 0.25 + (isDestination ? 0.25 : (Math.random() * 0.5))) * scale.z 263 | )); 264 | } 265 | this.path = path; 266 | this.step = 0; 267 | this.interpolation = 0; 268 | this.onDestination = onDestination; 269 | this.setAction(actions.walking); 270 | this.setLight(path[1]); 271 | this.setTarget(path[1]); 272 | } 273 | 274 | setTarget(target) { 275 | const { position, rotation } = this; 276 | _vector.subVectors(target, position); 277 | _vector.y = 0; 278 | _vector.normalize(); 279 | this.targetRotation = Math.atan2(_vector.x, _vector.z); 280 | const d = Math.abs(this.targetRotation - rotation.y); 281 | if (Math.abs(this.targetRotation - (rotation.y - Math.PI * 2)) < d) { 282 | rotation.y -= Math.PI * 2; 283 | } else if (Math.abs(this.targetRotation - (rotation.y + Math.PI * 2)) < d) { 284 | rotation.y += Math.PI * 2; 285 | } 286 | } 287 | 288 | setWalkingSpeed(speed) { 289 | const { actions } = this; 290 | actions.walking.timeScale = speed / actions.walking.baseSpeed; 291 | this.walkingSpeed = speed; 292 | } 293 | } 294 | 295 | export default Actor; 296 | -------------------------------------------------------------------------------- /example/src/renderables/dome.js: -------------------------------------------------------------------------------- 1 | import { 2 | BackSide, 3 | Color, 4 | DataTexture, 5 | FloatType, 6 | IcosahedronGeometry, 7 | LinearFilter, 8 | Mesh, 9 | RedFormat, 10 | RepeatWrapping, 11 | ShaderLib, 12 | ShaderMaterial, 13 | UniformsUtils, 14 | } from 'three'; 15 | 16 | const Noise = (size = 256) => { 17 | const data = new Float32Array(size * size); 18 | for (let i = 0; i < size * size; i++) { 19 | data[i] = Math.random(); 20 | } 21 | const texture = new DataTexture(data, size, size, RedFormat, FloatType); 22 | texture.needsUpdate = true; 23 | texture.magFilter = texture.minFilter = LinearFilter; 24 | texture.wrapS = texture.wrapT = RepeatWrapping; 25 | return texture; 26 | }; 27 | 28 | class Dome extends Mesh { 29 | static setupGeometry() { 30 | const geometry = new IcosahedronGeometry(512, 3); 31 | geometry.deleteAttribute('normal'); 32 | Dome.geometry = geometry; 33 | } 34 | 35 | static setupMaterial() { 36 | const { uniforms, vertexShader, fragmentShader } = ShaderLib.basic; 37 | Dome.material = new ShaderMaterial({ 38 | side: BackSide, 39 | uniforms: { 40 | ...UniformsUtils.clone(uniforms), 41 | diffuse: { value: new Color(0.003, 0.005, 0.008) }, 42 | noise: { value: Noise() }, 43 | }, 44 | vertexShader: vertexShader 45 | .replace( 46 | '#include ', 47 | [ 48 | 'varying vec3 fragNormal;', 49 | 'varying float vAltitude;', 50 | 'varying vec2 vNoiseUV;', 51 | '#include ', 52 | ].join('\n') 53 | ) 54 | .replace( 55 | '#if defined ( USE_ENVMAP ) || defined ( USE_SKINNING )', 56 | '#if 1' 57 | ) 58 | .replace( 59 | '#include ', 60 | [ 61 | 'fragNormal = transformedNormal;', 62 | '#include ', 63 | ].join('\n') 64 | ) 65 | .replace( 66 | 'include ', 67 | [ 68 | 'include ', 69 | 'vAltitude = (normalize(position).y + 1.0) * 0.5;', 70 | 'vNoiseUV = uv * vec2(2.0, 4.0);', 71 | 'gl_Position = gl_Position.xyww;', 72 | ].join('\n') 73 | ), 74 | fragmentShader: fragmentShader 75 | .replace( 76 | '#include ', 77 | [ 78 | 'layout(location = 1) out vec4 pc_fragNormal;', 79 | 'varying vec3 fragNormal;', 80 | 'varying float vAltitude;', 81 | 'varying vec2 vNoiseUV;', 82 | 'uniform sampler2D noise;', 83 | '#include ', 84 | ].join('\n') 85 | ) 86 | .replace( 87 | 'vec4 diffuseColor = vec4( diffuse, opacity );', 88 | [ 89 | 'vec4 diffuseColor = vec4(mix(diffuse * 0.5, diffuse * 1.5, vAltitude), opacity);', 90 | 'vec3 granularity = diffuse * 0.03;', 91 | 'diffuseColor.rgb += mix(-granularity, granularity, texture(noise, vNoiseUV).r);', 92 | ].join('\n') 93 | ) 94 | .replace( 95 | '#include ', 96 | [ 97 | '#include ', 98 | 'pc_fragNormal = vec4(normalize(fragNormal), 0.0);', 99 | ].join('\n') 100 | ), 101 | }); 102 | } 103 | 104 | constructor() { 105 | if (!Dome.geometry) { 106 | Dome.setupGeometry(); 107 | } 108 | if (!Dome.material) { 109 | Dome.setupMaterial(); 110 | } 111 | super( 112 | Dome.geometry, 113 | Dome.material 114 | ); 115 | this.frustumCulled = false; 116 | this.matrixAutoUpdate = false; 117 | this.renderOrder = 1; 118 | } 119 | } 120 | 121 | export default Dome; 122 | -------------------------------------------------------------------------------- /example/src/renderables/explosion.js: -------------------------------------------------------------------------------- 1 | import { 2 | BufferAttribute, 3 | Color, 4 | IcosahedronGeometry, 5 | InstancedBufferGeometry, 6 | InstancedBufferAttribute, 7 | Mesh, 8 | ShaderLib, 9 | ShaderMaterial, 10 | UniformsUtils, 11 | } from 'three'; 12 | import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; 13 | 14 | class Explosion extends Mesh { 15 | static setupGeometry() { 16 | const sphere = new IcosahedronGeometry(0.5, 3); 17 | sphere.deleteAttribute('uv'); 18 | const scale = 1 / Explosion.chunks; 19 | sphere.scale(scale, scale, scale); 20 | { 21 | const { count } = sphere.getAttribute('position'); 22 | const color = new BufferAttribute(new Float32Array(count * 3), 3); 23 | let light; 24 | for (let i = 0; i < count; i += 1) { 25 | if (i % 3 === 0) { 26 | light = 1 - Math.random() * 0.1; 27 | } 28 | color.setXYZ(i, light, light, light); 29 | } 30 | sphere.setAttribute('color', color); 31 | } 32 | const model = mergeVertices(sphere); 33 | const geometry = new InstancedBufferGeometry(); 34 | geometry.setIndex(model.getIndex()); 35 | geometry.setAttribute('position', model.getAttribute('position')); 36 | geometry.setAttribute('color', model.getAttribute('color')); 37 | geometry.setAttribute('normal', model.getAttribute('normal')); 38 | const count = Explosion.chunks ** 3; 39 | const stride = 1 / Explosion.chunks; 40 | const offset = new Float32Array(count * 3); 41 | const direction = new Float32Array(count * 3); 42 | for (let v = 0, z = -0.5; z < 0.5; z += stride) { 43 | for (let y = -0.5; y < 0.5; y += stride) { 44 | for (let x = -0.5; x < 0.5; x += stride, v += 3) { 45 | direction[v] = Math.random() - 0.5; 46 | direction[v + 1] = Math.random() - 0.5; 47 | direction[v + 2] = Math.random() - 0.5; 48 | offset[v] = x; 49 | offset[v + 1] = y; 50 | offset[v + 2] = z; 51 | } 52 | } 53 | } 54 | geometry.setAttribute('direction', new InstancedBufferAttribute(direction, 3)); 55 | geometry.setAttribute('offset', new InstancedBufferAttribute(offset, 3)); 56 | Explosion.geometry = geometry; 57 | } 58 | 59 | static setupMaterial() { 60 | const { uniforms, vertexShader, fragmentShader } = ShaderLib.basic; 61 | Explosion.material = new ShaderMaterial({ 62 | vertexColors: true, 63 | uniforms: { 64 | ...UniformsUtils.clone(uniforms), 65 | step: { value: 0 }, 66 | }, 67 | vertexShader: vertexShader 68 | .replace( 69 | '#include ', 70 | [ 71 | '#include ', 72 | 'varying vec3 fragNormal;', 73 | 'attribute vec3 direction;', 74 | 'attribute vec3 offset;', 75 | 'uniform float step;', 76 | ].join('\n') 77 | ) 78 | .replace( 79 | '#if defined ( USE_ENVMAP ) || defined ( USE_SKINNING )', 80 | '#if 1' 81 | ) 82 | .replace( 83 | '#include ', 84 | [ 85 | 'fragNormal = transformedNormal;', 86 | 'vec3 transformed = vec3( position * (2.0 - step * step * 2.0) + direction * step * 5.0 + offset );', 87 | ].join('\n') 88 | ), 89 | fragmentShader: fragmentShader 90 | .replace( 91 | '#include ', 92 | [ 93 | '#include ', 94 | 'layout(location = 1) out vec4 pc_fragNormal;', 95 | 'varying vec3 fragNormal;', 96 | ].join('\n') 97 | ) 98 | .replace( 99 | '#include ', 100 | [ 101 | '#include ', 102 | 'pc_fragNormal = vec4(normalize(fragNormal), 0.0);', 103 | ].join('\n') 104 | ), 105 | }); 106 | } 107 | 108 | constructor() { 109 | if (!Explosion.geometry) { 110 | Explosion.setupGeometry(); 111 | } 112 | if (!Explosion.material) { 113 | Explosion.setupMaterial(); 114 | } 115 | super( 116 | Explosion.geometry, 117 | Explosion.material 118 | ); 119 | this.color = new Color(); 120 | this.frustumCulled = false; 121 | this.matrixAutoUpdate = false; 122 | } 123 | 124 | onAnimationTick(delta) { 125 | const { step } = this; 126 | this.step = Math.min(step + delta * 3, 1); 127 | return this.step >= 1; 128 | } 129 | 130 | onBeforeRender() { 131 | const { color, material, step } = this; 132 | material.uniforms.diffuse.value.copy(color); 133 | material.uniforms.step.value = step; 134 | material.uniformsNeedUpdate = true; 135 | } 136 | } 137 | 138 | Explosion.chunks = 4; 139 | 140 | export default Explosion; 141 | -------------------------------------------------------------------------------- /example/src/renderables/projectile.js: -------------------------------------------------------------------------------- 1 | import { 2 | BufferAttribute, 3 | Color, 4 | IcosahedronGeometry, 5 | Mesh, 6 | ShaderLib, 7 | ShaderMaterial, 8 | UniformsUtils, 9 | Vector3, 10 | } from 'three'; 11 | 12 | class Projectile extends Mesh { 13 | static setupGeometry() { 14 | const geometry = new IcosahedronGeometry(0.25, 3); 15 | geometry.deleteAttribute('uv'); 16 | const color = new BufferAttribute(new Float32Array(geometry.getAttribute('position').count * 3), 3); 17 | let light; 18 | for (let i = 0; i < color.count; i++) { 19 | if (i % 3 === 0) { 20 | light = 1 - Math.random() * 0.1; 21 | } 22 | color.setXYZ(i, light, light, light); 23 | } 24 | geometry.setAttribute('color', color); 25 | Projectile.geometry = geometry; 26 | } 27 | 28 | static setupMaterial() { 29 | const { uniforms, vertexShader, fragmentShader } = ShaderLib.basic; 30 | Projectile.material = new ShaderMaterial({ 31 | vertexColors: true, 32 | uniforms: UniformsUtils.clone(uniforms), 33 | vertexShader: vertexShader 34 | .replace( 35 | '#include ', 36 | [ 37 | '#include ', 38 | 'varying vec3 fragNormal;', 39 | ].join('\n') 40 | ) 41 | .replace( 42 | '#if defined ( USE_ENVMAP ) || defined ( USE_SKINNING )', 43 | '#if 1' 44 | ) 45 | .replace( 46 | '#include ', 47 | [ 48 | '#include ', 49 | 'fragNormal = transformedNormal;', 50 | ].join('\n') 51 | ), 52 | fragmentShader: fragmentShader 53 | .replace( 54 | '#include ', 55 | [ 56 | '#include ', 57 | 'layout(location = 1) out vec4 pc_fragNormal;', 58 | 'varying vec3 fragNormal;', 59 | ].join('\n') 60 | ) 61 | .replace( 62 | '#include ', 63 | [ 64 | '#include ', 65 | 'pc_fragNormal = vec4(normalize(fragNormal), 0.0);', 66 | ].join('\n') 67 | ), 68 | }); 69 | } 70 | 71 | constructor() { 72 | if (!Projectile.geometry) { 73 | Projectile.setupGeometry(); 74 | } 75 | if (!Projectile.material) { 76 | Projectile.setupMaterial(); 77 | } 78 | super(Projectile.geometry, Projectile.material); 79 | this.color = new Color(); 80 | this.direction = new Vector3(); 81 | } 82 | 83 | onAnimationTick(step) { 84 | const { position, direction } = this; 85 | position.addScaledVector(direction, step); 86 | this.distance += step; 87 | } 88 | 89 | onBeforeRender() { 90 | const { color, material } = this; 91 | material.uniforms.diffuse.value.copy(color); 92 | material.uniformsNeedUpdate = true; 93 | } 94 | } 95 | 96 | export default Projectile; 97 | -------------------------------------------------------------------------------- /example/src/renderables/rain.js: -------------------------------------------------------------------------------- 1 | import { 2 | BoxGeometry, 3 | Color, 4 | DynamicDrawUsage, 5 | InstancedBufferGeometry, 6 | InstancedBufferAttribute, 7 | Mesh, 8 | ShaderLib, 9 | ShaderMaterial, 10 | UniformsUtils, 11 | Vector3, 12 | } from 'three'; 13 | 14 | const _voxel = new Vector3(); 15 | 16 | class Rain extends Mesh { 17 | static setupGeometry() { 18 | let drop = new BoxGeometry(0.05, 0.5, 0.05); 19 | drop.deleteAttribute('uv'); 20 | drop.translate(0, 0.25, 0); 21 | Rain.geometry = { 22 | index: drop.getIndex(), 23 | position: drop.getAttribute('position'), 24 | normal: drop.getAttribute('normal'), 25 | }; 26 | } 27 | 28 | static setupMaterial() { 29 | const { uniforms, vertexShader, fragmentShader } = ShaderLib.basic; 30 | Rain.material = new ShaderMaterial({ 31 | uniforms: { 32 | ...UniformsUtils.clone(uniforms), 33 | diffuse: { value: new Color(0x224466) }, 34 | }, 35 | vertexShader: vertexShader 36 | .replace( 37 | '#include ', 38 | [ 39 | 'attribute vec3 offset;', 40 | 'varying vec3 fragNormal;', 41 | '#include ', 42 | ].join('\n') 43 | ) 44 | .replace( 45 | '#if defined ( USE_ENVMAP ) || defined ( USE_SKINNING )', 46 | '#if 1' 47 | ) 48 | .replace( 49 | '#include ', 50 | [ 51 | 'vec3 transformed = vec3(position + offset);', 52 | 'fragNormal = transformedNormal;', 53 | ].join('\n') 54 | ), 55 | fragmentShader: fragmentShader 56 | .replace( 57 | '#include ', 58 | [ 59 | '#include ', 60 | 'layout(location = 1) out vec4 pc_fragNormal;', 61 | 'varying vec3 fragNormal;', 62 | ].join('\n') 63 | ) 64 | .replace( 65 | '#include ', 66 | [ 67 | '#include ', 68 | 'pc_fragNormal = vec4(normalize(fragNormal), 0.0);', 69 | ].join('\n') 70 | ), 71 | }); 72 | } 73 | 74 | constructor({ 75 | minY = 0, 76 | world, 77 | }) { 78 | if (!Rain.geometry) { 79 | Rain.setupGeometry(); 80 | } 81 | if (!Rain.material) { 82 | Rain.setupMaterial(); 83 | } 84 | const geometry = new InstancedBufferGeometry(); 85 | geometry.setIndex(Rain.geometry.index); 86 | geometry.setAttribute('position', Rain.geometry.position); 87 | geometry.setAttribute('offset', (new InstancedBufferAttribute(new Float32Array(Rain.numDrops * 3), 3).setUsage(DynamicDrawUsage))); 88 | super( 89 | geometry, 90 | Rain.material 91 | ); 92 | this.dropMinY = minY; 93 | this.targets = new Float32Array(Rain.numDrops); 94 | this.frustumCulled = false; 95 | this.matrixAutoUpdate = false; 96 | this.visible = false; 97 | this.world = world; 98 | } 99 | 100 | dispose() { 101 | const { geometry } = this; 102 | geometry.dispose(); 103 | } 104 | 105 | onAnimationTick(delta, anchor) { 106 | if (!this.visible) { 107 | return; 108 | } 109 | const { geometry, targets } = this; 110 | const offsets = geometry.getAttribute('offset'); 111 | for (let i = 0; i < Rain.numDrops; i += 1) { 112 | const y = offsets.getY(i) - (delta * (20 + (i % 10))); 113 | const height = targets[i]; 114 | if (y > height) { 115 | offsets.setY(i, y); 116 | } else { 117 | this.resetDrop(anchor, i); 118 | } 119 | } 120 | offsets.needsUpdate = true; 121 | } 122 | 123 | resetDrop(anchor, i) { 124 | const { radius } = Rain; 125 | const { 126 | geometry, 127 | dropMinY, 128 | targets, 129 | world, 130 | } = this; 131 | _voxel 132 | .set(Math.random() - 0.5, 0, Math.random() - 0.5) 133 | .normalize() 134 | .multiplyScalar(radius * Math.random()) 135 | .add(anchor); 136 | const offsets = geometry.getAttribute('offset'); 137 | offsets.setX(i, _voxel.x); 138 | offsets.setZ(i, _voxel.z); 139 | 140 | _voxel.divide(world.scale).floor(); 141 | let height = dropMinY; 142 | if ( 143 | _voxel.x >= 0 && _voxel.x < world.volume.width 144 | && _voxel.z >= 0 && _voxel.z < world.volume.depth 145 | ) { 146 | height = Math.max(world.volume.memory.height.view[_voxel.z * world.volume.width + _voxel.x] + 1, dropMinY); 147 | } 148 | height *= world.scale.y; 149 | targets[i] = height; 150 | offsets.setY(i, Math.max(anchor.y + Math.random() * radius * 2, height)); 151 | offsets.needsUpdate = true; 152 | } 153 | 154 | reset(anchor) { 155 | const { numDrops } = Rain; 156 | for (let i = 0; i < numDrops; i += 1) { 157 | this.resetDrop(anchor, i); 158 | } 159 | } 160 | } 161 | 162 | Rain.numDrops = 8000; 163 | Rain.radius = 40; 164 | 165 | export default Rain; 166 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cubitos", 3 | "author": "Daniel Esteban Nombela", 4 | "license": "MIT", 5 | "scripts": { 6 | "start": "run-p watch:module watch:wasm serve:example", 7 | "postinstall": "cd example && npm install", 8 | "build:module": "rollup -c rollup.config.js", 9 | "build:wasm": "sh src/compile.sh", 10 | "serve:example": "cd example && npm start", 11 | "watch:module": "npm run build:module -- -w", 12 | "watch:wasm": "npm-watch build:wasm" 13 | }, 14 | "watch": { 15 | "build:wasm": { 16 | "extensions": "c", 17 | "patterns": [ 18 | "src/*.c" 19 | ], 20 | "runOnChangeOnly": true 21 | } 22 | }, 23 | "devDependencies": { 24 | "@rollup/plugin-wasm": "^5.2.0", 25 | "npm-run-all": "^4.1.5", 26 | "npm-watch": "^0.11.0", 27 | "rollup": "^2.77.0", 28 | "rollup-plugin-copy": "^3.4.0", 29 | "rollup-plugin-terser": "^7.0.2", 30 | "rollup-plugin-web-worker-loader": "^1.6.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import copy from 'rollup-plugin-copy'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | import wasm from '@rollup/plugin-wasm'; 6 | import webWorkerLoader from 'rollup-plugin-web-worker-loader'; 7 | 8 | const outputPath = path.resolve(__dirname, 'dist'); 9 | 10 | export default { 11 | input: path.join(__dirname, 'src', 'module.js'), 12 | external: ['three'], 13 | output: { 14 | file: path.join(outputPath, 'cubitos.js'), 15 | format: 'esm', 16 | }, 17 | plugins: [ 18 | copy({ 19 | targets: [ 20 | { src: 'LICENSE', dest: 'dist' }, 21 | { src: 'README.md', dest: 'dist' }, 22 | ], 23 | copyOnce: !process.env.ROLLUP_WATCH, 24 | }), 25 | wasm({ 26 | maxFileSize: Infinity, 27 | }), 28 | webWorkerLoader({ 29 | forceInline: true, 30 | skipPlugins: ['copy', 'wasm'], 31 | }), 32 | { 33 | writeBundle() { 34 | fs.writeFileSync(path.join(outputPath, 'package.json'), JSON.stringify({ 35 | name: 'cubitos', 36 | author: 'Daniel Esteban Nombela', 37 | license: 'MIT', 38 | module: 'cubitos.js', 39 | version: '0.0.22', 40 | repository: { 41 | type: 'git', 42 | url: 'https://github.com/danielesteban/cubitos', 43 | }, 44 | peerDependencies: { 45 | three: '>=0.142.0', 46 | }, 47 | }, null, ' ')); 48 | }, 49 | }, 50 | ...(!process.env.ROLLUP_WATCH ? [terser()] : []), 51 | ], 52 | watch: { clearScreen: false }, 53 | }; 54 | -------------------------------------------------------------------------------- /src/chunk.js: -------------------------------------------------------------------------------- 1 | import { 2 | InstancedBufferGeometry, 3 | InstancedInterleavedBuffer, 4 | InterleavedBufferAttribute, 5 | Matrix4, 6 | Mesh, 7 | PlaneGeometry, 8 | Sphere, 9 | Vector4, 10 | } from 'three'; 11 | 12 | const _face = new Vector4(); 13 | const _intersects = []; 14 | const _sphere = new Sphere(); 15 | const _translation = new Matrix4(); 16 | 17 | class Chunk extends Mesh { 18 | static setupGeometry() { 19 | const face = new PlaneGeometry(1, 1, 1, 1); 20 | face.translate(0, 0, 0.5); 21 | const uv = face.getAttribute('uv'); 22 | for (let i = 0, l = uv.count; i < l; i++) { 23 | uv.setXY(i, uv.getX(i), 1.0 - uv.getY(i)); 24 | } 25 | Chunk.geometry = { 26 | index: face.getIndex(), 27 | position: face.getAttribute('position'), 28 | normal: face.getAttribute('normal'), 29 | uv, 30 | instance: new Mesh(face), 31 | rotations: Array.from({ length: 6 }, (v, i) => { 32 | const rotation = new Matrix4(); 33 | switch (i) { 34 | case 1: 35 | rotation.makeRotationX(Math.PI * -0.5); 36 | break; 37 | case 2: 38 | rotation.makeRotationX(Math.PI * 0.5); 39 | break; 40 | case 3: 41 | rotation.makeRotationY(Math.PI * -0.5); 42 | break; 43 | case 4: 44 | rotation.makeRotationY(Math.PI * 0.5); 45 | break; 46 | case 5: 47 | rotation.makeRotationY(Math.PI); 48 | break; 49 | } 50 | return rotation; 51 | }), 52 | }; 53 | } 54 | 55 | constructor({ material, position, volume }) { 56 | if (!Chunk.geometry) { 57 | Chunk.setupGeometry(); 58 | } 59 | const geometry = new InstancedBufferGeometry(); 60 | geometry.boundingSphere = new Sphere(); 61 | geometry.setIndex(Chunk.geometry.index); 62 | geometry.setAttribute('position', Chunk.geometry.position); 63 | geometry.setAttribute('normal', Chunk.geometry.normal); 64 | geometry.setAttribute('uv', Chunk.geometry.uv); 65 | super(geometry, material); 66 | this.matrixAutoUpdate = false; 67 | this.position.copy(position).multiplyScalar(volume.chunkSize); 68 | this.volume = volume; 69 | this.updateMatrix(); 70 | this.update(); 71 | } 72 | 73 | raycast(raycaster, intersects) { 74 | const { instance, rotations } = Chunk.geometry; 75 | const { geometry, matrixWorld, visible } = this; 76 | if (!visible) { 77 | return; 78 | } 79 | _sphere.copy(geometry.boundingSphere); 80 | _sphere.applyMatrix4(matrixWorld); 81 | if (!raycaster.ray.intersectsSphere(_sphere)) { 82 | return; 83 | } 84 | const face = geometry.getAttribute('face'); 85 | for (let i = 0, l = geometry.instanceCount; i < l; i++) { 86 | _face.fromBufferAttribute(face, i); 87 | instance.matrixWorld 88 | .multiplyMatrices(matrixWorld, _translation.makeTranslation(_face.x, _face.y, _face.z)) 89 | .multiply(rotations[Math.floor(_face.w % 6)]); 90 | instance.raycast(raycaster, _intersects); 91 | _intersects.forEach((intersect) => { 92 | intersect.object = this; 93 | intersect.face.normal.transformDirection(instance.matrixWorld); 94 | intersects.push(intersect); 95 | }); 96 | _intersects.length = 0; 97 | } 98 | } 99 | 100 | update() { 101 | const { geometry, position, volume } = this; 102 | const { bounds, count, faces } = volume.mesh(position); 103 | if (!count) { 104 | this.visible = false; 105 | return; 106 | } 107 | geometry.boundingSphere.center.set(bounds[0], bounds[1], bounds[2]); 108 | geometry.boundingSphere.radius = bounds[3]; 109 | const buffer = new InstancedInterleavedBuffer(faces, 8, 1); 110 | geometry.setAttribute('face', new InterleavedBufferAttribute(buffer, 4, 0)); 111 | geometry.setAttribute('light', new InterleavedBufferAttribute(buffer, 4, 4)); 112 | geometry.instanceCount = geometry._maxInstanceCount = count; 113 | this.visible = true; 114 | } 115 | } 116 | 117 | export default Chunk; 118 | -------------------------------------------------------------------------------- /src/chunkmaterial.js: -------------------------------------------------------------------------------- 1 | import { 2 | Color, 3 | DataArrayTexture, 4 | ShaderLib, 5 | ShaderMaterial, 6 | sRGBEncoding, 7 | UniformsUtils, 8 | } from 'three'; 9 | 10 | class ChunkMaterial extends ShaderMaterial { 11 | constructor({ 12 | atlas, 13 | ambientColor = new Color(0, 0, 0), 14 | light1Color = new Color(1, 1, 1), 15 | light2Color = new Color(1, 1, 1), 16 | light3Color = new Color(1, 1, 1), 17 | sunlightColor = new Color(1, 1, 1), 18 | light = true, 19 | } = {}) { 20 | const { uniforms, vertexShader, fragmentShader } = ShaderLib.basic; 21 | super({ 22 | defines: { 23 | USE_LIGHT: !!light, 24 | }, 25 | uniforms: { 26 | ...UniformsUtils.clone(uniforms), 27 | atlas: { value: null }, 28 | ambientColor: { value: ambientColor }, 29 | light1Color: { value: light1Color }, 30 | light2Color: { value: light2Color }, 31 | light3Color: { value: light3Color }, 32 | sunlightColor: { value: sunlightColor }, 33 | }, 34 | vertexShader: vertexShader 35 | .replace( 36 | '#include ', 37 | [ 38 | '#include ', 39 | 'attribute vec4 face;', 40 | 'varying vec3 fragNormal;', 41 | '#ifdef USE_ATLAS', 42 | 'varying vec3 fragUV;', 43 | '#endif', 44 | '#ifdef USE_LIGHT', 45 | 'attribute vec4 light;', 46 | 'uniform vec3 ambientColor;', 47 | 'uniform vec3 light1Color;', 48 | 'uniform vec3 light2Color;', 49 | 'uniform vec3 light3Color;', 50 | 'uniform vec3 sunlightColor;', 51 | 'varying vec3 fragLight;', 52 | '#endif', 53 | 'mat3 rotateX(const in float rad) {', 54 | ' float c = cos(rad);', 55 | ' float s = sin(rad);', 56 | ' return mat3(', 57 | ' 1.0, 0.0, 0.0,', 58 | ' 0.0, c, s,', 59 | ' 0.0, -s, c', 60 | ' );', 61 | '}', 62 | 'mat3 rotateY(const in float rad) {', 63 | ' float c = cos(rad);', 64 | ' float s = sin(rad);', 65 | ' return mat3(', 66 | ' c, 0.0, -s,', 67 | ' 0.0, 1.0, 0.0,', 68 | ' s, 0.0, c', 69 | ' );', 70 | '}', 71 | ].join('\n') 72 | ) 73 | .replace( 74 | '#if defined ( USE_ENVMAP ) || defined ( USE_SKINNING )', 75 | '#if 1' 76 | ) 77 | .replace( 78 | '#include ', 79 | '', 80 | ) 81 | .replace( 82 | '#include ', 83 | [ 84 | 'vec4 mvPosition = vec4(transformed, 1.0);', 85 | 'mat3 rot;', 86 | 'switch (int(mod(face.w, 6.0))) {', 87 | ' default:', 88 | ' rot = mat3(1.0);', 89 | ' break;', 90 | ' case 1:', 91 | ' rot = rotateX(PI * -0.5);', 92 | ' break;', 93 | ' case 2:', 94 | ' rot = rotateX(PI * 0.5);', 95 | ' break;', 96 | ' case 3:', 97 | ' rot = rotateY(PI * -0.5);', 98 | ' break;', 99 | ' case 4:', 100 | ' rot = rotateY(PI * 0.5);', 101 | ' break;', 102 | ' case 5:', 103 | ' rot = rotateY(PI);', 104 | ' break;', 105 | '}', 106 | 'mvPosition.xyz = (rot * mvPosition.xyz) + face.xyz;', 107 | 'mvPosition = modelViewMatrix * mvPosition;', 108 | 'gl_Position = projectionMatrix * mvPosition;', 109 | 'fragNormal = normalMatrix * rot * objectNormal;', 110 | '#ifdef USE_ATLAS', 111 | 'fragUV = vec3(uv, floor(face.w / 6.0));', 112 | '#endif', 113 | '#ifdef USE_LIGHT', 114 | 'vec3 lightColor = sunlightColor * light.x + light1Color * light.y + light2Color * light.z + light3Color * light.w;', 115 | 'fragLight = max(ambientColor, lightColor);', 116 | '#endif', 117 | ].join('\n') 118 | ), 119 | fragmentShader: fragmentShader 120 | .replace( 121 | '#include ', 122 | [ 123 | '#include ', 124 | 'layout(location = 1) out vec4 pc_fragNormal;', 125 | 'varying vec3 fragNormal;', 126 | '#ifdef USE_ATLAS', 127 | 'precision highp sampler2DArray;', 128 | 'uniform sampler2DArray atlas;', 129 | 'varying vec3 fragUV;', 130 | '#endif', 131 | '#ifdef USE_LIGHT', 132 | 'varying vec3 fragLight;', 133 | '#endif', 134 | ].join('\n') 135 | ) 136 | .replace( 137 | '#include ', 138 | [ 139 | '#include ', 140 | '#ifdef USE_ATLAS', 141 | 'diffuseColor *= texture(atlas, fragUV);', 142 | '#endif', 143 | '#ifdef USE_LIGHT', 144 | 'diffuseColor.rgb *= fragLight;', 145 | '#endif', 146 | ].join('\n'), 147 | ) 148 | .replace( 149 | '#include ', 150 | [ 151 | '#include ', 152 | 'pc_fragNormal = vec4(normalize(fragNormal), 0.0);', 153 | ].join('\n') 154 | ), 155 | }); 156 | this.setAtlas(atlas); 157 | } 158 | 159 | setAtlas(atlas) { 160 | const { defines, uniforms } = this; 161 | if (atlas && !atlas.isDataArrayTexture) { 162 | const canvas = document.createElement('canvas'); 163 | const ctx = canvas.getContext('2d'); 164 | canvas.width = atlas.image.width; 165 | canvas.height = atlas.image.height; 166 | ctx.imageSmoothingEnabled = false; 167 | ctx.drawImage(atlas.image, 0, 0); 168 | atlas = new DataArrayTexture( 169 | ctx.getImageData(0, 0, canvas.width, canvas.height).data, 170 | canvas.width, 171 | canvas.width, 172 | canvas.height / canvas.width 173 | ); 174 | atlas.encoding = sRGBEncoding; 175 | atlas.needsUpdate = true; 176 | } 177 | if (defines.USE_ATLAS !== !!atlas) { 178 | defines.USE_ATLAS = !!atlas; 179 | this.needsUpdate = true; 180 | } 181 | uniforms.atlas.value = atlas || null; 182 | } 183 | } 184 | 185 | export default ChunkMaterial; 186 | -------------------------------------------------------------------------------- /src/compile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd "${0%/*}" 3 | 4 | clang --target=wasm32-unknown-wasi --sysroot=../vendor/wasi-libc/sysroot -nostartfiles -flto -Ofast \ 5 | -Wl,--import-memory -Wl,--import-undefined -Wl,--no-entry -Wl,--lto-O3 \ 6 | -Wl,--export=malloc \ 7 | -Wl,--export=ground \ 8 | -Wl,--export=mesh \ 9 | -Wl,--export=pathfind \ 10 | -Wl,--export=propagate \ 11 | -Wl,--export=update \ 12 | -Wl,--export=voxel \ 13 | -o ./volume.wasm ./volume.c 14 | 15 | clang --target=wasm32-unknown-wasi --sysroot=../vendor/wasi-libc/sysroot -nostartfiles -flto -Ofast \ 16 | -Wl,--import-memory -Wl,--no-entry -Wl,--lto-O3 \ 17 | -Wl,--export=malloc \ 18 | -Wl,--export=generate \ 19 | -o ./worldgen.wasm ./worldgen.c 20 | -------------------------------------------------------------------------------- /src/module.js: -------------------------------------------------------------------------------- 1 | export { default as Chunk } from './chunk.js'; 2 | export { default as ChunkMaterial } from './chunkmaterial.js'; 3 | export { default as Volume } from './volume.js'; 4 | export { default as World } from './world.js'; 5 | export { default as Worldgen } from './worldgen.js'; 6 | -------------------------------------------------------------------------------- /src/volume.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include "../vendor/AStar/AStar.c" 3 | 4 | typedef struct { 5 | int x; 6 | int y; 7 | int z; 8 | } Voxel; 9 | 10 | typedef struct { 11 | Voxel min; 12 | Voxel max; 13 | } Box; 14 | 15 | typedef struct { 16 | const int width; 17 | const int height; 18 | const int depth; 19 | const int chunkSize; 20 | const int maxLight; 21 | } Volume; 22 | 23 | typedef struct { 24 | const Volume* volume; 25 | const unsigned char* voxels; 26 | const unsigned char* obstacles; 27 | const int height; 28 | const int maxVisited; 29 | const int minY; 30 | const int maxY; 31 | } PathContext; 32 | 33 | enum LightChannels { 34 | LIGHT_CHANNEL_SUNLIGHT, 35 | LIGHT_CHANNEL_LIGHT1, 36 | LIGHT_CHANNEL_LIGHT2, 37 | LIGHT_CHANNEL_LIGHT3, 38 | LIGHT_CHANNELS 39 | }; 40 | 41 | typedef unsigned char Light[LIGHT_CHANNELS]; 42 | 43 | static const Voxel lightNeighbors[6] = { 44 | { 0, -1, 0 }, 45 | { 0, 1, 0 }, 46 | { -1, 0, 0 }, 47 | { 1, 0, 0 }, 48 | { 0, 0, -1 }, 49 | { 0, 0, 1 } 50 | }; 51 | 52 | static const Voxel meshNormals[6][3] = { 53 | { { 0, 0, 1 }, { 0, 1, 0 }, { 1, 0, 0 }, }, 54 | { { 0, 1, 0 }, { 0, 0, -1 }, { 1, 0, 0 }, }, 55 | { { 0, -1, 0 }, { 0, 0, 1 }, { 1, 0, 0 }, }, 56 | { { -1, 0, 0 }, { 0, 1, 0 }, { 0, 0, 1 }, }, 57 | { { 1, 0, 0 }, { 0, 1, 0 }, { 0, 0, 1 }, }, 58 | { { 0, 0, -1 }, { 0, 1, 0 }, { -1, 0, 0 } }, 59 | }; 60 | 61 | static const int meshLightSamples[] = { 62 | 0, 0, 63 | -1, 0, 64 | 1, 0, 65 | 0, -1, 66 | 0, 1 67 | }; 68 | 69 | static const int horizontalNeighbors[] = { 70 | -1, 0, 71 | 1, 0, 72 | 0, -1, 73 | 0, 1 74 | }; 75 | 76 | static const int verticalNeighbors[] = { 77 | 0, 78 | 1, 79 | -1 80 | }; 81 | 82 | const int voxel( 83 | const Volume* volume, 84 | const int x, 85 | const int y, 86 | const int z 87 | ) { 88 | if ( 89 | x < 0 || x >= volume->width 90 | || y < 0 || y >= volume->height 91 | || z < 0 || z >= volume->depth 92 | ) { 93 | return -1; 94 | } 95 | return z * volume->width * volume->height + y * volume->width + x; 96 | } 97 | 98 | static void grow( 99 | Box* box, 100 | const int x, 101 | const int y, 102 | const int z 103 | ) { 104 | if (box == NULL) return; 105 | if (box->min.x > x) box->min.x = x; 106 | if (box->min.y > y) box->min.y = y; 107 | if (box->min.z > z) box->min.z = z; 108 | if (box->max.x < x) box->max.x = x; 109 | if (box->max.y < y) box->max.y = y; 110 | if (box->max.z < z) box->max.z = z; 111 | } 112 | 113 | static void floodLight( 114 | Box* bounds, 115 | const unsigned char channel, 116 | const Volume* volume, 117 | unsigned char* voxels, 118 | int* height, 119 | Light* light, 120 | int* queue, 121 | const unsigned int size, 122 | int* next 123 | ) { 124 | unsigned int nextLength = 0; 125 | for (unsigned int q = 0; q < size; q++) { 126 | const int i = queue[q]; 127 | const unsigned char level = light[i][channel]; 128 | if (level == 0) { 129 | continue; 130 | } 131 | const int z = floor(i / (volume->width * volume->height)), 132 | y = floor((i % (volume->width * volume->height)) / volume->width), 133 | x = floor((i % (volume->width * volume->height)) % volume->width); 134 | for (unsigned char n = 0; n < 6; n++) { 135 | const int nx = x + lightNeighbors[n].x, 136 | ny = y + lightNeighbors[n].y, 137 | nz = z + lightNeighbors[n].z, 138 | neighbor = voxel(volume, nx, ny, nz); 139 | const unsigned char nl = level - ( 140 | channel == LIGHT_CHANNEL_SUNLIGHT && n == 0 && level == volume->maxLight ? 0 : 1 141 | ); 142 | if ( 143 | neighbor == -1 144 | || light[neighbor][channel] >= nl 145 | || voxels[neighbor] 146 | || ( 147 | channel == LIGHT_CHANNEL_SUNLIGHT 148 | && n != 0 149 | && level == volume->maxLight 150 | && ny > height[nz * volume->width + nx] 151 | ) 152 | ) { 153 | continue; 154 | } 155 | light[neighbor][channel] = nl; 156 | next[nextLength++] = neighbor; 157 | grow(bounds, nx, ny, nz); 158 | } 159 | } 160 | if (nextLength > 0) { 161 | floodLight( 162 | bounds, 163 | channel, 164 | volume, 165 | voxels, 166 | height, 167 | light, 168 | next, 169 | nextLength, 170 | queue 171 | ); 172 | } 173 | } 174 | 175 | static void removeLight( 176 | Box* bounds, 177 | const unsigned char channel, 178 | const Volume* volume, 179 | unsigned char* voxels, 180 | int* height, 181 | Light* light, 182 | int* queue, 183 | const unsigned int size, 184 | int* next, 185 | int* floodQueue, 186 | unsigned int floodQueueSize 187 | ) { 188 | unsigned int nextLength = 0; 189 | for (int q = 0; q < size; q += 2) { 190 | const int i = queue[q]; 191 | const unsigned char level = queue[q + 1]; 192 | const int z = floor(i / (volume->width * volume->height)), 193 | y = floor((i % (volume->width * volume->height)) / volume->width), 194 | x = floor((i % (volume->width * volume->height)) % volume->width); 195 | for (unsigned char n = 0; n < 6; n++) { 196 | const int nx = x + lightNeighbors[n].x, 197 | ny = y + lightNeighbors[n].y, 198 | nz = z + lightNeighbors[n].z, 199 | neighbor = voxel(volume, nx, ny, nz); 200 | if (neighbor == -1 || voxels[neighbor]) { 201 | continue; 202 | } 203 | const unsigned char nl = light[neighbor][channel]; 204 | if (nl == 0) { 205 | continue; 206 | } 207 | if ( 208 | nl < level 209 | || ( 210 | channel == LIGHT_CHANNEL_SUNLIGHT 211 | && n == 0 212 | && level == volume->maxLight 213 | && nl == volume->maxLight 214 | ) 215 | ) { 216 | next[nextLength++] = neighbor; 217 | next[nextLength++] = nl; 218 | light[neighbor][channel] = 0; 219 | grow(bounds, nx, ny, nz); 220 | } else if (nl >= level) { 221 | floodQueue[floodQueueSize++] = neighbor; 222 | } 223 | } 224 | } 225 | if (nextLength > 0) { 226 | removeLight( 227 | bounds, 228 | channel, 229 | volume, 230 | voxels, 231 | height, 232 | light, 233 | next, 234 | nextLength, 235 | queue, 236 | floodQueue, 237 | floodQueueSize 238 | ); 239 | return; 240 | } 241 | if (floodQueueSize > 0) { 242 | floodLight( 243 | bounds, 244 | channel, 245 | volume, 246 | voxels, 247 | height, 248 | light, 249 | floodQueue, 250 | floodQueueSize, 251 | queue 252 | ); 253 | } 254 | } 255 | 256 | static const float lighting( 257 | const Volume* volume, 258 | const unsigned char* voxels, 259 | const Light* light, 260 | const unsigned char face, 261 | const unsigned char channel, 262 | const int x, 263 | const int y, 264 | const int z 265 | ) { 266 | const int vx = meshNormals[face][1].x, 267 | vy = meshNormals[face][1].y, 268 | vz = meshNormals[face][1].z, 269 | ux = meshNormals[face][2].x, 270 | uy = meshNormals[face][2].y, 271 | uz = meshNormals[face][2].z; 272 | float level = 0.0f; 273 | unsigned char count = 0; 274 | for (int s = 0; s < 5; s++) { 275 | const int u = meshLightSamples[s * 2], 276 | v = meshLightSamples[s * 2 + 1], 277 | n = voxel( 278 | volume, 279 | x + ux * u + vx * v, 280 | y + uy * u + vy * v, 281 | z + uz * u + vz * v 282 | ); 283 | if (s == 0 || (n != -1 && !voxels[n] && light[n][channel])) { 284 | level += light[n][channel]; 285 | count++; 286 | } 287 | } 288 | return level / count / volume->maxLight; 289 | } 290 | 291 | static const bool canGoThrough( 292 | const PathContext* context, 293 | const int x, 294 | const int y, 295 | const int z 296 | ) { 297 | for (int h = 0; h < context->height; h++) { 298 | const int i = voxel(context->volume, x, y + h, z); 299 | if (i == -1 || context->voxels[i] || context->obstacles[i]) { 300 | return false; 301 | } 302 | } 303 | return true; 304 | } 305 | 306 | static const bool canStepAt( 307 | const PathContext* context, 308 | const int x, 309 | const int y, 310 | const int z 311 | ) { 312 | if ((y - 1) < context->minY || (y - 1) > context->maxY) { 313 | return false; 314 | } 315 | const int i = voxel(context->volume, x, y - 1, z); 316 | if (i == -1 || !context->voxels[i] || context->obstacles[i]) { 317 | return false; 318 | } 319 | return canGoThrough(context, x, y, z); 320 | } 321 | 322 | static void PathNodeNeighbors(ASNeighborList neighbors, void* pathNode, void* pathContext) { 323 | Voxel* node = (Voxel*) pathNode; 324 | PathContext* context = (PathContext*) pathContext; 325 | for (int i = 0; i < 8; i += 2) { 326 | const int x = horizontalNeighbors[i]; 327 | const int z = horizontalNeighbors[i + 1]; 328 | for (int j = 0; j < 3; j++) { 329 | const int y = verticalNeighbors[j]; 330 | if (canStepAt(context, node->x + x, node->y + y, node->z + z)) { 331 | ASNeighborListAdd(neighbors, &(Voxel){node->x + x, node->y + y, node->z + z}, j > 0 ? 2 : 1); 332 | } 333 | } 334 | } 335 | } 336 | 337 | static float PathNodeHeuristic(void* fromNode, void* toNode, void* context) { 338 | Voxel* from = (Voxel*) fromNode; 339 | Voxel* to = (Voxel*) toNode; 340 | return abs(from->x - to->x) + abs(from->y - to->y) + abs(from->z - to->z); 341 | } 342 | 343 | static int EarlyExit(size_t visitedCount, void* visitingNode, void* goalNode, void* context) { 344 | if (visitedCount > ((PathContext*) context)->maxVisited) { 345 | return -1; 346 | } 347 | return 0; 348 | } 349 | 350 | static const ASPathNodeSource PathNodeSource = { 351 | sizeof(Voxel), 352 | &PathNodeNeighbors, 353 | &PathNodeHeuristic, 354 | &EarlyExit, 355 | NULL 356 | }; 357 | 358 | const int ground( 359 | const Volume* volume, 360 | const unsigned char* voxels, 361 | const int height, 362 | const int x, 363 | int y, 364 | const int z 365 | ) { 366 | if ( 367 | x < 0 || x >= volume->width 368 | || y < 0 || y >= volume->height 369 | || z < 0 || z >= volume->depth 370 | || voxels[voxel(volume, x, y, z)] 371 | ) { 372 | return -1; 373 | } 374 | y--; 375 | for (; y >= 0; y--) { 376 | if (!voxels[voxel(volume, x, y, z)]) { 377 | continue; 378 | } 379 | for (int h = 1; h <= height; h++) { 380 | if (voxels[voxel(volume, x, y + h, z)]) { 381 | return -1; 382 | } 383 | } 384 | return y + 1; 385 | } 386 | return 0; 387 | } 388 | 389 | int mapping(int face, int value, int x, int y, int z); 390 | 391 | int mesh( 392 | const Volume* volume, 393 | const unsigned char* voxels, 394 | const Light* light, 395 | float* faces, 396 | float* sphere, 397 | Box* box, 398 | const int chunkX, 399 | const int chunkY, 400 | const int chunkZ 401 | ) { 402 | box->min.x = box->min.y = box->min.z = volume->chunkSize; 403 | box->max.x = box->max.y = box->max.z = 0; 404 | int count = 0; 405 | int offset = 0; 406 | for (int z = chunkZ; z < chunkZ + volume->chunkSize; z++) { 407 | for (int y = chunkY; y < chunkY + volume->chunkSize; y++) { 408 | for (int x = chunkX; x < chunkX + volume->chunkSize; x++) { 409 | const unsigned char value = voxels[voxel(volume, x, y, z)]; 410 | if (value) { 411 | const int cx = x - chunkX, 412 | cy = y - chunkY, 413 | cz = z - chunkZ; 414 | bool isVisible = false; 415 | for (unsigned char face = 0; face < 6; face++) { 416 | const int nx = x + meshNormals[face][0].x, 417 | ny = y + meshNormals[face][0].y, 418 | nz = z + meshNormals[face][0].z, 419 | neighbor = voxel(volume, nx, ny, nz); 420 | if (neighbor != -1 && !voxels[neighbor]) { 421 | isVisible = true; 422 | const float texture = mapping(face, value, x, y, z); 423 | faces[offset++] = cx + 0.5f; 424 | faces[offset++] = cy + 0.5f; 425 | faces[offset++] = cz + 0.5f; 426 | faces[offset++] = texture * 6.0f + (float) face; 427 | for (int channel = 0; channel < LIGHT_CHANNELS; channel++) { 428 | faces[offset++] = lighting(volume, voxels, light, face, channel, nx, ny, nz); 429 | } 430 | count++; 431 | } 432 | } 433 | if (isVisible) { 434 | if (box->min.x > cx) box->min.x = cx; 435 | if (box->min.y > cy) box->min.y = cy; 436 | if (box->min.z > cz) box->min.z = cz; 437 | if (box->max.x < cx + 1) box->max.x = cx + 1; 438 | if (box->max.y < cy + 1) box->max.y = cy + 1; 439 | if (box->max.z < cz + 1) box->max.z = cz + 1; 440 | } 441 | } 442 | } 443 | } 444 | } 445 | const float halfWidth = 0.5f * (box->max.x - box->min.x), 446 | halfHeight = 0.5f * (box->max.y - box->min.y), 447 | halfDepth = 0.5f * (box->max.z - box->min.z); 448 | sphere[0] = 0.5f * (box->min.x + box->max.x); 449 | sphere[1] = 0.5f * (box->min.y + box->max.y); 450 | sphere[2] = 0.5f * (box->min.z + box->max.z); 451 | sphere[3] = sqrt( 452 | halfWidth * halfWidth 453 | + halfHeight * halfHeight 454 | + halfDepth * halfDepth 455 | ); 456 | return count; 457 | } 458 | 459 | const int pathfind( 460 | const Volume* volume, 461 | const unsigned char* voxels, 462 | const unsigned char* obstacles, 463 | int* results, 464 | const int height, 465 | const int maxVisited, 466 | const int minY, 467 | const int maxY, 468 | const int fromX, 469 | const int fromY, 470 | const int fromZ, 471 | const int toX, 472 | const int toY, 473 | const int toZ 474 | ) { 475 | if ( 476 | fromX < 0 || fromX >= volume->width 477 | || fromY < 0 || fromY >= volume->height 478 | || fromZ < 0 || fromZ >= volume->depth 479 | || toX < 0 || toX >= volume->width 480 | || toY < 0 || toY >= volume->height 481 | || toZ < 0 || toZ >= volume->depth 482 | ) { 483 | return 0; 484 | } 485 | ASPath path = ASPathCreate( 486 | &PathNodeSource, 487 | &(PathContext){volume, voxels, obstacles, height, maxVisited, minY, maxY}, 488 | &(Voxel){fromX, fromY, fromZ}, 489 | &(Voxel){toX, toY, toZ} 490 | ); 491 | const int nodes = ASPathGetCount(path); 492 | for (int i = 0, p = 0; i < nodes; i++, p += 3) { 493 | Voxel* node = ASPathGetNode(path, i); 494 | results[p] = node->x; 495 | results[p + 1] = node->y; 496 | results[p + 2] = node->z; 497 | } 498 | ASPathDestroy(path); 499 | return nodes; 500 | } 501 | 502 | int emission(int value); 503 | 504 | void propagate( 505 | const Volume* volume, 506 | unsigned char* voxels, 507 | int* height, 508 | Light* light, 509 | int* queueA, 510 | int* queueB 511 | ) { 512 | for (int i = 0, z = 0; z < volume->depth; z++) { 513 | for (int x = 0; x < volume->width; x++, i++) { 514 | for (int y = volume->height - 1; y >= 0; y--) { 515 | if (y == 0 || voxels[voxel(volume, x, y, z)]) { 516 | height[i] = y; 517 | break; 518 | } 519 | } 520 | } 521 | } 522 | for (int channel = 0; channel < LIGHT_CHANNELS; channel++) { 523 | unsigned int count = 0; 524 | if (channel == LIGHT_CHANNEL_SUNLIGHT) { 525 | for (int z = 0; z < volume->depth; z++) { 526 | for (int x = 0; x < volume->width; x++) { 527 | const int i = voxel(volume, x, volume->height - 1, z); 528 | if (!voxels[i]) { 529 | light[i][channel] = volume->maxLight; 530 | queueA[count++] = i; 531 | } 532 | } 533 | } 534 | } else { 535 | for (int i = 0, z = 0; z < volume->depth; z++) { 536 | for (int y = 0; y < volume->height; y++) { 537 | for (int x = 0; x < volume->width; x++, i++) { 538 | if (voxels[i] && emission(voxels[i]) == channel) { 539 | light[i][channel] = volume->maxLight; 540 | queueA[count++] = i; 541 | } 542 | } 543 | } 544 | } 545 | } 546 | floodLight( 547 | NULL, 548 | channel, 549 | volume, 550 | voxels, 551 | height, 552 | light, 553 | queueA, 554 | count, 555 | queueB 556 | ); 557 | } 558 | } 559 | 560 | void update( 561 | Box* bounds, 562 | const Volume* volume, 563 | unsigned char* voxels, 564 | int* height, 565 | Light* light, 566 | int* queueA, 567 | int* queueB, 568 | int* queueC, 569 | const int x, 570 | const int y, 571 | const int z, 572 | const unsigned char value, 573 | const unsigned char updateLight 574 | ) { 575 | bounds->min.x = bounds->max.x = x; 576 | bounds->min.y = bounds->max.y = y; 577 | bounds->min.z = bounds->max.z = z; 578 | 579 | const int i = voxel(volume, x, y, z); 580 | if (i == -1) { 581 | return; 582 | } 583 | const unsigned char current = voxels[i]; 584 | if (current == value) { 585 | return; 586 | } 587 | voxels[i] = value; 588 | 589 | if (!updateLight) { 590 | return; 591 | } 592 | 593 | const int heightIndex = z * volume->width + x; 594 | const int currentHeight = height[heightIndex]; 595 | if (value && currentHeight < y) { 596 | height[heightIndex] = y; 597 | } 598 | if (!value && currentHeight == y) { 599 | for (int h = y - 1; h >= 0; h--) { 600 | if (h == 0 || voxels[voxel(volume, x, h, z)]) { 601 | height[heightIndex] = h; 602 | break; 603 | } 604 | } 605 | } 606 | 607 | const int currentEmission = current ? emission(current) : 0; 608 | if (currentEmission > 0 && currentEmission < LIGHT_CHANNELS) { 609 | const unsigned char level = light[i][currentEmission]; 610 | light[i][currentEmission] = 0; 611 | queueA[0] = i; 612 | queueA[1] = level; 613 | removeLight( 614 | bounds, 615 | currentEmission, 616 | volume, 617 | voxels, 618 | height, 619 | light, 620 | queueA, 621 | 2, 622 | queueB, 623 | queueC, 624 | 0 625 | ); 626 | } 627 | 628 | if (value && !current) { 629 | for (int channel = 0; channel < LIGHT_CHANNELS; channel++) { 630 | const unsigned char level = light[i][channel]; 631 | if (level != 0) { 632 | light[i][channel] = 0; 633 | queueA[0] = i; 634 | queueA[1] = level; 635 | removeLight( 636 | bounds, 637 | channel, 638 | volume, 639 | voxels, 640 | height, 641 | light, 642 | queueA, 643 | 2, 644 | queueB, 645 | queueC, 646 | 0 647 | ); 648 | } 649 | } 650 | } 651 | 652 | const int valueEmission = value ? emission(value) : 0; 653 | if (valueEmission > 0 && valueEmission < LIGHT_CHANNELS) { 654 | light[i][valueEmission] = volume->maxLight; 655 | queueA[0] = i; 656 | floodLight( 657 | bounds, 658 | valueEmission, 659 | volume, 660 | voxels, 661 | height, 662 | light, 663 | queueA, 664 | 1, 665 | queueB 666 | ); 667 | } 668 | 669 | if (!value && current) { 670 | for (int channel = 0; channel < LIGHT_CHANNELS; channel++) { 671 | unsigned int count = 0; 672 | for (unsigned char n = 0; n < 6; n++) { 673 | const int neighbor = voxel( 674 | volume, 675 | x + lightNeighbors[n].x, 676 | y + lightNeighbors[n].y, 677 | z + lightNeighbors[n].z 678 | ); 679 | if (neighbor != -1 && light[neighbor][channel]) { 680 | queueA[count++] = neighbor; 681 | } 682 | } 683 | if (count > 0) { 684 | floodLight( 685 | bounds, 686 | channel, 687 | volume, 688 | voxels, 689 | height, 690 | light, 691 | queueA, 692 | count, 693 | queueB 694 | ); 695 | } 696 | } 697 | } 698 | } 699 | -------------------------------------------------------------------------------- /src/volume.js: -------------------------------------------------------------------------------- 1 | import Program from './volume.wasm'; 2 | 3 | class Volume { 4 | constructor({ 5 | width, 6 | height, 7 | depth, 8 | chunkSize = 32, 9 | maxLight = 24, 10 | emission = (v) => (0), 11 | mapping = (f, v) => (v - 1), 12 | onLoad, 13 | onError, 14 | }) { 15 | if (width % chunkSize || height % chunkSize || depth % chunkSize) { 16 | if (onError) { 17 | onError(new Error(`width, height and depth must be multiples of ${chunkSize}`)); 18 | } 19 | return; 20 | } 21 | const properties = { width, height, depth, chunkSize, maxLight }; 22 | Object.keys(properties).forEach((key) => ( 23 | Object.defineProperty(this, key, { value: properties[key], writable: false }) 24 | )); 25 | const layout = [ 26 | { id: 'volume', type: Int32Array, size: 5 }, 27 | { id: 'voxels', type: Uint8Array, size: width * height * depth }, 28 | { id: 'height', type: Int32Array, size: width * depth }, 29 | { id: 'light', type: Uint8Array, size: width * height * depth * 4 }, 30 | { id: 'obstacles', type: Uint8Array, size: width * height * depth }, 31 | { id: 'faces', type: Float32Array, size: Math.ceil((chunkSize ** 3) * 0.5) * 6 * 8 }, 32 | { id: 'sphere', type: Float32Array, size: 4 }, 33 | { id: 'box', type: Int32Array, size: 6 }, 34 | { id: 'queueA', type: Int32Array, size: width * depth }, 35 | { id: 'queueB', type: Int32Array, size: width * depth }, 36 | { id: 'queueC', type: Int32Array, size: width * depth }, 37 | ]; 38 | const pages = Math.ceil(layout.reduce((total, { type, size }) => ( 39 | total + size * type.BYTES_PER_ELEMENT 40 | ), 0) / 65536) + 10; 41 | const memory = new WebAssembly.Memory({ initial: pages, maximum: pages }); 42 | Program() 43 | .then((program) => ( 44 | WebAssembly 45 | .instantiate(program, { env: { emission, mapping, memory } }) 46 | .then((instance) => { 47 | this.memory = layout.reduce((layout, { id, type, size }) => { 48 | const address = instance.exports.malloc(size * type.BYTES_PER_ELEMENT); 49 | layout[id] = { 50 | address, 51 | view: new type(memory.buffer, address, size), 52 | }; 53 | return layout; 54 | }, {}); 55 | this.memory.volume.view.set([width, height, depth, chunkSize, maxLight]); 56 | this._ground = instance.exports.ground; 57 | this._mesh = instance.exports.mesh; 58 | this._pathfind = instance.exports.pathfind; 59 | this._propagate = instance.exports.propagate; 60 | this._update = instance.exports.update; 61 | this._voxel = instance.exports.voxel; 62 | }) 63 | )) 64 | .then(() => { 65 | if (onLoad) { 66 | onLoad(); 67 | } 68 | }) 69 | .catch((e) => { 70 | if (onError) { 71 | onError(e); 72 | } 73 | }); 74 | } 75 | 76 | ground(position, height = 1) { 77 | const { memory, _ground } = this; 78 | return _ground( 79 | memory.volume.address, 80 | memory.voxels.address, 81 | height, 82 | position.x, 83 | position.y, 84 | position.z 85 | ); 86 | } 87 | 88 | mesh(chunk) { 89 | const { memory, _mesh } = this; 90 | const count = _mesh( 91 | memory.volume.address, 92 | memory.voxels.address, 93 | memory.light.address, 94 | memory.faces.address, 95 | memory.sphere.address, 96 | memory.box.address, 97 | chunk.x, 98 | chunk.y, 99 | chunk.z 100 | ); 101 | return { 102 | bounds: memory.sphere.view, 103 | count, 104 | faces: new Float32Array(memory.faces.view.subarray(0, count * 8)), 105 | }; 106 | } 107 | 108 | obstacle(position, enabled, height = 1) { 109 | const { memory } = this; 110 | for (let y = 0; y < height; y++) { 111 | const voxel = this.voxel(position); 112 | if (voxel !== -1) { 113 | memory.obstacles.view[voxel] = enabled ? 1 : 0; 114 | } 115 | } 116 | } 117 | 118 | pathfind({ 119 | from, 120 | to, 121 | height = 1, 122 | maxVisited = 4096, 123 | minY = 0, 124 | maxY = Infinity, 125 | }) { 126 | const { memory, _pathfind } = this; 127 | const nodes = _pathfind( 128 | memory.volume.address, 129 | memory.voxels.address, 130 | memory.obstacles.address, 131 | memory.queueA.address, 132 | height, 133 | maxVisited, 134 | Math.max(minY, 0), 135 | Math.min(maxY, this.height - 1), 136 | from.x, 137 | from.y, 138 | from.z, 139 | to.x, 140 | to.y, 141 | to.z 142 | ); 143 | return memory.queueA.view.subarray(0, nodes * 3); 144 | } 145 | 146 | propagate() { 147 | const { memory, _propagate } = this; 148 | _propagate( 149 | memory.volume.address, 150 | memory.voxels.address, 151 | memory.height.address, 152 | memory.light.address, 153 | memory.queueA.address, 154 | memory.queueB.address 155 | ); 156 | return this; 157 | } 158 | 159 | update(position, value, updateLight = true) { 160 | const { memory, _update } = this; 161 | _update( 162 | memory.box.address, 163 | memory.volume.address, 164 | memory.voxels.address, 165 | memory.height.address, 166 | memory.light.address, 167 | memory.queueA.address, 168 | memory.queueB.address, 169 | memory.queueC.address, 170 | position.x, 171 | position.y, 172 | position.z, 173 | value, 174 | updateLight 175 | ); 176 | return memory.box.view; 177 | } 178 | 179 | voxel(position) { 180 | const { memory, _voxel } = this; 181 | return _voxel( 182 | memory.volume.address, 183 | position.x, 184 | position.y, 185 | position.z 186 | ); 187 | } 188 | } 189 | 190 | export default Volume; 191 | -------------------------------------------------------------------------------- /src/volume.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielesteban/cubitos/581a0056f65b75fa944c2c52900bf3da35c255d3/src/volume.wasm -------------------------------------------------------------------------------- /src/world.js: -------------------------------------------------------------------------------- 1 | import { Group, Vector3 } from 'three'; 2 | import Chunk from './chunk.js'; 3 | 4 | const _queueMicrotask = (typeof self.queueMicrotask === 'function') ? ( 5 | self.queueMicrotask 6 | ) : (callback) => { 7 | Promise.resolve() 8 | .then(callback) 9 | .catch(e => setTimeout(() => { throw e; })); 10 | }; 11 | 12 | const _max = new Vector3(); 13 | const _min = new Vector3(); 14 | const _voxel = new Vector3(); 15 | 16 | class World extends Group { 17 | constructor({ material, volume }) { 18 | super(); 19 | this.matrixAutoUpdate = false; 20 | this.chunks = new Map(); 21 | this.material = material; 22 | this.remeshQueue = new Map(); 23 | this.volume = volume; 24 | for (let z = 0; z < volume.depth / volume.chunkSize; z++) { 25 | for (let y = 0; y < volume.height / volume.chunkSize; y++) { 26 | for (let x = 0; x < volume.width / volume.chunkSize; x++) { 27 | const chunk = new Chunk({ material, position: new Vector3(x, y, z), volume }); 28 | this.chunks.set(`${z}:${y}:${x}`, chunk); 29 | this.add(chunk); 30 | } 31 | } 32 | } 33 | } 34 | 35 | remesh(x, y, z) { 36 | const { chunks, remeshQueue } = this; 37 | const mesh = chunks.get(`${z}:${y}:${x}`); 38 | if (!mesh) { 39 | return; 40 | } 41 | if (!remeshQueue.size) { 42 | _queueMicrotask(() => { 43 | remeshQueue.forEach((mesh) => mesh.update()); 44 | remeshQueue.clear(); 45 | }); 46 | } 47 | remeshQueue.set(mesh, mesh); 48 | } 49 | 50 | update(point, radius, value) { 51 | const { material, volume } = this; 52 | const updateLight = material.defines.USE_LIGHT; 53 | World.getBrush(radius).forEach((offset) => { 54 | _voxel.addVectors(point, offset); 55 | const current = volume.memory.voxels.view[volume.voxel(_voxel)]; 56 | const update = typeof value === 'function' ? ( 57 | value(offset.d, current, _voxel) 58 | ) : ( 59 | value 60 | ); 61 | if (update !== -1 && update !== current) { 62 | const bounds = volume.update(_voxel, update, updateLight); 63 | _min.set(bounds[0] - 1, bounds[1] - 1, bounds[2] - 1).divideScalar(volume.chunkSize).floor(); 64 | _max.set(bounds[3] + 1, bounds[4] + 1, bounds[5] + 1).divideScalar(volume.chunkSize).floor(); 65 | for (let z = _min.z; z <= _max.z; z++) { 66 | for (let y = _min.y; y <= _max.y; y++) { 67 | for (let x = _min.x; x <= _max.x; x++) { 68 | this.remesh(x, y, z); 69 | } 70 | } 71 | } 72 | } 73 | }); 74 | } 75 | 76 | static getBrush(radius) { 77 | const { brushes } = World; 78 | let brush = brushes.get(radius); 79 | if (!brush) { 80 | brush = []; 81 | const center = new Vector3(); 82 | for (let z = -radius; z <= radius; z++) { 83 | for (let y = -radius; y <= radius; y++) { 84 | for (let x = -radius; x <= radius; x++) { 85 | const point = new Vector3(x, y, z); 86 | point.d = point.distanceTo(center); 87 | if (point.d < radius) { 88 | brush.push(point); 89 | } 90 | } 91 | } 92 | } 93 | brush.sort((a, b) => (a.d - b.d)); 94 | brushes.set(radius, brush); 95 | } 96 | return brush; 97 | } 98 | } 99 | 100 | World.brushes = new Map(); 101 | 102 | export default World; 103 | -------------------------------------------------------------------------------- /src/worldgen.c: -------------------------------------------------------------------------------- 1 | #define FNL_IMPL 2 | #include "../vendor/FastNoiseLite/C/FastNoiseLite.h" 3 | 4 | void generate( 5 | unsigned char* voxels, 6 | const int width, 7 | const int height, 8 | const int depth, 9 | const unsigned char grass, 10 | const unsigned char lights, 11 | const float frequency, 12 | const float gain, 13 | const float lacunarity, 14 | const int octaves, 15 | const int seed 16 | ) { 17 | fnl_state fbm = fnlCreateState(); 18 | fbm.fractal_type = FNL_FRACTAL_FBM; 19 | fbm.frequency = frequency; 20 | fbm.gain = gain; 21 | fbm.lacunarity = lacunarity; 22 | fbm.octaves = octaves; 23 | fbm.seed = seed; 24 | fnl_state simplex = fnlCreateState(); 25 | simplex.frequency = fbm.frequency * 4.0f; 26 | simplex.gain = fbm.gain; 27 | simplex.lacunarity = fbm.lacunarity; 28 | simplex.octaves = fbm.octaves; 29 | simplex.seed = fbm.seed; 30 | const float radius = fmax(width, depth) * 0.5f; 31 | for (int i = 0, z = 0; z < depth; z++) { 32 | for (int y = 0; y < height; y++) { 33 | for (int x = 0; x < width; x++, i++) { 34 | const float dx = (x - width * 0.5f + 0.5f); 35 | const float dz = (z - depth * 0.5f + 0.5f); 36 | const float d = sqrt(dx * dx + dz * dz); 37 | if (d > radius) { 38 | continue; 39 | } 40 | const float n = fabs(fnlGetNoise3D(&fbm, x, y, z)); 41 | if ( 42 | y < (height - 2) * n 43 | && d < radius * (0.8f + 0.2f * n) 44 | ) { 45 | voxels[i] = 2 - round(fabs(fnlGetNoise3D(&simplex, z, x, y))); 46 | continue; 47 | } 48 | if ( 49 | (grass || lights) 50 | && y > 0 51 | && !voxels[i] 52 | && (voxels[i - width] == 1 || voxels[i - width] == 2) 53 | ) { 54 | if (grass) { 55 | voxels[i - width] = 3; 56 | } 57 | if (lights && fabs(fnlGetNoise3D(&simplex, z * 10, x * 10, y * 10)) > 0.98f) { 58 | voxels[i] = 2; 59 | voxels[i + width] = 4 + round(fabs(fnlGetNoise3D(&simplex, x, y, z)) * 2); 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/worldgen.js: -------------------------------------------------------------------------------- 1 | import Program from './worldgen.wasm'; 2 | import Worker from 'web-worker:./worldgen.worker.js'; 3 | 4 | export default ({ 5 | grass = true, 6 | lights = true, 7 | frequency = 0.01, 8 | gain = 0.5, 9 | lacunarity = 2, 10 | octaves = 3, 11 | seed = Math.floor(Math.random() * 2147483647), 12 | volume, 13 | }) => ( 14 | Program().then((program) => new Promise((resolve) => { 15 | const worker = new Worker(); 16 | worker.addEventListener('message', ({ data }) => { 17 | volume.memory.voxels.view.set(data); 18 | worker.terminate(); 19 | resolve(volume); 20 | }); 21 | worker.postMessage({ 22 | program, 23 | width: volume.width, 24 | height: volume.height, 25 | depth: volume.depth, 26 | grass, 27 | lights, 28 | frequency, 29 | gain, 30 | lacunarity, 31 | octaves, 32 | seed, 33 | }); 34 | })) 35 | ); 36 | -------------------------------------------------------------------------------- /src/worldgen.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielesteban/cubitos/581a0056f65b75fa944c2c52900bf3da35c255d3/src/worldgen.wasm -------------------------------------------------------------------------------- /src/worldgen.worker.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('message', ({ 2 | data: { 3 | program, 4 | width, 5 | height, 6 | grass, 7 | lights, 8 | depth, 9 | frequency, 10 | gain, 11 | lacunarity, 12 | octaves, 13 | seed, 14 | }, 15 | }) => { 16 | const size = width * height * depth; 17 | const pages = Math.ceil(size / 65536) + 10; 18 | const memory = new WebAssembly.Memory({ initial: pages, maximum: pages }); 19 | WebAssembly 20 | .instantiate(program, { env: { memory } }) 21 | .then((instance) => { 22 | const voxels = instance.exports.malloc(size); 23 | instance.exports.generate( 24 | voxels, 25 | width, 26 | height, 27 | depth, 28 | grass ? 1 : 0, 29 | lights ? 1 : 0, 30 | frequency, 31 | gain, 32 | lacunarity, 33 | octaves, 34 | seed 35 | ); 36 | self.postMessage(new Uint8Array(memory.buffer, voxels, size)); 37 | }) 38 | }); 39 | --------------------------------------------------------------------------------