From 1b4f07001584baf097154da25f2e03e09740c352 Mon Sep 17 00:00:00 2001 From: Elias Stepanik <40958815+eliasstepanik@users.noreply.github.com> Date: Sat, 14 Jun 2025 00:34:37 +0200 Subject: [PATCH] Add basic voxel texture atlas support --- .../plugins/environment/environment_plugin.rs | 7 ++ .../environment/systems/voxel_system.rs | 7 +- .../environment/systems/voxels/atlas.rs | 66 +++++++++++++++++++ .../environment/systems/voxels/meshing.rs | 44 +++++++++---- .../plugins/environment/systems/voxels/mod.rs | 1 + .../systems/voxels/render_chunks.rs | 11 +++- .../environment/systems/voxels/structure.rs | 19 +++++- 7 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 client/src/plugins/environment/systems/voxels/atlas.rs diff --git a/client/src/plugins/environment/environment_plugin.rs b/client/src/plugins/environment/environment_plugin.rs index 0dff8bd..af448d0 100644 --- a/client/src/plugins/environment/environment_plugin.rs +++ b/client/src/plugins/environment/environment_plugin.rs @@ -9,6 +9,7 @@ use crate::plugins::environment::systems::voxels::queue_systems::{ enqueue_visible_chunks, process_chunk_queue, }; use crate::plugins::environment::systems::voxels::render_chunks::rebuild_dirty_chunks; +use crate::plugins::environment::systems::voxels::atlas::{VoxelTextureAtlas}; use crate::plugins::environment::systems::voxels::structure::{ ChunkBudget, ChunkCullingCfg, ChunkQueue, MeshBufferPool, PrevCameraChunk, SparseVoxelOctree, SpawnedChunks, @@ -22,6 +23,7 @@ impl Plugin for EnvironmentPlugin { app.add_systems( Startup, ( + setup_texture_atlas, crate::plugins::environment::systems::camera_system::setup, crate::plugins::environment::systems::environment_system::setup .after(crate::plugins::environment::systems::camera_system::setup), @@ -89,3 +91,8 @@ fn should_draw_grid(octree_query: Query<&SparseVoxelOctree>) -> bool { }; octree.show_world_grid } + +fn setup_texture_atlas(mut commands: Commands, mut images: ResMut>) { + let atlas = VoxelTextureAtlas::generate(&mut images); + commands.insert_resource(atlas); +} diff --git a/client/src/plugins/environment/systems/voxel_system.rs b/client/src/plugins/environment/systems/voxel_system.rs index ab9a445..52683dd 100644 --- a/client/src/plugins/environment/systems/voxel_system.rs +++ b/client/src/plugins/environment/systems/voxel_system.rs @@ -89,7 +89,7 @@ pub fn generate_voxel_sphere_parallel( center.y + iy as f32 * step, center.z + iz as f32 * step, ); - (pos, Voxel { color }) + (pos, Voxel { color, textures: [0; 6] }) }).collect::>() }) }) @@ -133,6 +133,7 @@ fn generate_voxel_sphere( // Insert the voxel let voxel = Voxel { color: voxel_color, + textures: [0; 6], }; octree.insert(position, voxel); } @@ -174,6 +175,7 @@ fn generate_voxel_rect( // Insert the voxel let voxel = Voxel { color: voxel_color, + textures: [0; 6], }; octree.insert(position, voxel); } @@ -209,6 +211,7 @@ fn generate_large_plane( // Insert the voxel let voxel = Voxel { color, + textures: [0; 6], }; octree.insert(position, voxel); } @@ -251,7 +254,7 @@ pub fn generate_solid_plane_with_noise( z * step, ); - let voxel = Voxel { color }; + let voxel = Voxel { color, textures: [0; 6] }; octree.insert(position, voxel); } } diff --git a/client/src/plugins/environment/systems/voxels/atlas.rs b/client/src/plugins/environment/systems/voxels/atlas.rs new file mode 100644 index 0000000..f61748c --- /dev/null +++ b/client/src/plugins/environment/systems/voxels/atlas.rs @@ -0,0 +1,66 @@ +use bevy::prelude::*; +use bevy::render::texture::{Extent3d, TextureDimension, TextureFormat}; + +/// Configuration and handle for the voxel texture atlas. +#[derive(Resource, Clone)] +pub struct VoxelTextureAtlas { + pub handle: Handle, + pub columns: usize, + pub rows: usize, +} + +impl VoxelTextureAtlas { + /// Create a simple procedural atlas with solid colors. + pub fn generate(images: &mut Assets) -> Self { + let tile_size = 16u32; + let columns = 2; + let rows = 3; + let width = tile_size * columns as u32; + let height = tile_size * rows as u32; + let mut data = vec![0u8; (width * height * 4) as usize]; + let colors = [ + [255, 0, 0, 255], // red + [0, 255, 0, 255], // green + [0, 0, 255, 255], // blue + [255, 255, 0, 255], // yellow + [255, 0, 255, 255], // magenta + [0, 255, 255, 255], // cyan + ]; + for (i, col) in colors.iter().enumerate() { + let cx = (i % columns) as u32 * tile_size; + let cy = (i / columns) as u32 * tile_size; + for y in 0..tile_size { + for x in 0..tile_size { + let idx = (((cy + y) * width + (cx + x)) * 4) as usize; + data[idx..idx + 4].copy_from_slice(col); + } + } + } + let image = Image::new_fill( + Extent3d { width, height, depth_or_array_layers: 1 }, + TextureDimension::D2, + &data, + TextureFormat::Rgba8UnormSrgb, + ); + let handle = images.add(image); + Self { handle, columns, rows } + } + + /// Compute UV coordinates for the given atlas index. + pub fn uv_rect(&self, index: usize) -> [[f32; 2]; 4] { + let col = index % self.columns; + let row = index / self.columns; + let cols = self.columns as f32; + let rows = self.rows as f32; + let u0 = col as f32 / cols; + let v0 = row as f32 / rows; + let u1 = (col + 1) as f32 / cols; + let v1 = (row + 1) as f32 / rows; + [ + [u0, v1], + [u1, v1], + [u1, v0], + [u0, v0], + ] + } +} diff --git a/client/src/plugins/environment/systems/voxels/meshing.rs b/client/src/plugins/environment/systems/voxels/meshing.rs index 5452d0e..da0dd86 100644 --- a/client/src/plugins/environment/systems/voxels/meshing.rs +++ b/client/src/plugins/environment/systems/voxels/meshing.rs @@ -1,4 +1,5 @@ use crate::plugins::environment::systems::voxels::structure::*; +use crate::plugins::environment::systems::voxels::atlas::VoxelTextureAtlas; use bevy::asset::RenderAssetUsages; use bevy::prelude::*; use bevy::render::mesh::{Indices, Mesh, PrimitiveTopology, VertexAttributeValues}; @@ -304,6 +305,7 @@ pub(crate) fn mesh_chunk( step: f32, tree: &SparseVoxelOctree, pool: &mut MeshBufferPool, + atlas: &VoxelTextureAtlas, ) -> Option { // ──────────────────────────────────────────────────────────────────────────── // Helpers @@ -313,15 +315,15 @@ pub(crate) fn mesh_chunk( const MASK_LEN: usize = N * N; // Safe voxel query that falls back to the octree for out‑of‑chunk requests. - let filled = |x: i32, y: i32, z: i32| -> bool { + let get_voxel = |x: i32, y: i32, z: i32| -> Option { if (0..CHUNK_SIZE).contains(&x) && (0..CHUNK_SIZE).contains(&y) && (0..CHUNK_SIZE).contains(&z) { - buffer[x as usize][y as usize][z as usize].is_some() + buffer[x as usize][y as usize][z as usize] } else { let world = origin + Vec3::new(x as f32 * step, y as f32 * step, z as f32 * step); - tree.get_voxel_at_world_coords(world).is_some() + tree.get_voxel_at_world_coords(world).copied() } }; @@ -341,7 +343,7 @@ pub(crate) fn mesh_chunk( let uvs = &mut pool.uvs; let indices = &mut pool.indices; - let mut push_quad = |base: Vec3, size: Vec2, n: Vec3, u: Vec3, v: Vec3| { + let mut push_quad = |base: Vec3, size: Vec2, n: Vec3, u: Vec3, v: Vec3, tex_id: usize| { let i0 = positions.len() as u32; positions.extend_from_slice(&[ (base).into(), @@ -350,7 +352,8 @@ pub(crate) fn mesh_chunk( (base + v * size.y).into(), ]); normals.extend_from_slice(&[[n.x, n.y, n.z]; 4]); - uvs.extend_from_slice(&[[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]]); + let uv_rect = atlas.uv_rect(tex_id); + uvs.extend_from_slice(&uv_rect); if n.x + n.y + n.z >= 0.0 { indices.extend_from_slice(&[i0, i0 + 1, i0 + 2, i0 + 2, i0 + 3, i0]); @@ -382,7 +385,7 @@ pub(crate) fn mesh_chunk( for slice in 0..=N { // Build the face mask for this slice using a fixed-size array to // avoid heap allocations. - let mut mask = [false; MASK_LEN]; + let mut mask = [None::; MASK_LEN]; let mut visited = [false; MASK_LEN]; let idx = |u: usize, v: usize| -> usize { u * N + v }; @@ -400,25 +403,38 @@ pub(crate) fn mesh_chunk( neighbor[u_axis] = u as i32; neighbor[v_axis] = v as i32; - if filled(cell[0], cell[1], cell[2]) - && !filled(neighbor[0], neighbor[1], neighbor[2]) - { - mask[idx(u, v)] = true; + if let Some(vox) = get_voxel(cell[0], cell[1], cell[2]) { + if get_voxel(neighbor[0], neighbor[1], neighbor[2]).is_none() { + let face_idx = match (axis, dir) { + (0, -1) => 0, + (0, 1) => 1, + (1, -1) => 2, + (1, 1) => 3, + (2, -1) => 4, + (2, 1) => 5, + _ => unreachable!(), + }; + mask[idx(u, v)] = Some(vox.textures[face_idx]); + } } } } + } // Greedy merge the mask into maximal rectangles. for u0 in 0..N { for v0 in 0..N { - if !mask[idx(u0, v0)] || visited[idx(u0, v0)] { + if visited[idx(u0, v0)] { + continue; + } + let Some(tex_id) = mask[idx(u0, v0)] else { continue }; continue; } // Determine the rectangle width. let mut width = 1; while u0 + width < N - && mask[idx(u0 + width, v0)] + && mask[idx(u0 + width, v0)] == Some(tex_id) && !visited[idx(u0 + width, v0)] { width += 1; @@ -428,7 +444,7 @@ pub(crate) fn mesh_chunk( let mut height = 1; 'h: while v0 + height < N { for du in 0..width { - if !mask[idx(u0 + du, v0 + height)] + if mask[idx(u0 + du, v0 + height)] != Some(tex_id) || visited[idx(u0 + du, v0 + height)] { break 'h; @@ -466,7 +482,7 @@ pub(crate) fn mesh_chunk( } let size = Vec2::new(width as f32 * step, height as f32 * step); - push_quad(base, size, face_normal, u_vec, v_vec); + push_quad(base, size, face_normal, u_vec, v_vec, tex_id); } } } diff --git a/client/src/plugins/environment/systems/voxels/mod.rs b/client/src/plugins/environment/systems/voxels/mod.rs index b71c048..b4a3129 100644 --- a/client/src/plugins/environment/systems/voxels/mod.rs +++ b/client/src/plugins/environment/systems/voxels/mod.rs @@ -10,3 +10,4 @@ mod meshing; pub mod meshing_gpu; pub mod queue_systems; pub mod render_chunks; +pub mod atlas; diff --git a/client/src/plugins/environment/systems/voxels/render_chunks.rs b/client/src/plugins/environment/systems/voxels/render_chunks.rs index 993d8be..acb268e 100644 --- a/client/src/plugins/environment/systems/voxels/render_chunks.rs +++ b/client/src/plugins/environment/systems/voxels/render_chunks.rs @@ -1,6 +1,7 @@ use crate::plugins::big_space::big_space_plugin::RootGrid; use crate::plugins::environment::systems::voxels::meshing::mesh_chunk; use crate::plugins::environment::systems::voxels::structure::*; +use crate::plugins::environment::systems::voxels::atlas::VoxelTextureAtlas; use bevy::pbr::wireframe::Wireframe; use bevy::prelude::*; use bevy::render::mesh::Mesh; @@ -25,6 +26,7 @@ pub fn rebuild_dirty_chunks( mut spawned: ResMut, mut pool: ResMut, root: Res, + atlas: Res, ) { // map ChunkKey → (entity, mesh-handle, material-handle) let existing: HashMap, Handle, u32)> = @@ -87,7 +89,7 @@ pub fn rebuild_dirty_chunks( for (key, buf, origin, step, lod) in bufs { if let Some((ent, mesh_h, _mat_h, _)) = existing.get(&key).cloned() { // update mesh in-place; keeps old asset id - match mesh_chunk(&buf, origin, step, &tree, &mut pool) { + match mesh_chunk(&buf, origin, step, &tree, &mut pool, &atlas) { Some(new_mesh) => { if let Some(mesh) = meshes.get_mut(&mesh_h) { *mesh = new_mesh; @@ -100,10 +102,13 @@ pub fn rebuild_dirty_chunks( spawned.0.remove(&key); } } - } else if let Some(mesh) = mesh_chunk(&buf, origin, step, &tree, &mut pool) { + } else if let Some(mesh) = mesh_chunk(&buf, origin, step, &tree, &mut pool, &atlas) { // spawn brand-new chunk only if mesh has faces let mesh_h = meshes.add(mesh); - let mat_h = materials.add(StandardMaterial::default()); + let mat_h = materials.add(StandardMaterial { + base_color_texture: Some(atlas.handle.clone()), + ..Default::default() + }); commands.entity(root.0).with_children(|p| { let e = p diff --git a/client/src/plugins/environment/systems/voxels/structure.rs b/client/src/plugins/environment/systems/voxels/structure.rs index b687d5a..4327afa 100644 --- a/client/src/plugins/environment/systems/voxels/structure.rs +++ b/client/src/plugins/environment/systems/voxels/structure.rs @@ -20,13 +20,26 @@ where } /// Represents a single voxel with a color. -#[derive(Debug, Clone, Copy, Component, PartialEq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Component, PartialEq, Serialize, Deserialize)] pub struct Voxel { #[serde( serialize_with = "serialize_color", deserialize_with = "deserialize_color" )] pub color: Color, + /// Indexes into the texture atlas for the six faces in the order + /// left, right, bottom, top, back, front. + #[serde(default)] + pub textures: [usize; 6], +} + +impl Default for Voxel { + fn default() -> Self { + Self { + color: Color::WHITE, + textures: [0; 6], + } + } } #[derive(Debug, Clone, Copy)] @@ -77,8 +90,8 @@ impl OctreeNode { impl Voxel { /// Creates a new empty octree node. - pub fn new(color: Color) -> Self { - Self { color } + pub fn new(color: Color, textures: [usize; 6]) -> Self { + Self { color, textures } } }