Source code for voxel_handler

"""
Block interaction handler for raycasting, placing, and breaking voxels.

This module implements the mathematical DDA (Digital Differential Analyzer)
raycasting algorithm to cleanly trace the player's line of sight through
the 3D chunk grid. It triggers multithreaded lighting and meshing updates
whenever the player adds or destroys blocks in the world.
"""

from typing import Any, Tuple

from pyglm import glm

from lighting import place_torch, update_light_place_block, update_light_remove_block
from profiler import global_profiler
from settings import (
    CHUNK_AREA,
    CHUNK_SIZE,
    GLOWSTONE,
    MAX_RAY_DISTANCE,
    NON_PLACEABLE,
    STONE,
    SURVIVAL,
    WATER,
    WOODEN_PICKAXE,
)


[docs] class VoxelHandler: """ Performs raycasting from the player's camera to interact with the voxel world. Handles calculating the targeted block, removing blocks (mining), and adding blocks (placing), while triggering necessary lighting and meshing updates. Args: world (Any): The main world context containing the chunk array. """ @global_profiler.profile_func('VoxelHandler_Init') def __init__(self, world: Any) -> None: """ Initialize the `VoxelHandler` for a world instance. Args: world: The world object that owns this handler (provides app, chunks, etc.). """ # World references self.app: Any = world.app self.chunks: Any = world.chunks # Ray casting result state self.chunk: Any = None self.voxel_id: int = 0 self.voxel_index: int = 0 self.voxel_local_pos: Any = None self.voxel_world_pos: Any = None self.voxel_normal: Any = None # Interaction settings self.interaction_mode: int = 0
[docs] @global_profiler.profile_func('VoxelHandler_AddVoxel') def add_voxel(self) -> None: """ Attempts to place the currently held block into the world at the targeted face. Checks for player collision, updates lighting (e.g. for Glowstone), and queues adjacent chunks for remeshing. """ if self.voxel_id: # Check selected item current_id: int = self.app.player.inventory[self.app.player.hotbar_index] if current_id == 0 or current_id in NON_PLACEABLE: return # Check placement target validity new_voxel_pos: Any = self.voxel_world_pos + self.voxel_normal result: Tuple[int, int, Any, Any] = self.get_voxel_id(new_voxel_pos) if not result[0]: # Verify player intersection bounds player_min, player_max = self.app.player.get_aabb() voxel_min: Any = glm.vec3(new_voxel_pos) voxel_max: Any = voxel_min + 1.0 if self.app.player.aabb_intersect(player_min, player_max, voxel_min, voxel_max): return # Apply block to chunk voxel_index: int = result[1] chunk: Any = result[3] chunk.voxels[voxel_index] = current_id world_x: float = float(new_voxel_pos.x) world_y: float = float(new_voxel_pos.y) world_z: float = float(new_voxel_pos.z) # Dispatch async lighting and mesh updates def async_add_voxel( world_x: float = world_x, world_y: float = world_y, world_z: float = world_z, cid: int = current_id, ch: Any = chunk, ) -> None: update_light_place_block( int(world_x), int(world_y), int(world_z), self.app.scene.world.voxels, self.app.scene.world.lightmaps, self.app.scene.world.chunk_positions, ) if cid == GLOWSTONE: place_torch( int(world_x), int(world_y), int(world_z), self.app.scene.world.voxels, self.app.scene.world.lightmaps, self.app.scene.world.chunk_positions, ) if ch not in self.app.scene.world.build_queue: self.app.scene.world.build_queue.append(ch) self.rebuild_adjacent_chunks(glm.vec3(world_x, world_y, world_z), is_light_update=True) self.app.scene.world.executor.submit(async_add_voxel) # Process interaction effects self.app.sounds.play_place(current_id) if self.app.player.game_mode == SURVIVAL: self.app.player.inventory_counts[self.app.player.hotbar_index] -= 1 if self.app.player.inventory_counts[self.app.player.hotbar_index] <= 0: self.app.player.inventory[self.app.player.hotbar_index] = 0 if chunk.is_empty: chunk.is_empty = False
[docs] @global_profiler.profile_func('VoxelHandler_RebuildAdjacentChunks') def rebuild_adjacent_chunks(self, world_pos: Any, is_light_update: bool = True) -> None: """ Automatically queues neighboring chunks for mesh regeneration if a block modification occurs near a chunk border, or if it creates a large lighting update requiring neighbors to recalculate their block/sunlight visuals. """ # Calculate update radii and bounds world_x: int = int(world_pos.x) world_y: int = int(world_pos.y) world_z: int = int(world_pos.z) chunk_x: int = world_x // CHUNK_SIZE chunk_y: int = world_y // CHUNK_SIZE chunk_z: int = world_z // CHUNK_SIZE radius: int = 15 if is_light_update else 1 min_cx: int = (world_x - radius) // CHUNK_SIZE max_cx: int = (world_x + radius) // CHUNK_SIZE min_cz: int = (world_z - radius) // CHUNK_SIZE max_cz: int = (world_z + radius) // CHUNK_SIZE min_cy: int = 0 if is_light_update else (world_y - radius) // CHUNK_SIZE max_cy: int = (world_y + radius) // CHUNK_SIZE # Queue chunks in range for x in range(min_cx, max_cx + 1): for y in range(min_cy, max_cy + 1): for z in range(min_cz, max_cz + 1): if x == chunk_x and y == chunk_y and z == chunk_z: continue chunk_pos: Tuple[int, int, int] = (x, y, z) if chunk_pos in self.app.scene.world.active_chunks: chunk: Any = self.app.scene.world.active_chunks[chunk_pos] if chunk not in self.app.scene.world.build_queue: self.app.scene.world.build_queue.append(chunk)
[docs] @global_profiler.profile_func('VoxelHandler_RemoveVoxel') def remove_voxel(self) -> None: """ Breaks the targeted voxel, updates local block lighting (stripping or letting sunlight in), spawns a dropped item entity in Survival mode, and queues chunks for remeshing. """ if self.voxel_id: # Clear targeted block world_x: float = float(self.voxel_world_pos.x) world_y: float = float(self.voxel_world_pos.y) world_z: float = float(self.voxel_world_pos.z) self.chunk.voxels[self.voxel_index] = 0 # Dispatch async lighting and mesh updates def async_remove_voxel( world_x: float = world_x, world_y: float = world_y, world_z: float = world_z, vid: int = self.voxel_id, ch: Any = self.chunk, ) -> None: if vid == GLOWSTONE: update_light_place_block( int(world_x), int(world_y), int(world_z), self.app.scene.world.voxels, self.app.scene.world.lightmaps, self.app.scene.world.chunk_positions, ) update_light_remove_block( int(world_x), int(world_y), int(world_z), self.app.scene.world.voxels, self.app.scene.world.lightmaps, self.app.scene.world.chunk_positions, ) if ch not in self.app.scene.world.build_queue: self.app.scene.world.build_queue.append(ch) self.rebuild_adjacent_chunks(glm.vec3(world_x, world_y, world_z), is_light_update=True) self.app.scene.world.executor.submit(async_remove_voxel) # Process interaction effects self.app.sounds.play_break(self.voxel_id) if self.app.player.game_mode == SURVIVAL: held_id: int = self.app.player.inventory[self.app.player.hotbar_index] if self.voxel_id == STONE and held_id != WOODEN_PICKAXE: pass else: self.app.scene.item_manager.add_item(self.voxel_world_pos, self.voxel_id)
[docs] @global_profiler.profile_func('VoxelHandler_SetVoxel') def set_voxel(self, mode: str = 'remove') -> None: """ Wrapper to call either add_voxel or remove_voxel based on the mode. """ # Execute voxel action if mode == 'add': self.add_voxel() elif mode == 'remove': self.remove_voxel()
[docs] @global_profiler.profile_func('VoxelHandler_Update') def update(self) -> None: """ Update per-frame voxel interaction state (casts the interaction ray). This should be called from the main update loop to refresh the targeted voxel based on the player's view. """ # Execute frame interaction self.ray_cast()
# Fast Voxel Traversal Algorithm (3D DDA) # How does the game know exactly which block you are looking at? # It uses a technique called 3D Digital Differential Analyzer (DDA), specifically # the "Amanatides & Woo" algorithm for fast voxel traversal. # # How it works: # Instead of "stepping" forward by tiny amounts and checking if we hit a block # (which is slow and can "skip" through thin corners), this algorithm calculates # exactly how far the ray must travel to hit the next X, Y, or Z grid boundary # (`max_x`, `max_y`, `max_z`). # # We step into the grid along whichever axis is closest, update the boundary # distance (`delta_x`, `delta_y`, `delta_z`), and repeat until we hit a solid # block. It guarantees we never miss a block and runs incredibly fast since # it only evaluates grid intersections! # # References: # - 3D DDA Visualized: https://www.youtube.com/watch?v=NbSee-cg7Ig # - Original Amanatides & Woo Paper: http://www.cse.yorku.ca/~amana/research/grid.pdf # - LodeV's Raycasting Tutorial: https://lodev.org/cgtutor/raycasting.html
[docs] @global_profiler.profile_func('VoxelHandler_RayCast') def ray_cast(self) -> bool: """ Casts a ray forward from the camera's position through the voxel grid using a fast voxel traversal algorithm. Determines the exact targeted voxel and its normal face. """ # Define ray boundaries x1, y1, z1 = self.app.player.position x2, y2, z2 = self.app.player.position + self.app.player.forward * MAX_RAY_DISTANCE current_voxel_pos: Any = glm.ivec3(x1, y1, z1) self.voxel_id = 0 self.voxel_normal = glm.ivec3(0) step_dir: int = -1 # Initialize DDA variables dx: float = float(glm.sign(x2 - x1)) delta_x: float = min(dx / (x2 - x1), 10000000.0) if dx != 0 else 10000000.0 max_x: float = delta_x * (1.0 - glm.fract(x1)) if dx > 0 else delta_x * glm.fract(x1) dy: float = float(glm.sign(y2 - y1)) delta_y: float = min(dy / (y2 - y1), 10000000.0) if dy != 0 else 10000000.0 max_y: float = delta_y * (1.0 - glm.fract(y1)) if dy > 0 else delta_y * glm.fract(y1) dz: float = float(glm.sign(z2 - z1)) delta_z: float = min(dz / (z2 - z1), 10000000.0) if dz != 0 else 10000000.0 max_z: float = delta_z * (1.0 - glm.fract(z1)) if dz > 0 else delta_z * glm.fract(z1) # Traverse grid while not (max_x > 1.0 and max_y > 1.0 and max_z > 1.0): result: Tuple[int, int, Any, Any] = self.get_voxel_id(voxel_world_pos=current_voxel_pos) # Check for solid block intersection if result[0] and result[0] != WATER: self.voxel_id = result[0] self.voxel_index = result[1] self.voxel_local_pos = result[2] self.chunk = result[3] self.voxel_world_pos = current_voxel_pos if step_dir == 0: self.voxel_normal.x = -dx elif step_dir == 1: self.voxel_normal.y = -dy else: self.voxel_normal.z = -dz return True # Step along shortest distance if max_x < max_y: if max_x < max_z: current_voxel_pos.x += int(dx) max_x += delta_x step_dir = 0 else: current_voxel_pos.z += int(dz) max_z += delta_z step_dir = 2 else: if max_y < max_z: current_voxel_pos.y += int(dy) max_y += delta_y step_dir = 1 else: current_voxel_pos.z += int(dz) max_z += delta_z step_dir = 2 return False
[docs] @global_profiler.profile_func('VoxelHandler_GetVoxelId') def get_voxel_id(self, voxel_world_pos: Any) -> Tuple[int, int, Any, Any]: """ Resolve a world-space voxel position to its chunk-local index and id. Args: voxel_world_pos: 3D position (vec-like) in world coordinates. Returns: A tuple of (voxel_id, voxel_index, voxel_local_pos, chunk) where `voxel_id` is 0 for empty space, `voxel_index` is the linear index inside the chunk voxel array, `voxel_local_pos` is the local 3D integer coordinate within the chunk, and `chunk` is the chunk object containing the voxel (or None if out of loaded range). """ # Determine target chunk chunk_x: int = int(glm.floor(voxel_world_pos.x / CHUNK_SIZE)) chunk_y: int = int(glm.floor(voxel_world_pos.y / CHUNK_SIZE)) chunk_z: int = int(glm.floor(voxel_world_pos.z / CHUNK_SIZE)) chunk_pos: Tuple[int, int, int] = (chunk_x, chunk_y, chunk_z) # Fetch local voxel data if chunk_pos in self.app.scene.world.active_chunks: chunk: Any = self.app.scene.world.active_chunks[chunk_pos] if chunk.voxels is None: return 0, 0, None, None lx, ly, lz = voxel_local_pos = ( glm.ivec3(voxel_world_pos) - glm.ivec3(chunk_x, chunk_y, chunk_z) * CHUNK_SIZE ) voxel_index: int = lx + CHUNK_SIZE * lz + CHUNK_AREA * ly voxel_id: int = chunk.voxels[voxel_index] return voxel_id, voxel_index, voxel_local_pos, chunk return 0, 0, None, None