As noted in the theory section, a voxel can be anything; the only limit is your imagination... and the amount of memory and disk-space you have! Speaking of which, how are voxels represented in practice?

From this point on, we assume that you (the reader) know the basics of programming; in concrete terms that would be what bits and bytes are, primitive types like integers, the relation between stack and heap memory and, last of all, pointers/references.

Also, for the purpose of clarity, we will not be using pseudocode.

The following sections are a work-in-progress.

Basic Storage

For a start, let's assume that our voxels store... nothing.

/// This represents a single voxel sample/instance.
type Voxel = (); // using the empty 'unit type' for now.

Since a voxel outside a grid is, by definition, not a voxel, we will have to put it into a grid of voxels...

/// A finite grid of voxels.
pub struct VoxelGrid {
  // ???
}

...but how exactly do we do that?

At first, you might try to use a 3D array; let's say of size 16³:

/// The size of our grid along any axis.
pub const GRID_SIZE: usize = 16;

/// A finite grid of `GRID_SIZE³` voxels.
pub struct VoxelGrid {
  values: [[[Voxel; GRID_SIZE]; GRID_SIZE]; GRID_SIZE];
  // Well ain't that nice to look at, eh?
}

Now accessing it is pretty simple:

// Create the volume, filled with 'nothing'...
let mut volume = VoxelGrid {
  values: [[[Voxel; GRID_SIZE]; GRID_SIZE]; GRID_SIZE]
};

// Have some coordinates...
let (x,y,z) = (0, 1, 2);

// Get a voxel:
let v = volume.values[x][y][z];

// Set a voxel:
*volume.values[x][y][z] = v;

But what happens if x, y or z go outside the volume? We might get an error and crash!

Let's prevent that by defining accessor functions and then only use these:

impl VoxelGrid {
  pub fn get(&self, x: u32, y: u32, z: u32) -> Option<Voxel> {
    self.values.get(x)?.get(y)?.get(z)
  }
  
  pub fn set(&self, x: u32, y: u32, z: u32, v: Voxel) -> Option<()> {
    *self.values.get_mut(x)?.get_mut(y)?.get_mut(z) = v;
  }
}

Alas, this shows us one of three annoyances with using 3D arrays:

  • Accessing elements always requires us to 'jump' trough two levels of indirection.
  • Iterating/looping over our voxels requires three nested loops, which is a pain to write.
  • Creating and filling a 3D array is, unsurprisingly, quite messy.

As such, we will now go ahead and make our array flat, turning it one-dimensional!

pub struct VoxelGrid {
  values: [Voxel; GRID_SIZE * GRID_SIZE * GRID_SIZE];
}

Of course, we will now have to do the bound-checks by ourselves, but as long as we use the correct equality operators, there really is nothing to it!

impl VoxelGrid {
  pub fn get(&self, x: u32, y: u32, z: u32) -> Option<Voxel> {
    if x < 0 || x >= GRID_SIZE {return None}
    if y < 0 || y >= GRID_SIZE {return None}
    if z < 0 || z >= GRID_SIZE {return None}
    self.values[ /* ??? */] // uuuuh...?
  }
}

I suppose a function that turns x,y,z into an index is also needed: an index function! Since it depends on the bounds-check to work correctly, let's move that there too.

impl VoxelGrid {
  pub fn index(&self, x: u32, y: u32, z: u32) -> Option<usize> {
    if x < 0 || x >= GRID_SIZE {return None} // 0 ⋯ GRID_SIZE-1
    if y < 0 || y >= GRID_SIZE {return None} // 0 ⋯ GRID_SIZE-1
    if z < 0 || z >= GRID_SIZE {return None} // 0 ⋯ GRID_SIZE-1
    Some(x + y*GRID_SIZE + z*GRID_SIZE*GRID_SIZE) // SCHEME
  }
  
  pub fn get(&self, x: u32, y: u32, z: u32) -> Option<Voxel> {
    self.values[ self.index(x,y,z)? ] // yay!
  }
  
  pub fn set(&self, x: u32, y: u32, z: u32, v: Voxel) -> Option<()> {
    *self.values[ self.index(x,y,z)? ] = v;
  }
}

The line marked with SCHEME declares a spatial indexing scheme for us, which defines the order and importance of the x,y,z axes, but also how to turn coordinates into a usable index. Neat!

And so our example becomes this:

// Create the volume... somehow.
let mut volume = VoxelGrid { /* ??? */ };

// Have some coordinates...
let (x,y,z) = (/**/, /**/, /**/);

// Get a voxel:
let v = volume.get(x, y, z).unwrap();

// Set a voxel:
volume.set(x, y, z, v).unwrap();

Handling errors is outside the scope of this guide, so do note that the unwraps in the example will, if the coordinates are ever out of bounds, crash our program; but at least you'll know where!

But how to we fill it? And just what type should Voxel be?!

Types of Voxel

TODO: Types of voxels.

Basic Generation

TODO: Filling a volume via Procedural Generation.