├── .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 | [](https://www.npmjs.com/package/cubitos)
3 | ==
4 |
5 | [](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 |
28 |
29 |
30 |
41 |
44 |
45 |
46 |
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 |
--------------------------------------------------------------------------------