I'm building a very original game based in cubes you place in a sandbox world (a totally unique concept that will revolutionize gaming as we know it) and I'm working with the chunk generation. Here's what I have so far:
My blocks are defined in an object literal:
import * as THREE from 'three';
const loader = new THREE.TextureLoader();
interface BlockAttrs
{
breakable: boolean;
empty: boolean;
}
export interface Block
{
attrs: BlockAttrs;
mat_bottom?: THREE.MeshBasicMaterial;
mat_side?: THREE.MeshBasicMaterial;
mat_top?: THREE.MeshBasicMaterial;
}
interface BlockList
{
[key: string]: Block;
}
export const Blocks: BlockList = {
air:
{
attrs:
{
breakable: false,
empty: true,
},
},
grass:
{
attrs:
{
breakable: true,
empty: false,
},
mat_bottom: new THREE.MeshBasicMaterial({map: loader.load("/tex/dirt.png")}),
mat_side: new THREE.MeshBasicMaterial({map: loader.load("/tex/grass-side.png")}),
mat_top: new THREE.MeshBasicMaterial({map: loader.load("/tex/grass-top.png")}),
},
};
Here is my Chunk
class:
import * as THREE from 'three';
import { BufferGeometryUtils } from 'three/examples/jsm/utils/BufferGeometryUtils';
import { Block, Blocks } from './blocks';
const px = 0; const nx = 1; const py = 2;
const ny = 3; const pz = 4; const nz = 5;
export default class Chunk
{
private static readonly faces = [
new THREE.PlaneGeometry(1,1)
.rotateY(Math.PI / 2)
.translate(0.5, 0, 0),
new THREE.PlaneGeometry(1,1)
.rotateY(-Math.PI / 2)
.translate(-0.5, 0, 0),
new THREE.PlaneGeometry(1,1)
.rotateX(-Math.PI / 2)
.translate(0, 0.5, 0),
new THREE.PlaneGeometry(1,1)
.rotateX(Math.PI / 2)
.translate(0, -0.5, 0),
new THREE.PlaneGeometry(1,1)
.translate(0, 0, 0.5),
new THREE.PlaneGeometry(1,1)
.rotateY(Math.PI)
.translate(0, 0, -0.5)
];
private structure: Array<Array<Array<Block>>>;
public static readonly size = 16;
private materials = Array<THREE.MeshBasicMaterial>();
private terrain = Array<THREE.BufferGeometry>();
constructor ()
{
this.structure = new Array<Array<Array<Block>>>(Chunk.size);
for (let x = 0; x < Chunk.size; x++)
{
this.structure[x] = new Array<Array<Block>>(Chunk.size);
for (let y = 0; y < Chunk.size; y++)
{
this.structure[x][y] = new Array<Block>(Chunk.size);
for (let z = 0; z < Chunk.size; z++)
if ((x+y+z) % 2)
this.structure[x][y][z] = Blocks.grass;
else
this.structure[x][y][z] = Blocks.air;
}
}
}
private blockEmpty (x: number, y: number, z: number): boolean
{
let empty = true;
if (
x >= 0 && x < Chunk.size &&
y >= 0 && y < Chunk.size &&
z >= 0 && z < Chunk.size
) {
empty = this.structure[x][y][z].attrs.empty;
}
return empty;
}
private generateBlockFaces (x: number, y: number, z: number): void
{
if (this.blockEmpty(x+1, y, z))
{
this.terrain.push(Chunk.faces[px].clone().translate(x, y, z));
this.materials.push(this.structure[x][y][z].mat_side);
}
if (this.blockEmpty(x, y, z+1))
{
this.terrain.push(Chunk.faces[nx].clone().translate(x, y, z));
this.materials.push(this.structure[x][y][z].mat_side);
}
if (this.blockEmpty(x, y-1, z))
{
this.terrain.push(Chunk.faces[py].clone().translate(x, y, z));
this.materials.push(this.structure[x][y][z].mat_bottom);
}
if (this.blockEmpty(x, y+1, z))
{
this.terrain.push(Chunk.faces[ny].clone().translate(x, y, z));
this.materials.push(this.structure[x][y][z].mat_top);
}
if (this.blockEmpty(x, y, z-1))
{
this.terrain.push(Chunk.faces[pz].clone().translate(x, y, z));
this.materials.push(this.structure[x][y][z].mat_side);
}
if (this.blockEmpty(x-1, y, z))
{
this.terrain.push(Chunk.faces[nz].clone().translate(x, y, z));
this.materials.push(this.structure[x][y][z].mat_side);
}
}
public generateTerrain (): THREE.Mesh
{
this.terrain = new Array<THREE.BufferGeometry>();
this.materials = new Array<THREE.MeshBasicMaterial>();
for (let x = 0; x < Chunk.size; x++)
for (let y = 0; y < Chunk.size; y++)
for (let z = 0; z < Chunk.size; z++)
if (!this.structure[x][y][z].attrs.empty)
this.generateBlockFaces(x, y, z);
return new THREE.Mesh(
BufferGeometryUtils.mergeBufferGeometries(this.terrain),
this.materials
);
}
}
I know the mesh creator should be decoupled from the model, but right now I'm experimenting. The class works like this:
First, constructor()
creates a 3D matrix of Block
. I've set it to create it in a chess board pattern of air
and grass
, so every other block is empty.
Next, I call generateTerrain()
from my Scene:
this.chunk = new Chunk();
this.add(this.chunk.generateTerrain());
When this method is called, it enters generateBlockFaces
for every non-empty block and pushes the appropiate PlaneGeometry
s into the terrain
array as well as the appropriate THREE.MeshBasicMaterial
into the materials
array. I then merge the geometries using BufferGeometryUtils.mergeBufferGeometries
and create the mesh passing the merged geometry and the materials
array.
The problem I have is that creating the mesh works perfectly well when passing new THREE.MeshNormalMaterial
, or any other material for that matter, but not when I pass the materials
array. Passing the array creates the object (and console.log
ing it shows that it was created without errors), but it isn't drawn with the scene.
Am I mistaken in believing that the materials
array will assign a material to each of the faces? What am I doing wrong?
I solved it after finding a reference to THREE.UVMapping in the docs. When sending the geometry to the GPU, textures coordinates need to be a biyection from the vertices coordinates. To achieve this, I defined the following three attributes in my blocks:
uv_bottom: [
stone_row / Textures.rows, (stone_col+1) / Textures.cols,
(stone_row+1) / Textures.rows, (stone_col+1) / Textures.cols,
stone_row / Textures.rows, stone_col / Textures.cols,
(stone_row+1) / Textures.rows, stone_col / Textures.cols,
],
uv_side: [
stone_row / Textures.rows, (stone_col+1) / Textures.cols,
(stone_row+1) / Textures.rows, (stone_col+1) / Textures.cols,
stone_row / Textures.rows, stone_col / Textures.cols,
(stone_row+1) / Textures.rows, stone_col / Textures.cols,
],
uv_top: [
stone_row / Textures.rows, (stone_col+1) / Textures.cols,
(stone_row+1) / Textures.rows, (stone_col+1) / Textures.cols,
stone_row / Textures.rows, stone_col / Textures.cols,
(stone_row+1) / Textures.rows, stone_col / Textures.cols,
],
Textures.rows
and Textures.cols
references the number of colums and rows my textures atlas (the file where all the textures are stored in a png grid) has and every block has their own row
and col
file that references its position. Then, I created a private uv = Array<Array<number>>();
in my Chunk
class and modified the terrain generator to push the blocks' uv arrays to it. For example, this is how it is done for positive z faces (note that I have swapped y
and z
for efficiency purposes):
if (this.blockEmpty(x, z+1, y))
{
this.terrain.push(Chunk.faces[pz].clone().translate(x, y, z));
this.uv.push(this.structure[x][z][y].uv_side);
}
Now, BufferGeometry
only accepts 'uv'
arrays as typed (Float32Array
in this case), so I had to construct one from a flattened version of this.uv
. This is how the terrain generator function looks like now:
public generateTerrain (): THREE.Mesh
{
this.terrain = new Array<THREE.BufferGeometry>();
for (let x = 0; x < Chunk.base; x++)
for (let z = 0; z < Chunk.base; z++)
for (let y = 0; y < Chunk.build_height; y++)
if (!this.structure[x][z][y].attrs.empty)
this.generateBlockFaces(x, z, y);
const geometry = BufferGeometryUtils.mergeBufferGeometries(this.terrain);
geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(this.uv.flat()), 2));
return new THREE.Mesh(geometry, Textures.material);
}
As you can see, the material I'm using comes from the Textures
class. Here's the whole imported file:
import * as THREE from 'three';
export default class Textures
{
private static readonly loader = new THREE.TextureLoader();
public static readonly rows = 3;
public static readonly cols = 2;
public static readonly atlas = Textures.loader.load("/tex/atlas.png");
public static readonly material = new THREE.MeshBasicMaterial({map: Textures.atlas});
}
Textures.atlas.magFilter = THREE.NearestFilter;
Textures.atlas.minFilter = THREE.NearestFilter;
And that's it! The terrain now generates rendering every single block's texture and I can't be happier about it :D