Voxel Engine

Block Placement and Breaking (DDA Raycasting)

Block Interaction

One of the first interaction problems in a voxel world is deceptively simple: which block is the player looking at?

The camera gives a continuous ray through 3D space, while the world is made up of discrete voxels. Digital Differential Analysis (DDA) bridges the two by tracing the ray through the voxel grid and determining which blocks it passes through.

In the voxel engine, this became the foundation for:

  • selecting the block the player is pointing at,
  • breaking a block, and
  • placing a new block against the selected one.

For a visual explanation of the algorithm, this video gives a good demonstration of the underlying idea.

Tracing a Ray Through the Voxel Grid

Ray Casting

The ray starts at the camera and travels in its viewing direction. Rather than advancing by a fixed distance, DDA keeps track of which voxel boundary the ray will encounter next and advances to that voxel.

This matters because the world is discrete. The useful result is not the exact point where the ray happens to be in space, but the sequence of voxels that the ray passes through.

The traversal continues until either:

  • a solid voxel is reached, or
  • the ray travels beyond its maximum interaction distance.

Once a voxel is reached, the continuous world-space position needs to be mapped back into the chunked representation used by the engine.

Coordinate Conversion between World, Chunk, and Voxel Space

My engine uses 3 levels of coordinates:

  1. World space: the absolute world space.
  2. Chunk space: identifies which chunk contains the voxel.
  3. Local voxel space: identifies the voxel inside that chunk.

The chunk coordinate is obtained by dividing the world position by the chunk size:

ivec3 world_to_chunk_coord(vec3 world_coord) {
    return {
        floor(world_coord.x / chunk_size),
        floor(world_coord.y / chunk_size),
        floor(world_coord.z / chunk_size)
    };
}

The local coordinate is then the voxel's position relative to the chunk:

ivec3 world_to_local_coord(vec3 world_coord) {
    ivec3 chunk_origin = world_to_chunk_coord(world_coord);

    ivec3 local_coord = {
        floor(world_coord.x - chunk_origin.x * chunk_size) + 1,
        floor(world_coord.y - chunk_origin.y * chunk_size) + 1,
        floor(world_coord.z - chunk_origin.z * chunk_size) + 1
    };

    return local_coord;
}

The use of floor() is important around negative coordinates. A world position such as (-1.1, 0.5, 2.7) belongs to the voxel at (-2, 0, 2), rather than simply truncating toward zero. Keeping this consistent is necessary for block interaction to behave correctly on both sides of the world origin.

The additional +1 comes from the chunk representation described earlier. Each chunk has an extra boundary layer used to store neighboring chunk data for mesh generation. Adding 1 ensures the raycast to access a chunk's actual voxels rather than treating its boundary data as part of the chunk itself.

With the local coordinate resolved, the targeted voxel becomes a normal block-array lookup:

chunk.blocks_array[local_coord.x][local_coord.y][local_coord.z];

This is where the continuous ray ultimately meets the discrete voxel world: a point in world space becomes a specific block in a specific chunk.

Block Interaction

Once the targeted voxel is known, block interaction is merely changing the block type or block texture.

Breaking replaces the targeted block with Air type.

Placing sets a block's type from 'Air' to other visible block type, updating texture.

On every block type change, a chunk must be rebuild its mesh. If the modified block lies on a chunk boundary, the neighboring chunk may need to be updated and rebuilt as well because its visible faces depend on that block.

Different block types also trigger different sound effects, making the interaction feel connected to the material being modified.

DDA Raycasting in 3D Space Implementation

void Camera::raycast() {
	vec3 origin = position; //camera position
	vec3 dir = direction; //camera direction
	vec3 delta = { //unit step size in x, z, and y axis
		abs(1.0f / dir.x),
		abs(1.0f / dir.y),
		abs(1.0f / dir.z),
	};
	vec3 ray_length, step;
	vec3 current = floor(origin);
	if (dir.x < 0) {
		step.x = -1;
		ray_length.x = (origin.x - current.x) * delta.x; 
	}
	else {
		step.x = 1;
		ray_length.x = (current.x + 1 - origin.x) * delta.x;
	}
	if (dir.y < 0) {
		step.y = -1;
		ray_length.y = (origin.y - current.y) * delta.y;
	}
	else {
		step.y = 1;
		ray_length.y = (current.y + 1 - origin.y) * delta.y;
	}
	if (dir.z < 0) {
		step.z = -1;
		ray_length.z = (origin.z - current.z) * delta.z;
	}
	else {
		step.z = 1;
		ray_length.z = (current.z + 1 - origin.z) * delta.z;
	}

	float dist = 0.0f;
	while (dist < max_ray_length) {
		Block* block = cm.get_block_worldspace(current);
		if (block != nullptr) {
			hovered_block = block;
			if (block->type != none) {
				return;
			}
		}

		//increment in the direction where ray_length is shorter
		if (ray_length.x < ray_length.y) {
			if (ray_length.x < ray_length.z) {
				//horizontal step in x-axis
				current.x += step.x;
				dist = ray_length.x;
				ray_length.x += delta.x;
			}
			else {
				//horizontal step in z-axis
				current.z += step.z;
				dist = ray_length.z;
				ray_length.z += delta.z;
			}
		}
		else {
			if (ray_length.z < ray_length.y) {
				//horizontal step in z-axis
				current.z += step.z;
				dist = ray_length.z;
				ray_length.z += delta.z;
			}
			else {
				//vertical step in y-axis
				current.y += step.y;
				dist = ray_length.y;
				ray_length.y += delta.y;
			}
		}
	}
}
NextCollision Detection between Player and Block (Swept AABB)