Browse Source

add voxel article

Gregg Tavares 6 years ago
parent
commit
82dd10e4f2

+ 52 - 0
threejs/lessons/resources/threejs-voxel-geometry.js

@@ -0,0 +1,52 @@
+'use strict';
+
+/* global THREE, threejsLessonUtils */
+
+{
+  threejsLessonUtils.addDiagrams({
+    mergedCubes: {
+      create() {
+        const geometries = [];
+        const width = 3;
+        const height = 2;
+        const depth = 2;
+        for (let y = 0; y < height; ++y) {
+          for (let z = 0; z < depth; ++z) {
+            for (let x = 0; x < width; ++x) {
+              const geometry = new THREE.BoxBufferGeometry(1, 1, 1);
+              geometry.applyMatrix((new THREE.Matrix4()).makeTranslation(x, y, z));
+              geometries.push(geometry);
+            }
+          }
+        }
+        const mergedGeometry = THREE.BufferGeometryUtils.mergeBufferGeometries(geometries, false);
+        const material = new THREE.MeshBasicMaterial({
+          color: 'black',
+          wireframe: true,
+        });
+        const mesh = new THREE.Mesh(mergedGeometry, material);
+        mesh.position.set(
+            0.5 - width / 2,
+            0.5 - height / 2,
+            0.5 - depth / 2);
+        const base = new THREE.Object3D();
+        base.add(mesh);
+        base.scale.setScalar(3.5);
+        return base;
+      },
+    },
+    culledCubes: {
+      create() {
+        const geometry = new THREE.BoxBufferGeometry(3, 2, 2, 3, 2, 2);
+        const material = new THREE.MeshBasicMaterial({
+          color: 'black',
+          wireframe: true,
+        });
+        const mesh = new THREE.Mesh(geometry, material);
+        mesh.scale.setScalar(3.5);
+        return mesh;
+      },
+    },
+  });
+}
+

+ 1210 - 0
threejs/lessons/threejs-voxel-geometry.md

@@ -0,0 +1,1210 @@
+Title: Three.js Voxel(Minecraft Like) Geometry
+Description: How to make voxel geometry like Minecraft
+
+I've seen this topic come up more than once in various places.
+That is basically, "How do I make a voxel display like Minecraft".
+
+Most people first attempt this by making a cube geometry and then
+making a mesh at each voxel position. Just for fun I tried
+this. I made a 16777216 element `Uint8Array` to represent
+a 256x256x256 cube of voxels.
+
+```js
+const cellSize = 256;
+const cell = new Uint8Array(cellSize * cellSize * cellSize);
+```
+
+I then made a single layer with a kind of hills of 
+sine waves like this
+
+```js
+for (let y = 0; y < cellSize; ++y) {
+  for (let z = 0; z < cellSize; ++z) {
+    for (let x = 0; x < cellSize; ++x) {
+      const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + cellSize / 2;
+      if (height > y && height < y + 1) {
+        const offset = y * cellSize * cellSize +
+                       z * cellSize +
+                       x;
+        cell[offset] = 1;
+      }
+    }
+  }
+}
+```
+
+I then walked through all the cells and if they were not
+0 I created a mesh with a cube.
+
+```js
+const geometry = new THREE.BoxBufferGeometry(1, 1, 1);
+const material = new THREE.MeshPhongMaterial({color: 'green'});
+
+for (let y = 0; y < cellSize; ++y) {
+  for (let z = 0; z < cellSize; ++z) {
+    for (let x = 0; x < cellSize; ++x) {
+      const offset = y * cellSize * cellSize +
+                     z * cellSize +
+                     x;
+      const block = cell[offset];
+      const mesh = new THREE.Mesh(geometry, material);
+      mesh.position.set(x, y, z);
+      scene.add(mesh);
+    }
+  }
+}
+```
+
+The rest of the code is based on the example from
+[the article on rendering on demand](threejs-rendering-on-demand.html).
+
+{{{example url="../threejs-voxel-geometry-separate-cubes.html" }}}
+
+It takes a while to start and if you try to move the camera
+it's likely too slow. Like [the article on how to optimize lots of objects](threejs-optimize-lots-of-objects.html)
+the problem is there are just way too many objects. 256x256
+is 65536 boxes!
+
+Using [the technique of merging the geometry](threejs-rendering-on-demand.html)
+will fix the issue for this example but what if instead of just making
+a single layer we filled in everything below the ground with voxel. 
+In otherwords change the loop filling in the voxels to this
+
+```js
+for (let y = 0; y < cellSize; ++y) {
+  for (let z = 0; z < cellSize; ++z) {
+    for (let x = 0; x < cellSize; ++x) {
+      const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + cellSize / 2;
+-      if (height > y && height < y + 1) {
++      if (height < y + 1) {
+        const offset = y * cellSize * cellSize +
+                       z * cellSize +
+                       x;
+        cell[offset] = 1;
+      }
+    }
+  }
+}
+```
+
+I tried it once just to see the results. It churned for
+about a minute and then crashed with *out of memory* 😅
+
+There are several issues but the biggest issue is
+we're making all these faces inside the cubes that
+we can actually never see.
+
+In other words lets say we have a box of voxels
+3x2x2. By merging cubes we're getting this
+
+<div class="spread">
+  <div data-diagram="mergedCubes" style="height: 300px;"></div>
+</div>
+
+but we really want this
+
+<div class="spread">
+  <div data-diagram="culledCubes" style="height: 300px;"></div>
+</div>
+
+In the top box there are faces between the voxels. Faces
+that are a waste since they can't be seen. It's not just
+one face between each voxel, there are 2 faces, one for
+each voxel facing its neighbor that are a waste. All these extra faces,
+especially for a large volume of voxels will kill performance.
+
+It should be clear that we can't just merge geometry.
+We need to build it ourselves, taking into account that
+if a voxel has an adjacent neighbor it doesn't need the
+face facing that neighbor.
+
+The next issue is that 256x256x256 is just too big. 16meg is a lot of memory and
+if nothing else in much of the space nothing is there so that's a lot of wasted
+memory. It's also a huge number of voxels, 16 million! That's too much to
+consider at once.
+
+A solution is to divide the area into smaller areas.
+Any area that has nothing in it needs no storage. Let's use 
+32x32x32 areas (that's 32k) and only create an area if something is in it.
+We'll call one of these larger 32x32x32 areas a "cell".
+
+Let's break this into pieces. First let's make a class to manage the voxel data.
+
+```js
+class VoxelWorld {
+  constructor(cellSize) {
+    this.cellSize = cellSize;
+  }
+}
+```
+
+Let's make the function that makes geometry for a cell. 
+Let's assume you pass in a cell position.
+In other words if you want the geometry for the cell that covers voxels (0-31x, 0-31y, 0-31z)
+then you'd pass in 0,0,0. For the cell that covers voxels (32-63x, 0-31y, 0-31z) you'd
+pass in 1,0,0.
+
+We need to be able to check the neighboring voxels so let's assume our class
+has a function `getVoxel` that given a voxel position returns the value of
+the voxel there. In other words if you pass it 35,0,0 and the cellSize is 32
+it's going to look at cell 1,0,0 and in that cell it will look at voxel 3,0,0.
+Using this function we can look at a voxel's neighboring voxels even if they
+happen to be in neighboring cells.
+
+```js
+class VoxelWorld {
+  constructor(cellSize) {
+    this.cellSize = cellSize;
+  }
++  generateGeometryDataForCell(cellX, cellY, cellZ) {
++    const {cellSize} = this;
++    const startX = cellX * cellSize;
++    const startY = cellY * cellSize;
++    const startZ = cellZ * cellSize;
++
++    for (let y = 0; y < cellSize; ++y) {
++      const voxelY = startY + y;
++      for (let z = 0; z < cellSize; ++z) {
++        const voxelZ = startZ + z;
++        for (let x = 0; x < cellSize; ++x) {
++          const voxelX = startX + x;
++          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
++          if (voxel) {
++            for (const {dir} of VoxelWorld.faces) {
++              const neighbor = getVoxel(
++                  voxelX + dir[0],
++                  voxelY + dir[1],
++                  voxelZ + dir[2]);
++              if (!neighbor) {
++                // this voxel has no neighbor in this direction so we need a face
++                // here.
++              }
++            }
++          }
++        }
++      }
++    }
++  }
+}
+
++VoxelWorld.faces = [
++  { // left
++    dir: [ -1,  0,  0, ],
++  },
++  { // right
++    dir: [  1,  0,  0, ],
++  },
++  { // bottom
++    dir: [  0, -1,  0, ],
++  },
++  { // top
++    dir: [  0,  1,  0, ],
++  },
++  { // back
++    dir: [  0,  0, -1, ],
++  },
++  { // front
++    dir: [  0,  0,  1, ],
++  },
++];
+```
+
+So using the code above we know when we need a face. Let's generate the faces.
+
+```js
+class VoxelWorld {
+  constructor(cellSize) {
+    this.cellSize = cellSize;
+  }
+  generateGeometryDataForCell(cellX, cellY, cellZ) {
+    const {cellSize} = this;
++    const positions = [];
++    const normals = [];
++    const indices = [];
+    const startX = cellX * cellSize;
+    const startY = cellY * cellSize;
+    const startZ = cellZ * cellSize;
+
+    for (let y = 0; y < cellSize; ++y) {
+      const voxelY = startY + y;
+      for (let z = 0; z < cellSize; ++z) {
+        const voxelZ = startZ + z;
+        for (let x = 0; x < cellSize; ++x) {
+          const voxelX = startX + x;
+          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
+          if (voxel) {
+-            for (const {dir} of VoxelWorld.faces) {
++            for (const {dir, corners} of VoxelWorld.faces) {
+              const neighbor = getVoxel(
+                  voxelX + dir[0],
+                  voxelY + dir[1],
+                  voxelZ + dir[2]);
+              if (!neighbor) {
+                // this voxel has no neighbor in this direction so we need a face.
++                const ndx = positions.length / 3;
++                for (const pos of corners) {
++                  positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
++                  normals.push(...dir);
++                }
++                indices.push(
++                  ndx, ndx + 1, ndx + 2,
++                  ndx + 2, ndx + 1, ndx + 3,
++                );
+              }
+            }
+          }
+        }
+      }
+    }
++    return {
++      positions,
++      normals,
++      indices,
+    };
+  }
+}
+
+VoxelWorld.faces = [
+  { // left
+    dir: [ -1,  0,  0, ],
++    corners: [
++      [ 0, 1, 0 ],
++      [ 0, 0, 0 ],
++      [ 0, 1, 1 ],
++      [ 0, 0, 1 ],
++    ],
+  },
+  { // right
+    dir: [  1,  0,  0, ],
++    corners: [
++      [ 1, 1, 1 ],
++      [ 1, 0, 1 ],
++      [ 1, 1, 0 ],
++      [ 1, 0, 0 ],
++    ],
+  },
+  { // bottom
+    dir: [  0, -1,  0, ],
++    corners: [
++      [ 1, 0, 1 ],
++      [ 0, 0, 1 ],
++      [ 1, 0, 0 ],
++      [ 0, 0, 0 ],
++    ],
+  },
+  { // top
+    dir: [  0,  1,  0, ],
++    corners: [
++      [ 0, 1, 1 ],
++      [ 1, 1, 1 ],
++      [ 0, 1, 0 ],
++      [ 1, 1, 0 ],
++    ],
+  },
+  { // back
+    dir: [  0,  0, -1, ],
++    corners: [
++      [ 1, 0, 0 ],
++      [ 0, 0, 0 ],
++      [ 1, 1, 0 ],
++      [ 0, 1, 0 ],
++    ],
+  },
+  { // front
+    dir: [  0,  0,  1, ],
++    corners: [
++      [ 0, 0, 1 ],
++      [ 1, 0, 1 ],
++      [ 0, 1, 1 ],
++      [ 1, 1, 1 ],
++    ],
+  },
+];
+```
+
+The code above would make basic geometry data for us. We just need to supply
+the `getVoxel` function. Let's start with just one hardcoded cell.
+
+```js
+class VoxelWorld {
+  constructor(cellSize) {
+    this.cellSize = cellSize;
++    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
+  }
++  getCellForVoxel(x, y, z) {
++    const {cellSize} = this;
++    const cellX = Math.floor(x / cellSize);
++    const cellY = Math.floor(y / cellSize);
++    const cellZ = Math.floor(z / cellSize);
++    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
++      return null
++    }
++    return this.cell;
++  }
++  getVoxel(x, y, z) {
++    const cell = this.getCellForVoxel(x, y, z);
++    if (!cell) {
++      return 0;
++    }
++    const {cellSize} = this;
++    const voxelX = THREE.Math.euclideanModulo(x, cellSize) | 0;
++    const voxelY = THREE.Math.euclideanModulo(y, cellSize) | 0;
++    const voxelZ = THREE.Math.euclideanModulo(z, cellSize) | 0;
++    const voxelOffset = voxelY * cellSize * cellSize +
++                        voxelZ * cellSize +
++                        voxelX;
++    return cell[voxelOffset];
++  }
+  generateGeometryDataForCell(cellX, cellY, cellZ) {
+
+  ...
+}
+```
+
+This seems like it would work. Let's make a `setVoxel` function
+so we can set some data.
+
+```js
+class VoxelWorld {
+  constructor(cellSize) {
+    this.cellSize = cellSize;
+    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
+  }
+  getCellForVoxel(x, y, z) {
+    const {cellSize} = this;
+    const cellX = Math.floor(x / cellSize);
+    const cellY = Math.floor(y / cellSize);
+    const cellZ = Math.floor(z / cellSize);
+    if (cellX !== 0 || cellY !== 0 || celllZ !== 0) {
+      return null
+    }
+    return this.cell;
+  }
++  setVoxel(x, y, z, v) {
++    let cell = this.getCellForVoxel(x, y, z);
++    if (!cell) {
++      return;  // TODO: add a new cell?
++    }
++    const {cellSize} = this;
++    const voxelX = THREE.Math.euclideanModulo(x, cellSize) | 0;
++    const voxelY = THREE.Math.euclideanModulo(y, cellSize) | 0;
++    const voxelZ = THREE.Math.euclideanModulo(z, cellSize) | 0;
++    const voxelOffset = voxelY * cellSize * cellSize +
++                        voxelZ * cellSize +
++                        voxelX;
++    cell[voxelOffset] = v;
++  }
+  getVoxel(x, y, z) {
+    const cell = this.getCellForVoxel(x, y, z);
+    if (!cell) {
+      return 0;
+    }
+    const {cellSize} = this;
+    const voxelX = THREE.Math.euclideanModulo(x, cellSize) | 0;
+    const voxelY = THREE.Math.euclideanModulo(y, cellSize) | 0;
+    const voxelZ = THREE.Math.euclideanModulo(z, cellSize) | 0;
+    const voxelOffset = voxelY * cellSize * cellSize +
+                        voxelZ * cellSize +
+                        voxelX;
+    return cell[voxelOffset];
+  }
+  generateGeometryDataForCell(cellX, cellY, cellZ) {
+
+  ...
+}
+```
+
+Hmmm, I see a lot of repeated code. Let's fix that up
+
+```js
+class VoxelWorld {
+  constructor(cellSize) {
+    this.cellSize = cellSize;
++    this.cellSliceSize = cellSize * cellSize;
+    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
+  }
+  getCellForVoxel(x, y, z) {
+    const {cellSize} = this;
+    const cellX = Math.floor(x / cellSize);
+    const cellY = Math.floor(y / cellSize);
+    const cellZ = Math.floor(z / cellSize);
+    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
+      return null;
+    }
+    return this.cell;
+  }
++  computeVoxelOffset(x, y, z) {
++    const {cellSize, cellSliceSize} = this;
++    const voxelX = THREE.Math.euclideanModulo(x, cellSize) | 0;
++    const voxelY = THREE.Math.euclideanModulo(y, cellSize) | 0;
++    const voxelZ = THREE.Math.euclideanModulo(z, cellSize) | 0;
++    return voxelY * cellSliceSize +
++           voxelZ * cellSize +
++           voxelX;
++  }
+  setVoxel(x, y, z, v) {
+    const cell = this.getCellForVoxel(x, y, z);
+    if (!cell) {
+      return;  // TODO: add a new cell?
+    }
+-    const {cellSize} = this;
+-    const voxelX = THREE.Math.euclideanModulo(x, cellSize) | 0;
+-    const voxelY = THREE.Math.euclideanModulo(y, cellSize) | 0;
+-    const voxelZ = THREE.Math.euclideanModulo(z, cellSize) | 0;
+-    const voxelOffset = voxelY * cellSize * cellSize +
+-                        voxelZ * cellSize +
+-                        voxelX;
++    const voxelOffset = this.computeVoxelOffset(x, y, z);
+    cell[voxelOffset] = v;
+  }
+  getVoxel(x, y, z) {
+    const cell = this.getCellForVoxel(x, y, z);
+    if (!cell) {
+      return 0;
+    }
+-    const {cellSize} = this;
+-    const voxelX = THREE.Math.euclideanModulo(x, cellSize) | 0;
+-    const voxelY = THREE.Math.euclideanModulo(y, cellSize) | 0;
+-    const voxelZ = THREE.Math.euclideanModulo(z, cellSize) | 0;
+-    const voxelOffset = voxelY * cellSize * cellSize +
+-                        voxelZ * cellSize +
+-                        voxelX;
++    const voxelOffset = this.computeVoxelOffset(x, y, z);
+    return cell[voxelOffset];
+  }
+  generateGeometryDataForCell(cellX, cellY, cellZ) {
+
+  ...
+}
+```
+
+Now let's make some code to fill out the first cell with voxels.
+
+```js
+const cellSize = 32;
+
+const world = new VoxelWorld(cellSize);
+
+for (let y = 0; y < cellSize; ++y) {
+  for (let z = 0; z < cellSize; ++z) {
+    for (let x = 0; x < cellSize; ++x) {
+      const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
+      if (y < height) {
+        world.setVoxel(x, y, z, 1);
+      }
+    }
+  }
+}
+```
+
+and some code to actually generate geometry like we covered in
+[the article on custom BufferGeometry](threejs-custom-buffergeometry.html).
+
+```js
+const {positions, normals, indices} = world.generateGeometryDataForCell(0, 0, 0);
+const geometry = new THREE.BufferGeometry();
+const material = new THREE.MeshLambertMaterial({color: 'green'});
+
+const positionNumComponents = 3;
+const normalNumComponents = 3;
+geometry.addAttribute(
+    'position',
+    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
+geometry.addAttribute(
+    'normal',
+    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
+geometry.setIndex(indices);
+const mesh = new THREE.Mesh(geometry, material);
+scene.add(mesh);
+```
+
+let's try it
+
+{{{example url="../threejs-voxel-geometry-culled-faces.html" }}}
+
+That seems to be working! Okay, let's add in textures.
+
+Searching on the net I found [this set](https://www.minecraftforum.net/forums/mapping-and-modding-java-edition/resource-packs/1245961-16x-1-7-4-wip-flourish)
+of [CC-BY-NC-SA](https://creativecommons.org/licenses/by-nc-sa/4.0/) licensed minecraft textures
+by [Joshtimus](https://www.minecraftforum.net/members/Joshtimus).
+I picked a few at random and built this [texture atlas](https://www.google.com/search?q=texture+atlas).
+
+<div class="threejs_center"><img class="checkerboard" src="../resources/images/minecraft/flourish-cc-by-nc-sa.png" style="width: 512px; image-rendering: pixelated;"></div>
+
+To make things simple they are arranged a voxel type per column
+where the top row is the side of a voxel. The 2nd row is
+the top of voxel, and the 3rd row is the bottom of the voxel.
+
+Knowing that we can add info to our `VoxelWorld.faces` data
+to specify for each face which row to use and the UVs to use
+for that face.
+
+```js
+VoxelWorld.faces = [
+  { // left
++    uvRow: 0,
+    dir: [ -1,  0,  0, ],
+    corners: [
+-      [ 0, 1, 0 ],
+-      [ 0, 0, 0 ],
+-      [ 0, 1, 1 ],
+-      [ 0, 0, 1 ],
++      { pos: [ 0, 1, 0 ], uv: [ 0, 1 ], },
++      { pos: [ 0, 0, 0 ], uv: [ 0, 0 ], },
++      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ], },
++      { pos: [ 0, 0, 1 ], uv: [ 1, 0 ], },
+    ],
+  },
+  { // right
++    uvRow: 0,
+    dir: [  1,  0,  0, ],
+    corners: [
+-      [ 1, 1, 1 ],
+-      [ 1, 0, 1 ],
+-      [ 1, 1, 0 ],
+-      [ 1, 0, 0 ],
++      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ], },
++      { pos: [ 1, 0, 1 ], uv: [ 0, 0 ], },
++      { pos: [ 1, 1, 0 ], uv: [ 1, 1 ], },
++      { pos: [ 1, 0, 0 ], uv: [ 1, 0 ], },
+    ],
+  },
+  { // bottom
++    uvRow: 1,
+    dir: [  0, -1,  0, ],
+    corners: [
+-      [ 1, 0, 1 ],
+-      [ 0, 0, 1 ],
+-      [ 1, 0, 0 ],
+-      [ 0, 0, 0 ],
++      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ], },
++      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ], },
++      { pos: [ 1, 0, 0 ], uv: [ 1, 1 ], },
++      { pos: [ 0, 0, 0 ], uv: [ 0, 1 ], },
+    ],
+  },
+  { // top
++    uvRow: 2,
+    dir: [  0,  1,  0, ],
+    corners: [
+-      [ 0, 1, 1 ],
+-      [ 1, 1, 1 ],
+-      [ 0, 1, 0 ],
+-      [ 1, 1, 0 ],
++      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ], },
++      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ], },
++      { pos: [ 0, 1, 0 ], uv: [ 1, 0 ], },
++      { pos: [ 1, 1, 0 ], uv: [ 0, 0 ], },
+    ],
+  },
+  { // back
++    uvRow: 0,
+    dir: [  0,  0, -1, ],
+    corners: [
+-      [ 1, 0, 0 ],
+-      [ 0, 0, 0 ],
+-      [ 1, 1, 0 ],
+-      [ 0, 1, 0 ],
++      { pos: [ 1, 0, 0 ], uv: [ 0, 0 ], },
++      { pos: [ 0, 0, 0 ], uv: [ 1, 0 ], },
++      { pos: [ 1, 1, 0 ], uv: [ 0, 1 ], },
++      { pos: [ 0, 1, 0 ], uv: [ 1, 1 ], },
+    ],
+  },
+  { // front
++    uvRow: 0,
+    dir: [  0,  0,  1, ],
+    corners: [
+-      [ 0, 0, 1 ],
+-      [ 1, 0, 1 ],
+-      [ 0, 1, 1 ],
+-      [ 1, 1, 1 ],
++      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ], },
++      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ], },
++      { pos: [ 0, 1, 1 ], uv: [ 0, 1 ], },
++      { pos: [ 1, 1, 1 ], uv: [ 1, 1 ], },
+    ],
+  },
+];
+```
+
+And we can update the code to use that data. We need to
+know the size of a tile in the texture atlas and the dimensions
+of the texture.
+
+```js
+class VoxelWorld {
+-  constructor(cellSize) {
+-    this.cellSize = cellSize;
++  constructor(options) {
++    this.cellSize = options.cellSize;
++    this.tileSize = options.tileSize;
++    this.tileTextureWidth = options.tileTextureWidth;
++    this.tileTextureHeight = options.tileTextureHeight;
++    const {cellSize} = this;
++    this.cellSliceSize = cellSize * cellSize;
++    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
+  }
+
+  ...
+
+  generateGeometryDataForCell(cellX, cellY, cellZ) {
+-    const {cellSize} = this;
++    const {cellSize, tileSize, tileTextureWidth, tileTextureHeight} = this;
+    const positions = [];
+    const normals = [];
++    const uvs = [];
+    const indices = [];
+    const startX = cellX * cellSize;
+    const startY = cellY * cellSize;
+    const startZ = cellZ * cellSize;
+
+    for (let y = 0; y < cellSize; ++y) {
+      const voxelY = startY + y;
+      for (let z = 0; z < cellSize; ++z) {
+        const voxelZ = startZ + z;
+        for (let x = 0; x < cellSize; ++x) {
+          const voxelX = startX + x;
+          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
+          if (voxel) {
+            const uvVoxel = voxel - 1;  // voxel 0 is sky so for UVs we start at 0
+            // There is a voxel here but do we need faces for it?
+-            for (const {dir, corners} of VoxelWorld.faces) {
++            for (const {dir, corners, uvRow} of VoxelWorld.faces) {
+              const neighbor = this.getVoxel(
+                  voxelX + dir[0],
+                  voxelY + dir[1],
+                  voxelZ + dir[2]);
+              if (!neighbor) {
+                // this voxel has no neighbor in this direction so we need a face.
+                const ndx = positions.length / 3;
+-                for (const pos of corners) {
++                for (const {pos, uv} of corners) {
+                  positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
+                  normals.push(...dir);
++                  uvs.push(
++                        (uvRow +     uv[0]) * tileSize / tileTextureWidth,
++                    1 - (uvRow + 1 - uv[1]) * tileSize / tileTextureHeight);
+                }
+                indices.push(
+                  ndx, ndx + 1, ndx + 2,
+                  ndx + 2, ndx + 1, ndx + 3,
+                );
+              }
+            }
+          }
+        }
+      }
+    }
+
+    return {
+      positions,
+      normals,
+      uvs,
+      indices,
+    };
+  }
+}
+```
+
+We then need to [load the texture](threejs-textures.html)
+
+```js
+const loader = new THREE.TextureLoader();
+const texture = loader.load('resources/images/minecraft/flourish-cc-by-nc-sa.png', render);
+texture.magFilter = THREE.NearestFilter;
+texture.minFilter = THREE.NearestFilter;
+```
+
+and pass the settings to the `VoxelWorld` class
+
+```js
++const tileSize = 16;
++const tileTextureWidth = 256;
++const tileTextureHeight = 64;
+-const world = new VoxelWorld(cellSize);
++const world = new VoxelWorld({
++  cellSize,
++  tileSize,
++  tileTextureWidth,
++  tileTextureHeight,
++});
+```
+
+Let's actually use the UVs when we create the geometry
+and the texture when we make the material
+
+```js
+-const {positions, normals, indices} = world.generateGeometryDataForCell(0, 0, 0);
++const {positions, normals, uvs, indices} = world.generateGeometryDataForCell(0, 0, 0);
+const geometry = new THREE.BufferGeometry();
+-const material = new THREE.MeshLambertMaterial({color: 'green'});
++const material = new THREE.MeshLambertMaterial({
++  map: texture,
++  side: THREE.DoubleSide,
++  alphaTest: 0.1,
++  transparent: true,
++});
+
+const positionNumComponents = 3;
+const normalNumComponents = 3;
++const uvNumComponents = 2;
+geometry.addAttribute(
+    'position',
+    new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
+geometry.addAttribute(
+    'normal',
+    new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
++geometry.addAttribute(
++    'uv',
++    new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
+geometry.setIndex(indices);
+const mesh = new THREE.Mesh(geometry, material);
+scene.add(mesh);
+```
+
+One last thing, we actually need to set some voxels
+to use different textures.
+
+```js
+for (let y = 0; y < cellSize; ++y) {
+  for (let z = 0; z < cellSize; ++z) {
+    for (let x = 0; x < cellSize; ++x) {
+      const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
+      if (y < height) {
+-        world.setVoxel(x, y, z, 1);
++        world.setVoxel(x, y, z, randInt(1, 17));
+      }
+    }
+  }
+}
+
++function randInt(min, max) {
++  return Math.floor(Math.random() * (max - min) + min);
++}
+```
+
+and with that we get textures!
+
+{{{example url="../threejs-voxel-geometry-culled-faces-with-textures.html"}}}
+
+Let's make it support more than one cell.
+
+To do this lets store cells in an object using cell ids.
+A cell id will just be a cell's coordinates separated by
+a comma. In other words if we ask for voxel 35,0,0
+that is in cell 1,0,0 so its id is `"1,0,0"`.
+
+```js
+class VoxelWorld {
+  constructor(options) {
+    this.cellSize = options.cellSize;
+    this.tileSize = options.tileSize;
+    this.tileTextureWidth = options.tileTextureWidth;
+    this.tileTextureHeight = options.tileTextureHeight;
+    const {cellSize} = this;
+    this.cellSliceSize = cellSize * cellSize;
+-    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
++    this.cells = {};
+  }
++  computeCellId(x, y, z) {
++    const {cellSize} = this;
++    const cellX = Math.floor(x / cellSize);
++    const cellY = Math.floor(y / cellSize);
++    const cellZ = Math.floor(z / cellSize);
++    return `${cellX},${cellY},${cellZ}`;
++  }
++  getCellForVoxel(x, y, z) {
+-    const cellX = Math.floor(x / cellSize);
+-    const cellY = Math.floor(y / cellSize);
+-    const cellZ = Math.floor(z / cellSize);
+-    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
+-      return null;
+-    }
+-    return this.cell;
++    return this.cells[this.computeCellId(x, y, z)];
+  }
+
+   ...
+}
+```
+
+and now we can make `setVoxel` add new cells if
+we try to set a voxel in a cell that does not yet exist
+
+```js
+  setVoxel(x, y, z, v) {
+-    const cell = this.getCellForVoxel(x, y, z);
++    let cell = this.getCellForVoxel(x, y, z);
+    if (!cell) {
+-      return 0;
++      cell = this.addCellForVoxel(x, y, z);
+    }
+    const voxelOffset = this.computeVoxelOffset(x, y, z);
+    cell[voxelOffset] = v;
+  }
++  addCellForVoxel(x, y, z) {
++    const cellId = this.computeCellId(x, y, z);
++    let cell = this.cells[cellId];
++    if (!cell) {
++      const {cellSize} = this;
++      cell = new Uint8Array(cellSize * cellSize * cellSize);
++      this.cells[cellId] = cell;
++    }
++    return cell;
++  }
+```
+
+Let's make this editable.
+
+First we`ll add a UI. Using radio buttons we can make an 8x2
+array of tiles
+
+```html
+<body>
+  <canvas id="c"></canvas>
++  <div id="ui">
++    <div class="tiles">
++      <input type="radio" name="voxel" id="voxel1" value="1"><label for="voxel1" style="background-position:   -0% -0%"></label>
++      <input type="radio" name="voxel" id="voxel2" value="2"><label for="voxel2" style="background-position: -100% -0%"></label>
++      <input type="radio" name="voxel" id="voxel3" value="3"><label for="voxel3" style="background-position: -200% -0%"></label>
++      <input type="radio" name="voxel" id="voxel4" value="4"><label for="voxel4" style="background-position: -300% -0%"></label>
++      <input type="radio" name="voxel" id="voxel5" value="5"><label for="voxel5" style="background-position: -400% -0%"></label>
++      <input type="radio" name="voxel" id="voxel6" value="6"><label for="voxel6" style="background-position: -500% -0%"></label>
++      <input type="radio" name="voxel" id="voxel7" value="7"><label for="voxel7" style="background-position: -600% -0%"></label>
++      <input type="radio" name="voxel" id="voxel8" value="8"><label for="voxel8" style="background-position: -700% -0%"></label>
++    </div>
++    <div class="tiles">
++      <input type="radio" name="voxel" id="voxel9"  value="9" ><label for="voxel9"  style="background-position:  -800% -0%"></label>
++      <input type="radio" name="voxel" id="voxel10" value="10"><label for="voxel10" style="background-position:  -900% -0%"></label>
++      <input type="radio" name="voxel" id="voxel11" value="11"><label for="voxel11" style="background-position: -1000% -0%"></label>
++      <input type="radio" name="voxel" id="voxel12" value="12"><label for="voxel12" style="background-position: -1100% -0%"></label>
++      <input type="radio" name="voxel" id="voxel13" value="13"><label for="voxel13" style="background-position: -1200% -0%"></label>
++      <input type="radio" name="voxel" id="voxel14" value="14"><label for="voxel14" style="background-position: -1300% -0%"></label>
++      <input type="radio" name="voxel" id="voxel15" value="15"><label for="voxel15" style="background-position: -1400% -0%"></label>
++      <input type="radio" name="voxel" id="voxel16" value="16"><label for="voxel16" style="background-position: -1500% -0%"></label>
++    </div>
++  </div>
+</body>
+```
+
+And add some CSS to style it, display the tiles and highlight
+the current selection
+
+```css
+body {
+    margin: 0;
+}
+#c {
+    width: 100vw;
+    height: 100vh;
+    display: block;
+}
++#ui {
++    position: absolute;
++    left: 10px;
++    top: 10px;
++    background: rgba(0, 0, 0, 0.8);
++    padding: 5px;
++}
++#ui input[type=radio] {
++  width: 0;
++  height: 0;
++  display: none;
++}
++#ui input[type=radio] + label {
++  background-image: url('resources/images/minecraft/flourish-cc-by-nc-sa.png');
++  background-size: 1600% 400%;
++  image-rendering: pixelated;
++  width: 64px;
++  height: 64px;
++  display: inline-block;
++}
++#ui input[type=radio]:checked + label {
++  outline: 3px solid red;
++}
++@media (max-width: 600px), (max-height: 600px) {
++  #ui input[type=radio] + label {
++    width: 32px;
++    height: 32px;
++  }
++}
+```
+
+The UX will be as follows. If no tile is selected and you click a voxel that
+voxel will be erased or if you click a voxel and are holding the shift key it
+will be erased. Otherwise if a tiles is selected it will be added. You can
+deselect the selected tile type by clicking it again.
+
+This code will let the user unselect the highlighted
+radio button.
+
+```js
+let currentVoxel = 0;
+let currentId;
+
+document.querySelectorAll('#ui .tiles input[type=radio][name=voxel]').forEach((elem) => {
+  elem.addEventListener('click', allowUncheck);
+});
+
+function allowUncheck() {
+  if (this.id === currentId) {
+    this.checked = false;
+    currentId = undefined;
+    currentVoxel = 0;
+  } else {
+    currentId = this.id;
+    currentVoxel = parseInt(this.value);
+  }
+}
+```
+
+And this below code will let us set a voxel based on where
+the user clicks. It uses code similar to the code we
+made in [the article on picking](threejs-picking.html)
+but it's not using the built in `RayCaster`. Instead
+it's using `VoxelWorld.intersectRay` which returns
+the position of intersection and the normal of the face
+hit.
+
+```js
+const mouse = {
+  x: 0,
+  y: 0,
+};
+function placeVoxel(event) {
+  const x = (event.clientX / canvas.clientWidth ) *  2 - 1;
+  const y = (event.clientY / canvas.clientHeight) * -2 + 1;  // note we flip Y
+
+  const start = new THREE.Vector3();
+  const end = new THREE.Vector3();
+  start.setFromMatrixPosition(camera.matrixWorld);
+  end.set(x, y, 1).unproject(camera);
+
+  const intersection = world.intersectRay(start, end);
+  if (intersection) {
+    const voxelId = event.shiftKey ? 0 : currentVoxel;
+    // the intersection point is on the face. That means
+    // the math imprecision could put us on either side of the face.
+    // so go half a normal into the voxel if removing (currentVoxel = 0)
+    // our out of the voxel if adding (currentVoxel  > 0)
+    const pos = intersection.position.map((v, ndx) => {
+      return v + intersection.normal[ndx] * (voxelId > 0 ? 0.5 : -0.5);
+    });
+    world.setVoxel(...pos, voxelId);
+    updateVoxelGeometry(...pos);
+    requestRenderIfNotRequested();
+  }
+}
+function recordStartPosition(event) {
+  mouse.x = event.clientX;
+  mouse.y = event.clientY;
+  mouse.moveX = 0;
+  mouse.moveY = 0;
+}
+function recordMovement(event) {
+  mouse.moveX += Math.abs(mouse.x - event.clientX);
+  mouse.moveY += Math.abs(mouse.y - event.clientY);
+}
+function placeVoxelIfNoMovement(event) {
+  if (mouse.moveX < 5 && mouse.moveY < 5) {
+    placeVoxel(event);
+  }
+  window.removeEventListener('mousemove', recordMovement);
+  window.removeEventListener('mouseup', placeVoxelIfNoMovement);
+}
+canvas.addEventListener('mousedown', (event) => {
+  event.preventDefault();
+  recordStartPosition(event);
+  window.addEventListener('mousemove', recordMovement);
+  window.addEventListener('mouseup', placeVoxelIfNoMovement);
+}, {passive: false});
+canvas.addEventListener('touchstart', (event) => {
+  event.preventDefault();
+  recordStartPosition(event.touches[0]);
+}, {passive: false});
+canvas.addEventListener('touchmove', (event) => {
+  event.preventDefault();
+  recordMovement(event.touches[0]);
+}, {passive: false});
+canvas.addEventListener('touchend', () => {
+  placeVoxelIfNoMovement({
+    clientX: mouse.x,
+    clientY: mouse.y,
+  });
+});
+```
+
+There's a lot going on in the code above. Basically the mouse
+has a dual purpose. One is to move the camera. The other is to
+edit the world. Placing/Erasing a voxel happen when you let off the mouse
+but only if you have not moved the mouse since you first pressed down.
+This is just a guess that if you did move the mouse you were trying
+to move the camera, not place a block. `moveX` and `moveY` are
+in absolute movement so if you move to the left 10 and then back to
+the right 10 you'll have moved 20 units. In that case the user likely
+was just rotating the model back and forth and does not want to
+place a block. I didn't do any testing to see if `5` is a good range or not. 
+
+In the code we call `world.setVoxel` to set a voxel and
+then `updateVoxelGeometry` to update the three.js geometry
+based on what's changed.
+
+Let's make that now. If the user clicks a
+voxel on the edge of a cell then the geometry for the voxel
+in the adjacent cell might need new geometry. This means
+we need to check the cell for the voxel we just edited
+as well as in all 6 directions from that cell.
+
+
+```js
+const neighborOffsets = [
+  [ 0,  0,  0], // self
+  [-1,  0,  0], // left
+  [ 1,  0,  0], // right
+  [ 0, -1,  0], // down
+  [ 0,  1,  0], // up
+  [ 0,  0, -1], // back
+  [ 0,  0,  1], // front
+];
+function updateVoxelGeometry(x, y, z) {
+  const updatedCellIds = {};
+  for (const offset of neighborOffsets) {
+    const ox = x + offset[0];
+    const oy = y + offset[1];
+    const oz = z + offset[2];
+    const cellId = world.computeCellId(ox, oy, oz);
+    if (!updatedCellIds[cellId]) {
+      updatedCellIds[cellId] = true;
+      updateCellGeometry(ox, oy, oz);
+    }
+  }
+}
+```
+
+I thought about checking for adjacent cells like 
+
+```js
+const voxelX = THREE.Math.euclideanModulo(x, cellSize) | 0;
+if (voxelX === 0) {
+  // update cell to the left
+} else if (voxelX === cellSize - 1) {
+  // update cell to the right
+}
+```
+
+and there would be 4 more checks for the other 4 directions
+but it occurred to me the code would be much simpler with
+just an array of offsets and saving off the cell ids of
+the cells we already updated. If the updated voxel is not
+on the edge of a cell then the test will quickly reject updating
+the same cell.
+
+For `updateCellGeometry` we're just going to take the code we
+had before that was generating the geometry for one cell
+and make it handle multiple cells.
+
+```js
+const cellIdToMesh = {};
+function updateCellGeometry(x, y, z) {
+  const cellX = Math.floor(x / cellSize);
+  const cellY = Math.floor(y / cellSize);
+  const cellZ = Math.floor(z / cellSize);
+  const cellId = world.computeCellId(x, y, z);
+  let mesh = cellIdToMesh[cellId];
+  if (!mesh) {
+    const geometry = new THREE.BufferGeometry();
+    const positionNumComponents = 3;
+    const normalNumComponents = 3;
+    const uvNumComponents = 2;
+
+    geometry.addAttribute(
+        'position',
+        new THREE.BufferAttribute(new Float32Array(0), positionNumComponents));
+    geometry.addAttribute(
+        'normal',
+        new THREE.BufferAttribute(new Float32Array(0), normalNumComponents));
+    geometry.addAttribute(
+        'uv',
+        new THREE.BufferAttribute(new Float32Array(0), uvNumComponents));
+
+    mesh = new THREE.Mesh(geometry, material);
+    mesh.name = cellId;
+    cellIdToMesh[cellId] = mesh;
+    scene.add(mesh);
+    mesh.position.set(cellX * cellSize, cellY * cellSize, cellZ * cellSize);
+  }
+
+  const {positions, normals, uvs, indices} = world.generateGeometryDataForCell(cellX, cellY, cellZ);
+  const geometry = mesh.geometry;
+  geometry.getAttribute('position').setArray(new Float32Array(positions)).needsUpdate = true;
+  geometry.getAttribute('normal').setArray(new Float32Array(normals)).needsUpdate = true;
+  geometry.getAttribute('uv').setArray(new Float32Array(uvs)).needsUpdate = true;
+  geometry.setIndex(indices);
+  geometry.computeBoundingSphere();
+}
+```
+
+The code above checks a map of cell ids to meshes. If
+we ask for a cell that doesn't exist a new `Mesh` is made
+and added to the correct place in world space.
+At the end we update the attributes and indices with the new data.
+
+{{{example url="../threejs-voxel-geometry-culled-faces-ui.html"}}}
+
+Some notes:
+
+`RayCaster` might have worked just fine. I didn't try it.
+Instead I found [a voxel specific raycaster](http://www.cse.chalmers.se/edu/year/2010/course/TDA361/grid.pdf).
+that is optimized for voxels.
+
+I made `intersectRay` part of VoxelWorld because it seemed
+like if it gets too slow we could raycast against cells
+before raycasting on voxels as a simple speed up if it becomes
+too slow.
+
+You might want to change the length of the raycast 
+as currently it's all the way to Z-far. I expect if the
+user clicks something too far way they don't really want
+to be placing blocks on the other side of the world that
+are 1 or 2 pixel large.
+
+Calling `geometry.computeBoundingSphere` might be slow.
+We could just manually set the bounding sphere to the fit
+the entire cell.
+
+Do we want remove cells if all voxels in that cell are 0? 
+That would probably be reasonable change if we wanted to ship this.
+
+Thinking about how this works it's clear the absolute
+worst case is a checking board of on and off voxels. I don't
+know off the top of my head what other strategies to use
+if things get too slow. Maybe getting too slow would just
+encourage the user not to make giant checkerboard areas.
+
+To keep it simple the texture atlas is just 1 column
+per voxel type. It would be better to make something more
+flexible where we have a table of voxel types and each
+type can specify where its face textures are in the atlas.
+As it is lots of space is wasted.
+
+Looking at real minecraft there are tiles that are not
+voxels, not cubes. Like a fence tile or flowers. To do that
+we'd again need some table of voxel types and for each
+voxel whether it's a cube or some other geometry. If it's
+not a cube the neighbor check when generating the geometry
+would also need to change. A flower voxel next to another
+voxel should not remove the faces between them.
+
+If you want to make some minecraft like thing using three.js
+I hope this has given you some ideas where to start and how
+to generate some what efficient geometry.
+
+<canvas id="c"></canvas>
+<script src="../resources/threejs/r105/three.min.js"></script>
+<script src="../resources/threejs/r105/js/utils/BufferGeometryUtils.js"></script>
+<script src="../resources/threejs/r105/js/controls/TrackballControls.js"></script>
+<script src="resources/threejs-lesson-utils.js"></script>
+<script src="resources/threejs-voxel-geometry.js"></script>
+
+

+ 1 - 0
threejs/lessons/toc.html

@@ -22,6 +22,7 @@
     <li><a href="/threejs/lessons/threejs-canvas-textures.html">Using A Canvas for Dynamic Textures</a></li>
     <li><a href="/threejs/lessons/threejs-canvas-textures.html">Using A Canvas for Dynamic Textures</a></li>
     <li><a href="/threejs/lessons/threejs-billboards.html">Billboards and Facades</a></li>
     <li><a href="/threejs/lessons/threejs-billboards.html">Billboards and Facades</a></li>
     <li><a href="/threejs/lessons/threejs-cleanup.html">Freeing Resources</a></li>
     <li><a href="/threejs/lessons/threejs-cleanup.html">Freeing Resources</a></li>
+    <li><a href="/threejs/lessons/threejs-voxel-geometry.html">Making Voxel Geometry (Minecraft)</a></li>
   </ul>
   </ul>
   <li>WebVR</li>
   <li>WebVR</li>
   <ul>
   <ul>

BIN
threejs/resources/images/minecraft/flourish-cc-by-nc-sa.png


+ 10 - 0
threejs/resources/images/minecraft/license.md

@@ -0,0 +1,10 @@
+These tiles are taken from the Flourish pack by Joshtimus
+
+Link
+https://www.minecraftforum.net/forums/mapping-and-modding-java-edition/resource-packs/1245961-16x-1-7-4-wip-flourish
+
+Artist: Joshtimus
+https://www.minecraftforum.net/members/Joshtimus
+
+Licence: CC-BY-SA-NC 4.0
+https://creativecommons.org/licenses/by-nc-sa/4.0/

+ 599 - 0
threejs/threejs-voxel-geometry-culled-faces-ui.html

@@ -0,0 +1,599 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - Voxel Geometry - UI</title>
+    <style>
+    body {
+        margin: 0;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    #ui {
+        position: absolute;
+        left: 10px;
+        top: 10px;
+        background: rgba(0, 0, 0, 0.8);
+        padding: 5px;
+    }
+    #ui input[type=radio] {
+      width: 0;
+      height: 0;
+      display: none;
+    }
+    #ui input[type=radio] + label {
+      background-image: url('resources/images/minecraft/flourish-cc-by-nc-sa.png');
+      background-size: 1600% 400%;
+      image-rendering: pixelated;
+      width: 64px;
+      height: 64px;
+      display: inline-block;
+    }
+    #ui input[type=radio]:checked + label {
+      outline: 3px solid red;
+    }
+    @media (max-width: 600px), (max-height: 600px) {
+      #ui input[type=radio] + label {
+        width: 32px;
+        height: 32px;
+      }
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+    <div id="ui">
+      <div class="tiles">
+        <input type="radio" name="voxel" id="voxel1" value="1"><label for="voxel1" style="background-position:   -0% -0%"></label>
+        <input type="radio" name="voxel" id="voxel2" value="2"><label for="voxel2" style="background-position: -100% -0%"></label>
+        <input type="radio" name="voxel" id="voxel3" value="3"><label for="voxel3" style="background-position: -200% -0%"></label>
+        <input type="radio" name="voxel" id="voxel4" value="4"><label for="voxel4" style="background-position: -300% -0%"></label>
+        <input type="radio" name="voxel" id="voxel5" value="5"><label for="voxel5" style="background-position: -400% -0%"></label>
+        <input type="radio" name="voxel" id="voxel6" value="6"><label for="voxel6" style="background-position: -500% -0%"></label>
+        <input type="radio" name="voxel" id="voxel7" value="7"><label for="voxel7" style="background-position: -600% -0%"></label>
+        <input type="radio" name="voxel" id="voxel8" value="8"><label for="voxel8" style="background-position: -700% -0%"></label>
+      </div>
+      <div class="tiles">
+        <input type="radio" name="voxel" id="voxel9"  value="9" ><label for="voxel9"  style="background-position:  -800% -0%"></label>
+        <input type="radio" name="voxel" id="voxel10" value="10"><label for="voxel10" style="background-position:  -900% -0%"></label>
+        <input type="radio" name="voxel" id="voxel11" value="11"><label for="voxel11" style="background-position: -1000% -0%"></label>
+        <input type="radio" name="voxel" id="voxel12" value="12"><label for="voxel12" style="background-position: -1100% -0%"></label>
+        <input type="radio" name="voxel" id="voxel13" value="13"><label for="voxel13" style="background-position: -1200% -0%"></label>
+        <input type="radio" name="voxel" id="voxel14" value="14"><label for="voxel14" style="background-position: -1300% -0%"></label>
+        <input type="radio" name="voxel" id="voxel15" value="15"><label for="voxel15" style="background-position: -1400% -0%"></label>
+        <input type="radio" name="voxel" id="voxel16" value="16"><label for="voxel16" style="background-position: -1500% -0%"></label>
+      </div>
+    </div>
+  </body>
+<script src="resources/threejs/r105/three.min.js"></script>
+<script src="resources/threejs/r105/js/controls/OrbitControls.js"></script>
+<script>
+'use strict';
+
+/* global THREE */
+
+class VoxelWorld {
+  constructor(options) {
+    this.cellSize = options.cellSize;
+    this.tileSize = options.tileSize;
+    this.tileTextureWidth = options.tileTextureWidth;
+    this.tileTextureHeight = options.tileTextureHeight;
+    const {cellSize} = this;
+    this.cellSliceSize = cellSize * cellSize;
+    this.cells = {};
+  }
+  computeVoxelOffset(x, y, z) {
+    const {cellSize, cellSliceSize} = this;
+    const voxelX = THREE.Math.euclideanModulo(x, cellSize) | 0;
+    const voxelY = THREE.Math.euclideanModulo(y, cellSize) | 0;
+    const voxelZ = THREE.Math.euclideanModulo(z, cellSize) | 0;
+    return voxelY * cellSliceSize +
+           voxelZ * cellSize +
+           voxelX;
+  }
+  computeCellId(x, y, z) {
+    const {cellSize} = this;
+    const cellX = Math.floor(x / cellSize);
+    const cellY = Math.floor(y / cellSize);
+    const cellZ = Math.floor(z / cellSize);
+    return `${cellX},${cellY},${cellZ}`;
+  }
+  addCellForVoxel(x, y, z) {
+    const cellId = this.computeCellId(x, y, z);
+    let cell = this.cells[cellId];
+    if (!cell) {
+      const {cellSize} = this;
+      cell = new Uint8Array(cellSize * cellSize * cellSize);
+      this.cells[cellId] = cell;
+    }
+    return cell;
+  }
+  getCellForVoxel(x, y, z) {
+    return this.cells[this.computeCellId(x, y, z)];
+  }
+  setVoxel(x, y, z, v, addCell = true) {
+    let cell = this.getCellForVoxel(x, y, z);
+    if (!cell) {
+      if (!addCell) {
+        return;
+      }
+      cell = this.addCellForVoxel(x, y, z);
+    }
+    const voxelOffset = this.computeVoxelOffset(x, y, z);
+    cell[voxelOffset] = v;
+  }
+  getVoxel(x, y, z) {
+    const cell = this.getCellForVoxel(x, y, z);
+    if (!cell) {
+      return 0;
+    }
+    const voxelOffset = this.computeVoxelOffset(x, y, z);
+    return cell[voxelOffset];
+  }
+  generateGeometryDataForCell(cellX, cellY, cellZ) {
+    const {cellSize, tileSize, tileTextureWidth, tileTextureHeight} = this;
+    const positions = [];
+    const normals = [];
+    const uvs = [];
+    const indices = [];
+    const startX = cellX * cellSize;
+    const startY = cellY * cellSize;
+    const startZ = cellZ * cellSize;
+
+    for (let y = 0; y < cellSize; ++y) {
+      const voxelY = startY + y;
+      for (let z = 0; z < cellSize; ++z) {
+        const voxelZ = startZ + z;
+        for (let x = 0; x < cellSize; ++x) {
+          const voxelX = startX + x;
+          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
+          if (voxel) {
+            // voxel 0 is sky (empty) so for UVs we start at 0
+            const uvVoxel = voxel - 1;
+            // There is a voxel here but do we need faces for it?
+            for (const {dir, corners, uvRow} of VoxelWorld.faces) {
+              const neighbor = this.getVoxel(
+                  voxelX + dir[0],
+                  voxelY + dir[1],
+                  voxelZ + dir[2]);
+              if (!neighbor) {
+                // this voxel has no neighbor in this direction so we need a face.
+                const ndx = positions.length / 3;
+                for (const {pos, uv} of corners) {
+                  positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
+                  normals.push(...dir);
+                  uvs.push(
+                        (uvVoxel +     uv[0]) * tileSize / tileTextureWidth,
+                    1 - (uvRow + 1 - uv[1]) * tileSize / tileTextureHeight);
+                }
+                indices.push(
+                  ndx, ndx + 1, ndx + 2,
+                  ndx + 2, ndx + 1, ndx + 3,
+                );
+              }
+            }
+          }
+        }
+      }
+    }
+
+    return {
+      positions,
+      normals,
+      uvs,
+      indices,
+    };
+  }
+
+    // from
+    // http://www.cse.chalmers.se/edu/year/2010/course/TDA361/grid.pdf
+    intersectRay(start, end) {
+    let dx = end.x - start.x;
+    let dy = end.y - start.y;
+    let dz = end.z - start.z;
+    const lenSq = dx * dx + dy * dy + dz * dz;
+    const len = Math.sqrt(lenSq);
+
+    dx /= len;
+    dy /= len;
+    dz /= len;
+
+    let t = 0.0;
+    let ix = Math.floor(start.x);
+    let iy = Math.floor(start.y);
+    let iz = Math.floor(start.z);
+
+    const stepX = (dx > 0) ? 1 : -1;
+    const stepY = (dy > 0) ? 1 : -1;
+    const stepZ = (dz > 0) ? 1 : -1;
+
+    const txDelta = Math.abs(1 / dx);
+    const tyDelta = Math.abs(1 / dy);
+    const tzDelta = Math.abs(1 / dz);
+
+    const xDist = (stepX > 0) ? (ix + 1 - start.x) : (start.x - ix);
+    const yDist = (stepY > 0) ? (iy + 1 - start.y) : (start.y - iy);
+    const zDist = (stepZ > 0) ? (iz + 1 - start.z) : (start.z - iz);
+
+    // location of nearest voxel boundary, in units of t
+    let txMax = (txDelta < Infinity) ? txDelta * xDist : Infinity;
+    let tyMax = (tyDelta < Infinity) ? tyDelta * yDist : Infinity;
+    let tzMax = (tzDelta < Infinity) ? tzDelta * zDist : Infinity;
+
+    let steppedIndex = -1;
+
+    // main loop along raycast vector
+    while (t <= len) {
+      const voxel = this.getVoxel(ix, iy, iz);
+      if (voxel) {
+        return {
+          position: [
+            start.x + t * dx,
+            start.y + t * dy,
+            start.z + t * dz,
+          ],
+          normal: [
+            steppedIndex === 0 ? -stepX : 0,
+            steppedIndex === 1 ? -stepY : 0,
+            steppedIndex === 2 ? -stepZ : 0,
+          ],
+          voxel,
+        };
+      }
+
+      // advance t to next nearest voxel boundary
+      if (txMax < tyMax) {
+        if (txMax < tzMax) {
+          ix += stepX;
+          t = txMax;
+          txMax += txDelta;
+          steppedIndex = 0;
+        } else {
+          iz += stepZ;
+          t = tzMax;
+          tzMax += tzDelta;
+          steppedIndex = 2;
+        }
+      } else {
+        if (tyMax < tzMax) {
+          iy += stepY;
+          t = tyMax;
+          tyMax += tyDelta;
+          steppedIndex = 1;
+        } else {
+          iz += stepZ;
+          t = tzMax;
+          tzMax += tzDelta;
+          steppedIndex = 2;
+        }
+      }
+    }
+    return null;
+  }
+}
+
+VoxelWorld.faces = [
+  { // left
+    uvRow: 0,
+    dir: [ -1,  0,  0, ],
+    corners: [
+      { pos: [ 0, 1, 0 ], uv: [ 0, 1 ], },
+      { pos: [ 0, 0, 0 ], uv: [ 0, 0 ], },
+      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ], },
+      { pos: [ 0, 0, 1 ], uv: [ 1, 0 ], },
+    ],
+  },
+  { // right
+    uvRow: 0,
+    dir: [  1,  0,  0, ],
+    corners: [
+      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ], },
+      { pos: [ 1, 0, 1 ], uv: [ 0, 0 ], },
+      { pos: [ 1, 1, 0 ], uv: [ 1, 1 ], },
+      { pos: [ 1, 0, 0 ], uv: [ 1, 0 ], },
+    ],
+  },
+  { // bottom
+    uvRow: 1,
+    dir: [  0, -1,  0, ],
+    corners: [
+      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ], },
+      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ], },
+      { pos: [ 1, 0, 0 ], uv: [ 1, 1 ], },
+      { pos: [ 0, 0, 0 ], uv: [ 0, 1 ], },
+    ],
+  },
+  { // top
+    uvRow: 2,
+    dir: [  0,  1,  0, ],
+    corners: [
+      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ], },
+      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ], },
+      { pos: [ 0, 1, 0 ], uv: [ 1, 0 ], },
+      { pos: [ 1, 1, 0 ], uv: [ 0, 0 ], },
+    ],
+  },
+  { // back
+    uvRow: 0,
+    dir: [  0,  0, -1, ],
+    corners: [
+      { pos: [ 1, 0, 0 ], uv: [ 0, 0 ], },
+      { pos: [ 0, 0, 0 ], uv: [ 1, 0 ], },
+      { pos: [ 1, 1, 0 ], uv: [ 0, 1 ], },
+      { pos: [ 0, 1, 0 ], uv: [ 1, 1 ], },
+    ],
+  },
+  { // front
+    uvRow: 0,
+    dir: [  0,  0,  1, ],
+    corners: [
+      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ], },
+      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ], },
+      { pos: [ 0, 1, 1 ], uv: [ 0, 1 ], },
+      { pos: [ 1, 1, 1 ], uv: [ 1, 1 ], },
+    ],
+  },
+];
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas});
+
+  const cellSize = 32;
+
+  const fov = 75;
+  const aspect = 2;  // the canvas default
+  const near = 0.1;
+  const far = 1000;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.set(-cellSize * .3, cellSize * .8, -cellSize * .3);
+
+  const controls = new THREE.OrbitControls(camera, canvas);
+  controls.target.set(cellSize / 2, cellSize / 3, cellSize / 2);
+  controls.update();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color('lightblue');
+
+  const tileSize = 16;
+  const tileTextureWidth = 256;
+  const tileTextureHeight = 64;
+  const loader = new THREE.TextureLoader();
+  const texture = loader.load('resources/images/minecraft/flourish-cc-by-nc-sa.png', render);
+  texture.magFilter = THREE.NearestFilter;
+  texture.minFilter = THREE.NearestFilter;
+
+  function addLight(x, y, z) {
+    const color = 0xFFFFFF;
+    const intensity = 1;
+    const light = new THREE.DirectionalLight(color, intensity);
+    light.position.set(x, y, z);
+    scene.add(light);
+  }
+  addLight(-1,  2,  4);
+  addLight( 1, -1, -2);
+
+  const world = new VoxelWorld({
+    cellSize,
+    tileSize,
+    tileTextureWidth,
+    tileTextureHeight,
+  });
+
+  const material = new THREE.MeshLambertMaterial({
+    map: texture,
+    side: THREE.DoubleSide,
+    alphaTest: 0.1,
+    transparent: true,
+  });
+
+  const cellIdToMesh = {};
+  function updateCellGeometry(x, y, z) {
+    const cellX = Math.floor(x / cellSize);
+    const cellY = Math.floor(y / cellSize);
+    const cellZ = Math.floor(z / cellSize);
+    const cellId = world.computeCellId(x, y, z);
+    let mesh = cellIdToMesh[cellId];
+    if (!mesh) {
+      const geometry = new THREE.BufferGeometry();
+      const positionNumComponents = 3;
+      const normalNumComponents = 3;
+      const uvNumComponents = 2;
+
+      geometry.addAttribute(
+          'position',
+          new THREE.BufferAttribute(new Float32Array(0), positionNumComponents));
+      geometry.addAttribute(
+          'normal',
+          new THREE.BufferAttribute(new Float32Array(0), normalNumComponents));
+      geometry.addAttribute(
+          'uv',
+          new THREE.BufferAttribute(new Float32Array(0), uvNumComponents));
+
+      mesh = new THREE.Mesh(geometry, material);
+      mesh.name = cellId;
+      cellIdToMesh[cellId] = mesh;
+      scene.add(mesh);
+      mesh.position.set(cellX * cellSize, cellY * cellSize, cellZ * cellSize);
+    }
+
+    const {positions, normals, uvs, indices} = world.generateGeometryDataForCell(cellX, cellY, cellZ);
+    const geometry = mesh.geometry;
+    geometry.getAttribute('position').setArray(new Float32Array(positions)).needsUpdate = true;
+    geometry.getAttribute('normal').setArray(new Float32Array(normals)).needsUpdate = true;
+    geometry.getAttribute('uv').setArray(new Float32Array(uvs)).needsUpdate = true;
+    geometry.setIndex(indices);
+    geometry.computeBoundingSphere();
+  }
+
+  const neighborOffsets = [
+    [ 0,  0,  0], // self
+    [-1,  0,  0], // left
+    [ 1,  0,  0], // right
+    [ 0, -1,  0], // down
+    [ 0,  1,  0], // up
+    [ 0,  0, -1], // back
+    [ 0,  0,  1], // front
+  ];
+  function updateVoxelGeometry(x, y, z) {
+    const updatedCellIds = {};
+    for (const offset of neighborOffsets) {
+      const ox = x + offset[0];
+      const oy = y + offset[1];
+      const oz = z + offset[2];
+      const cellId = world.computeCellId(ox, oy, oz);
+      if (!updatedCellIds[cellId]) {
+        updatedCellIds[cellId] = true;
+        updateCellGeometry(ox, oy, oz);
+      }
+    }
+  }
+
+  for (let y = 0; y < cellSize; ++y) {
+    for (let z = 0; z < cellSize; ++z) {
+      for (let x = 0; x < cellSize; ++x) {
+        const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
+        if (y < height) {
+          world.setVoxel(x, y, z, randInt(1, 17));
+        }
+      }
+    }
+  }
+
+  function randInt(min, max) {
+    return Math.floor(Math.random() * (max - min) + min);
+  }
+
+  updateVoxelGeometry(1, 1, 1);  // 0,0,0 will generate
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    const width = canvas.clientWidth;
+    const height = canvas.clientHeight;
+    const needResize = canvas.width !== width || canvas.height !== height;
+    if (needResize) {
+      renderer.setSize(width, height, false);
+    }
+    return needResize;
+  }
+
+  let renderRequested = false;
+
+  function render() {
+    renderRequested = undefined;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      const canvas = renderer.domElement;
+      camera.aspect = canvas.clientWidth / canvas.clientHeight;
+      camera.updateProjectionMatrix();
+    }
+
+    controls.update();
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  let currentVoxel = 0;
+  let currentId;
+
+  document.querySelectorAll('#ui .tiles input[type=radio][name=voxel]').forEach((elem) => {
+    elem.addEventListener('click', allowUncheck);
+  });
+
+  function allowUncheck() {
+    if (this.id === currentId) {
+      this.checked = false;
+      currentId = undefined;
+      currentVoxel = 0;
+    } else {
+      currentId = this.id;
+      currentVoxel = parseInt(this.value);
+    }
+  }
+
+  const mouse = {
+    x: 0,
+    y: 0,
+  };
+  function placeVoxel(event) {
+    const x = (event.clientX / canvas.clientWidth ) *  2 - 1;
+    const y = (event.clientY / canvas.clientHeight) * -2 + 1;  // note we flip Y
+
+    const start = new THREE.Vector3();
+    const end = new THREE.Vector3();
+    start.setFromMatrixPosition(camera.matrixWorld);
+    end.set(x, y, 1).unproject(camera);
+
+    const intersection = world.intersectRay(start, end);
+    if (intersection) {
+      const voxelId = event.shiftKey ? 0 : currentVoxel;
+      // the intersection point is on the face. That means
+      // the math imprecision could put us on either side of the face.
+      // so go half a normal into the voxel if removing (currentVoxel = 0)
+      // our out of the voxel if adding (currentVoxel  > 0)
+      const pos = intersection.position.map((v, ndx) => {
+        return v + intersection.normal[ndx] * (voxelId > 0 ? 0.5 : -0.5);
+      });
+      world.setVoxel(...pos, voxelId);
+      updateVoxelGeometry(...pos);
+      requestRenderIfNotRequested();
+    }
+  }
+  function recordStartPosition(event) {
+    mouse.x = event.clientX;
+    mouse.y = event.clientY;
+    mouse.moveX = 0;
+    mouse.moveY = 0;
+  }
+  function recordMovement(event) {
+    mouse.moveX += Math.abs(mouse.x - event.clientX);
+    mouse.moveY += Math.abs(mouse.y - event.clientY);
+  }
+  function placeVoxelIfNoMovement(event) {
+    if (mouse.moveX < 5 && mouse.moveY < 5) {
+      placeVoxel(event);
+    }
+    window.removeEventListener('mousemove', recordMovement);
+    window.removeEventListener('mouseup', placeVoxelIfNoMovement);
+  }
+  canvas.addEventListener('mousedown', (event) => {
+    event.preventDefault();
+    recordStartPosition(event);
+    window.addEventListener('mousemove', recordMovement);
+    window.addEventListener('mouseup', placeVoxelIfNoMovement);
+  }, {passive: false});
+  canvas.addEventListener('touchstart', (event) => {
+    event.preventDefault();
+    recordStartPosition(event.touches[0]);
+  }, {passive: false});
+  canvas.addEventListener('touchmove', (event) => {
+    event.preventDefault();
+    recordMovement(event.touches[0]);
+  }, {passive: false});
+  canvas.addEventListener('touchend', () => {
+    placeVoxelIfNoMovement({
+      clientX: mouse.x,
+      clientY: mouse.y,
+    });
+  });
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+}
+
+main();
+</script>
+</html>
+

+ 320 - 0
threejs/threejs-voxel-geometry-culled-faces-with-textures.html

@@ -0,0 +1,320 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - Voxel Geometry - Textures</title>
+    <style>
+    body {
+        margin: 0;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+  </body>
+<script src="resources/threejs/r105/three.min.js"></script>
+<script src="resources/threejs/r105/js/controls/OrbitControls.js"></script>
+<script>
+'use strict';
+
+/* global THREE */
+
+class VoxelWorld {
+  constructor(options) {
+    this.cellSize = options.cellSize;
+    this.tileSize = options.tileSize;
+    this.tileTextureWidth = options.tileTextureWidth;
+    this.tileTextureHeight = options.tileTextureHeight;
+    const {cellSize} = this;
+    this.cellSliceSize = cellSize * cellSize;
+    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
+  }
+  computeVoxelOffset(x, y, z) {
+    const {cellSize, cellSliceSize} = this;
+    const voxelX = THREE.Math.euclideanModulo(x, cellSize) | 0;
+    const voxelY = THREE.Math.euclideanModulo(y, cellSize) | 0;
+    const voxelZ = THREE.Math.euclideanModulo(z, cellSize) | 0;
+    return voxelY * cellSliceSize +
+           voxelZ * cellSize +
+           voxelX;
+  }
+  getCellForVoxel(x, y, z) {
+    const {cellSize} = this;
+    const cellX = Math.floor(x / cellSize);
+    const cellY = Math.floor(y / cellSize);
+    const cellZ = Math.floor(z / cellSize);
+    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
+      return null;
+    }
+    return this.cell;
+  }
+  setVoxel(x, y, z, v) {
+    const cell = this.getCellForVoxel(x, y, z);
+    if (!cell) {
+      return;  // TODO: add a new cell?
+    }
+    const voxelOffset = this.computeVoxelOffset(x, y, z);
+    cell[voxelOffset] = v;
+  }
+  getVoxel(x, y, z) {
+    const cell = this.getCellForVoxel(x, y, z);
+    if (!cell) {
+      return 0;
+    }
+    const voxelOffset = this.computeVoxelOffset(x, y, z);
+    return cell[voxelOffset];
+  }
+  generateGeometryDataForCell(cellX, cellY, cellZ) {
+    const {cellSize, tileSize, tileTextureWidth, tileTextureHeight} = this;
+    const positions = [];
+    const normals = [];
+    const uvs = [];
+    const indices = [];
+    const startX = cellX * cellSize;
+    const startY = cellY * cellSize;
+    const startZ = cellZ * cellSize;
+
+    for (let y = 0; y < cellSize; ++y) {
+      const voxelY = startY + y;
+      for (let z = 0; z < cellSize; ++z) {
+        const voxelZ = startZ + z;
+        for (let x = 0; x < cellSize; ++x) {
+          const voxelX = startX + x;
+          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
+          if (voxel) {
+            // voxel 0 is sky (empty) so for UVs we start at 0
+            const uvVoxel = voxel - 1;
+            // There is a voxel here but do we need faces for it?
+            for (const {dir, corners, uvRow} of VoxelWorld.faces) {
+              const neighbor = this.getVoxel(
+                  voxelX + dir[0],
+                  voxelY + dir[1],
+                  voxelZ + dir[2]);
+              if (!neighbor) {
+                // this voxel has no neighbor in this direction so we need a face.
+                const ndx = positions.length / 3;
+                for (const {pos, uv} of corners) {
+                  positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
+                  normals.push(...dir);
+                  uvs.push(
+                        (uvVoxel +     uv[0]) * tileSize / tileTextureWidth,
+                    1 - (uvRow + 1 - uv[1]) * tileSize / tileTextureHeight);
+                }
+                indices.push(
+                  ndx, ndx + 1, ndx + 2,
+                  ndx + 2, ndx + 1, ndx + 3,
+                );
+              }
+            }
+          }
+        }
+      }
+    }
+
+    return {
+      positions,
+      normals,
+      uvs,
+      indices,
+    };
+  }
+}
+
+VoxelWorld.faces = [
+  { // left
+    uvRow: 0,
+    dir: [ -1,  0,  0, ],
+    corners: [
+      { pos: [ 0, 1, 0 ], uv: [ 0, 1 ], },
+      { pos: [ 0, 0, 0 ], uv: [ 0, 0 ], },
+      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ], },
+      { pos: [ 0, 0, 1 ], uv: [ 1, 0 ], },
+    ],
+  },
+  { // right
+    uvRow: 0,
+    dir: [  1,  0,  0, ],
+    corners: [
+      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ], },
+      { pos: [ 1, 0, 1 ], uv: [ 0, 0 ], },
+      { pos: [ 1, 1, 0 ], uv: [ 1, 1 ], },
+      { pos: [ 1, 0, 0 ], uv: [ 1, 0 ], },
+    ],
+  },
+  { // bottom
+    uvRow: 1,
+    dir: [  0, -1,  0, ],
+    corners: [
+      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ], },
+      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ], },
+      { pos: [ 1, 0, 0 ], uv: [ 1, 1 ], },
+      { pos: [ 0, 0, 0 ], uv: [ 0, 1 ], },
+    ],
+  },
+  { // top
+    uvRow: 2,
+    dir: [  0,  1,  0, ],
+    corners: [
+      { pos: [ 0, 1, 1 ], uv: [ 1, 1 ], },
+      { pos: [ 1, 1, 1 ], uv: [ 0, 1 ], },
+      { pos: [ 0, 1, 0 ], uv: [ 1, 0 ], },
+      { pos: [ 1, 1, 0 ], uv: [ 0, 0 ], },
+    ],
+  },
+  { // back
+    uvRow: 0,
+    dir: [  0,  0, -1, ],
+    corners: [
+      { pos: [ 1, 0, 0 ], uv: [ 0, 0 ], },
+      { pos: [ 0, 0, 0 ], uv: [ 1, 0 ], },
+      { pos: [ 1, 1, 0 ], uv: [ 0, 1 ], },
+      { pos: [ 0, 1, 0 ], uv: [ 1, 1 ], },
+    ],
+  },
+  { // front
+    uvRow: 0,
+    dir: [  0,  0,  1, ],
+    corners: [
+      { pos: [ 0, 0, 1 ], uv: [ 0, 0 ], },
+      { pos: [ 1, 0, 1 ], uv: [ 1, 0 ], },
+      { pos: [ 0, 1, 1 ], uv: [ 0, 1 ], },
+      { pos: [ 1, 1, 1 ], uv: [ 1, 1 ], },
+    ],
+  },
+];
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas});
+
+  const cellSize = 32;
+
+  const fov = 75;
+  const aspect = 2;  // the canvas default
+  const near = 0.1;
+  const far = 1000;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.set(-cellSize * .3, cellSize * .8, -cellSize * .3);
+
+  const controls = new THREE.OrbitControls(camera, canvas);
+  controls.target.set(cellSize / 2, cellSize / 3, cellSize / 2);
+  controls.update();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color('lightblue');
+
+  function addLight(x, y, z) {
+    const color = 0xFFFFFF;
+    const intensity = 1;
+    const light = new THREE.DirectionalLight(color, intensity);
+    light.position.set(x, y, z);
+    scene.add(light);
+  }
+  addLight(-1,  2,  4);
+  addLight( 1, -1, -2);
+
+
+  const loader = new THREE.TextureLoader();
+  const texture = loader.load('resources/images/minecraft/flourish-cc-by-nc-sa.png', render);
+  texture.magFilter = THREE.NearestFilter;
+  texture.minFilter = THREE.NearestFilter;
+
+  const tileSize = 16;
+  const tileTextureWidth = 256;
+  const tileTextureHeight = 64;
+  const world = new VoxelWorld({
+    cellSize,
+    tileSize,
+    tileTextureWidth,
+    tileTextureHeight,
+  });
+
+  for (let y = 0; y < cellSize; ++y) {
+    for (let z = 0; z < cellSize; ++z) {
+      for (let x = 0; x < cellSize; ++x) {
+        const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
+        if (y < height) {
+          world.setVoxel(x, y, z, randInt(1, 17));
+        }
+      }
+    }
+  }
+
+  function randInt(min, max) {
+    return Math.floor(Math.random() * (max - min) + min);
+  }
+
+  const {positions, normals, uvs, indices} = world.generateGeometryDataForCell(0, 0, 0);
+  const geometry = new THREE.BufferGeometry();
+  const material = new THREE.MeshLambertMaterial({
+    map: texture,
+    side: THREE.DoubleSide,
+    alphaTest: 0.1,
+    transparent: true,
+  });
+
+  const positionNumComponents = 3;
+  const normalNumComponents = 3;
+  const uvNumComponents = 2;
+  geometry.addAttribute(
+      'position',
+      new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
+  geometry.addAttribute(
+      'normal',
+      new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
+  geometry.addAttribute(
+      'uv',
+      new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
+  geometry.setIndex(indices);
+  const mesh = new THREE.Mesh(geometry, material);
+  scene.add(mesh);
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    const width = canvas.clientWidth;
+    const height = canvas.clientHeight;
+    const needResize = canvas.width !== width || canvas.height !== height;
+    if (needResize) {
+      renderer.setSize(width, height, false);
+    }
+    return needResize;
+  }
+
+  let renderRequested = false;
+
+  function render() {
+    renderRequested = undefined;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      const canvas = renderer.domElement;
+      camera.aspect = canvas.clientWidth / canvas.clientHeight;
+      camera.updateProjectionMatrix();
+    }
+
+    controls.update();
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+}
+
+main();
+</script>
+</html>
+

+ 276 - 0
threejs/threejs-voxel-geometry-culled-faces.html

@@ -0,0 +1,276 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - Voxel Geometry - Culled Faces</title>
+    <style>
+    body {
+        margin: 0;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+  </body>
+<script src="resources/threejs/r105/three.min.js"></script>
+<script src="resources/threejs/r105/js/controls/OrbitControls.js"></script>
+<script>
+'use strict';
+
+/* global THREE */
+
+class VoxelWorld {
+  constructor(cellSize) {
+    this.cellSize = cellSize;
+    this.cellSliceSize = cellSize * cellSize;
+    this.cell = new Uint8Array(cellSize * cellSize * cellSize);
+  }
+  computeVoxelOffset(x, y, z) {
+    const {cellSize, cellSliceSize} = this;
+    const voxelX = THREE.Math.euclideanModulo(x, cellSize) | 0;
+    const voxelY = THREE.Math.euclideanModulo(y, cellSize) | 0;
+    const voxelZ = THREE.Math.euclideanModulo(z, cellSize) | 0;
+    return voxelY * cellSliceSize +
+           voxelZ * cellSize +
+           voxelX;
+  }
+  getCellForVoxel(x, y, z) {
+    const {cellSize} = this;
+    const cellX = Math.floor(x / cellSize);
+    const cellY = Math.floor(y / cellSize);
+    const cellZ = Math.floor(z / cellSize);
+    if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
+      return null;
+    }
+    return this.cell;
+  }
+  setVoxel(x, y, z, v) {
+    const cell = this.getCellForVoxel(x, y, z);
+    if (!cell) {
+      return;  // TODO: add a new cell?
+    }
+    const voxelOffset = this.computeVoxelOffset(x, y, z);
+    cell[voxelOffset] = v;
+  }
+  getVoxel(x, y, z) {
+    const cell = this.getCellForVoxel(x, y, z);
+    if (!cell) {
+      return 0;
+    }
+    const voxelOffset = this.computeVoxelOffset(x, y, z);
+    return cell[voxelOffset];
+  }
+  generateGeometryDataForCell(cellX, cellY, cellZ) {
+    const {cellSize} = this;
+    const positions = [];
+    const normals = [];
+    const indices = [];
+    const startX = cellX * cellSize;
+    const startY = cellY * cellSize;
+    const startZ = cellZ * cellSize;
+
+    for (let y = 0; y < cellSize; ++y) {
+      const voxelY = startY + y;
+      for (let z = 0; z < cellSize; ++z) {
+        const voxelZ = startZ + z;
+        for (let x = 0; x < cellSize; ++x) {
+          const voxelX = startX + x;
+          const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
+          if (voxel) {
+            // There is a voxel here but do we need faces for it?
+            for (const {dir, corners} of VoxelWorld.faces) {
+              const neighbor = this.getVoxel(
+                  voxelX + dir[0],
+                  voxelY + dir[1],
+                  voxelZ + dir[2]);
+              if (!neighbor) {
+                // this voxel has no neighbor in this direction so we need a face.
+                const ndx = positions.length / 3;
+                for (const pos of corners) {
+                  positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
+                  normals.push(...dir);
+                }
+                indices.push(
+                  ndx, ndx + 1, ndx + 2,
+                  ndx + 2, ndx + 1, ndx + 3,
+                );
+              }
+            }
+          }
+        }
+      }
+    }
+
+    return {
+      positions,
+      normals,
+      indices,
+    };
+  }
+}
+
+VoxelWorld.faces = [
+  { // left
+    dir: [ -1,  0,  0, ],
+    corners: [
+      [ 0, 1, 0 ],
+      [ 0, 0, 0 ],
+      [ 0, 1, 1 ],
+      [ 0, 0, 1 ],
+    ],
+  },
+  { // right
+    dir: [  1,  0,  0, ],
+    corners: [
+      [ 1, 1, 1 ],
+      [ 1, 0, 1 ],
+      [ 1, 1, 0 ],
+      [ 1, 0, 0 ],
+    ],
+  },
+  { // bottom
+    dir: [  0, -1,  0, ],
+    corners: [
+      [ 1, 0, 1 ],
+      [ 0, 0, 1 ],
+      [ 1, 0, 0 ],
+      [ 0, 0, 0 ],
+    ],
+  },
+  { // top
+    dir: [  0,  1,  0, ],
+    corners: [
+      [ 0, 1, 1 ],
+      [ 1, 1, 1 ],
+      [ 0, 1, 0 ],
+      [ 1, 1, 0 ],
+    ],
+  },
+  { // back
+    dir: [  0,  0, -1, ],
+    corners: [
+      [ 1, 0, 0 ],
+      [ 0, 0, 0 ],
+      [ 1, 1, 0 ],
+      [ 0, 1, 0 ],
+    ],
+  },
+  { // front
+    dir: [  0,  0,  1, ],
+    corners: [
+      [ 0, 0, 1 ],
+      [ 1, 0, 1 ],
+      [ 0, 1, 1 ],
+      [ 1, 1, 1 ],
+    ],
+  },
+];
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas});
+
+  const cellSize = 32;
+
+  const fov = 75;
+  const aspect = 2;  // the canvas default
+  const near = 0.1;
+  const far = 1000;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.set(-cellSize * .3, cellSize * .8, -cellSize * .3);
+
+  const controls = new THREE.OrbitControls(camera, canvas);
+  controls.target.set(cellSize / 2, cellSize / 3, cellSize / 2);
+  controls.update();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color('lightblue');
+
+  function addLight(x, y, z) {
+    const color = 0xFFFFFF;
+    const intensity = 1;
+    const light = new THREE.DirectionalLight(color, intensity);
+    light.position.set(x, y, z);
+    scene.add(light);
+  }
+  addLight(-1,  2,  4);
+  addLight( 1, -1, -2);
+
+  const world = new VoxelWorld(cellSize);
+
+  for (let y = 0; y < cellSize; ++y) {
+    for (let z = 0; z < cellSize; ++z) {
+      for (let x = 0; x < cellSize; ++x) {
+        const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
+        if (y < height) {
+          world.setVoxel(x, y, z, 1);
+        }
+      }
+    }
+  }
+
+  const {positions, normals, indices} = world.generateGeometryDataForCell(0, 0, 0);
+  const geometry = new THREE.BufferGeometry();
+  const material = new THREE.MeshLambertMaterial({color: 'green'});
+
+  const positionNumComponents = 3;
+  const normalNumComponents = 3;
+  geometry.addAttribute(
+      'position',
+      new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
+  geometry.addAttribute(
+      'normal',
+      new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
+  geometry.setIndex(indices);
+  const mesh = new THREE.Mesh(geometry, material);
+  scene.add(mesh);
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    const width = canvas.clientWidth;
+    const height = canvas.clientHeight;
+    const needResize = canvas.width !== width || canvas.height !== height;
+    if (needResize) {
+      renderer.setSize(width, height, false);
+    }
+    return needResize;
+  }
+
+  let renderRequested = false;
+
+  function render() {
+    renderRequested = undefined;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      const canvas = renderer.domElement;
+      camera.aspect = canvas.clientWidth / canvas.clientHeight;
+      camera.updateProjectionMatrix();
+    }
+
+    controls.update();
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+}
+
+main();
+</script>
+</html>
+

+ 120 - 0
threejs/threejs-voxel-geometry-merged.html

@@ -0,0 +1,120 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - Voxel Geometry - Merged</title>
+    <style>
+    body {
+        margin: 0;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+  </body>
+<script src="resources/threejs/r105/three.min.js"></script>
+<script src="resources/threejs/r105/js/utils/BufferGeometryUtils.js"></script>
+<script src="resources/threejs/r105/js/controls/OrbitControls.js"></script>
+<script>
+'use strict';
+
+/* global THREE */
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas});
+
+  const cellSize = 256;
+
+  const fov = 75;
+  const aspect = 2;  // the canvas default
+  const near = 0.1;
+  const far = 1000;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.set(cellSize / 2, 60, cellSize);
+
+  const controls = new THREE.OrbitControls(camera, canvas);
+  controls.target.set(cellSize / 2, 60, cellSize / 2);
+  controls.update();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color('lightblue');
+
+  {
+    const color = 0xFFFFFF;
+    const intensity = 1;
+    const light = new THREE.DirectionalLight(color, intensity);
+    light.position.set(-1, 2, 4);
+    scene.add(light);
+  }
+
+  const geometries = [];
+  for (let y = 0; y < cellSize; ++y) {
+    for (let z = 0; z < cellSize; ++z) {
+      for (let x = 0; x < cellSize; ++x) {
+        const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + 60;
+        //if (y < height) {
+        if (height > y && height < y + 1) {
+          const geometry = new THREE.BoxBufferGeometry(1, 1, 1);
+          geometry.applyMatrix((new THREE.Matrix4()).makeTranslation(x, y, z));
+          geometries.push(geometry);
+        }
+      }
+    }
+  }
+
+  const mergedGeometry = THREE.BufferGeometryUtils.mergeBufferGeometries(
+        geometries, false);
+  const material = new THREE.MeshPhongMaterial({color: 'green'});
+  const mesh = new THREE.Mesh(mergedGeometry, material);
+  scene.add(mesh);
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    const width = canvas.clientWidth;
+    const height = canvas.clientHeight;
+    const needResize = canvas.width !== width || canvas.height !== height;
+    if (needResize) {
+      renderer.setSize(width, height, false);
+    }
+    return needResize;
+  }
+
+  let renderRequested = false;
+
+  function render() {
+    renderRequested = undefined;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      const canvas = renderer.domElement;
+      camera.aspect = canvas.clientWidth / canvas.clientHeight;
+      camera.updateProjectionMatrix();
+    }
+
+    controls.update();
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+}
+
+main();
+</script>
+</html>
+

+ 132 - 0
threejs/threejs-voxel-geometry-separate-cubes.html

@@ -0,0 +1,132 @@
+<!-- Licensed under a BSD license. See license.html for license -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+    <title>Three.js - Voxel Geometry - Separate Cubes</title>
+    <style>
+    body {
+        margin: 0;
+    }
+    #c {
+        width: 100vw;
+        height: 100vh;
+        display: block;
+    }
+    </style>
+  </head>
+  <body>
+    <canvas id="c"></canvas>
+  </body>
+<script src="resources/threejs/r105/three.min.js"></script>
+<script src="resources/threejs/r105/js/controls/OrbitControls.js"></script>
+<script>
+'use strict';
+
+/* global THREE */
+
+function main() {
+  const canvas = document.querySelector('#c');
+  const renderer = new THREE.WebGLRenderer({canvas});
+
+  const cellSize = 256;
+
+  const fov = 75;
+  const aspect = 2;  // the canvas default
+  const near = 0.1;
+  const far = 1000;
+  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
+  camera.position.set(-cellSize * .3, cellSize * .8, -cellSize * .3);
+
+  const controls = new THREE.OrbitControls(camera, canvas);
+  controls.target.set(cellSize / 2, cellSize / 3, cellSize / 2);
+  controls.update();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color('lightblue');
+
+  {
+    const color = 0xFFFFFF;
+    const intensity = 1;
+    const light = new THREE.DirectionalLight(color, intensity);
+    light.position.set(-1, 2, 4);
+    scene.add(light);
+  }
+
+  const cell = new Uint8Array(cellSize * cellSize * cellSize);
+  for (let y = 0; y < cellSize; ++y) {
+    for (let z = 0; z < cellSize; ++z) {
+      for (let x = 0; x < cellSize; ++x) {
+        const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + cellSize / 2;
+        if (height > y && height < y + 1) {
+          const offset = y * cellSize * cellSize +
+                       z * cellSize +
+                       x;
+          cell[offset] = 1;
+        }
+      }
+    }
+  }
+
+  const geometry = new THREE.BoxBufferGeometry(1, 1, 1);
+  const material = new THREE.MeshPhongMaterial({color: 'green'});
+
+  for (let y = 0; y < cellSize; ++y) {
+    for (let z = 0; z < cellSize; ++z) {
+      for (let x = 0; x < cellSize; ++x) {
+        const offset = y * cellSize * cellSize +
+                       z * cellSize +
+                       x;
+        const block = cell[offset];
+        if (block) {
+          const mesh = new THREE.Mesh(geometry, material);
+          mesh.position.set(x, y, z);
+          scene.add(mesh);
+        }
+      }
+    }
+  }
+
+  function resizeRendererToDisplaySize(renderer) {
+    const canvas = renderer.domElement;
+    const width = canvas.clientWidth;
+    const height = canvas.clientHeight;
+    const needResize = canvas.width !== width || canvas.height !== height;
+    if (needResize) {
+      renderer.setSize(width, height, false);
+    }
+    return needResize;
+  }
+
+  let renderRequested = false;
+
+  function render() {
+    renderRequested = undefined;
+
+    if (resizeRendererToDisplaySize(renderer)) {
+      const canvas = renderer.domElement;
+      camera.aspect = canvas.clientWidth / canvas.clientHeight;
+      camera.updateProjectionMatrix();
+    }
+
+    controls.update();
+    renderer.render(scene, camera);
+  }
+  render();
+
+  function requestRenderIfNotRequested() {
+    if (!renderRequested) {
+      renderRequested = true;
+      requestAnimationFrame(render);
+    }
+  }
+
+  controls.addEventListener('change', requestRenderIfNotRequested);
+  window.addEventListener('resize', requestRenderIfNotRequested);
+}
+
+main();
+</script>
+</html>
+