diff --git a/src/world/generation/terrain.rs b/src/world/generation/terrain.rs index 64fec85..fb46146 100644 --- a/src/world/generation/terrain.rs +++ b/src/world/generation/terrain.rs @@ -146,8 +146,18 @@ pub fn generate_chunk_terrain( }); }); - for (pos, data) in tilemap_updates.into_inner().unwrap() { - tilemap.floor_tiles.insert(pos, data); + let new_positions: Vec = tilemap_updates + .into_inner() + .unwrap() + .into_iter() + .map(|(pos, data)| { + tilemap.floor_tiles.insert(pos, data); + pos + }) + .collect(); + + // All tiles now in tilemap — safe to calculate visibility + for pos in new_positions { occlusion_event_writer.write(TileOcclusionEvent { tile_position: pos }); } diff --git a/src/world/tiles/rendering.rs b/src/world/tiles/rendering.rs index aef39a0..6a47d6d 100644 --- a/src/world/tiles/rendering.rs +++ b/src/world/tiles/rendering.rs @@ -209,39 +209,42 @@ pub fn build_quilted_terrain_sprites( _ => return, }; - // Bounds are clamped to the chunk grid so textures are always exactly - // CHUNK_TILES wide/tall (or smaller at world edges), keeping sizes - // predictable and buffer reuse rates high. - let chunk_world_min_x = key.chunk_x as f32 * TILE_SIZE * CHUNK_TILES as f32; - let chunk_world_min_y = key.chunk_y as f32 * TILE_SIZE * CHUNK_TILES as f32; - - // Actual tile extent within this chunk (may be smaller than full chunk - // at world edges or sparse z-levels). + // Actual tile extent within this chunk, padded by one tile on each side + // so the rendered quad always shows one tile of context beyond the edge + // (prevents the hard black cutoff at chunk/world boundaries). let min_x_aligned: f32 = ((tiles.iter().map(|(p, _)| p.x).reduce(f32::min).unwrap() - TILE_SIZE / 2.0) / TILE_SIZE) .floor() - * TILE_SIZE; + * TILE_SIZE + - TILE_SIZE; let min_y_aligned: f32 = ((tiles.iter().map(|(p, _)| p.y).reduce(f32::min).unwrap() - TILE_SIZE / 2.0) / TILE_SIZE) .floor() - * TILE_SIZE; + * TILE_SIZE + - TILE_SIZE; let max_x_aligned: f32 = ((tiles.iter().map(|(p, _)| p.x).reduce(f32::max).unwrap() + TILE_SIZE / 2.0) / TILE_SIZE) .ceil() - * TILE_SIZE; + * TILE_SIZE + + TILE_SIZE; let max_y_aligned: f32 = ((tiles.iter().map(|(p, _)| p.y).reduce(f32::max).unwrap() + TILE_SIZE / 2.0) / TILE_SIZE) .ceil() - * TILE_SIZE; + * TILE_SIZE + + TILE_SIZE; let width_tiles = ((max_x_aligned - min_x_aligned) / TILE_SIZE) as u32; let height_tiles = ((max_y_aligned - min_y_aligned) / TILE_SIZE) as u32; - let width_px = width_tiles * TILE_PIXELS; - let height_px = height_tiles * TILE_PIXELS; + + // Add 2px bleed (1px each side) so adjacent chunk quads overlap by 1px + // at any zoom level. Without this, sub-pixel gaps appear between chunks + // at fractional orthographic scales. + let width_px = width_tiles * TILE_PIXELS + 2; + let height_px = height_tiles * TILE_PIXELS + 2; let required_len = (width_px * height_px * 4) as usize; dimensions_results @@ -281,8 +284,8 @@ pub fn build_quilted_terrain_sprites( src_height, width_px, height_px, - tile_x * TILE_PIXELS, - tile_y * TILE_PIXELS, + tile_x * TILE_PIXELS + 1, // +1 to account for 1px bleed border + tile_y * TILE_PIXELS + 1, ); } @@ -328,13 +331,13 @@ pub fn build_quilted_terrain_sprites( ..Default::default() }, Transform::from_xyz( - center_x - TILE_SIZE / 2.0, - center_y - TILE_SIZE / 2.0, + center_x - TILE_SIZE / 2.0 - PIXEL_RATIO, + center_y - TILE_SIZE / 2.0 + PIXEL_RATIO, -Z_BELOW * TILE_SIZE, ) .with_scale(Vec3::splat(PIXEL_RATIO)), Visibility::Hidden, - TerrainSprite { key }, + TerrainSprite { key }, // access z_index via .key.z_index )); }); } diff --git a/src/world/tiles/visibility.rs b/src/world/tiles/visibility.rs index 64e5459..c742685 100644 --- a/src/world/tiles/visibility.rs +++ b/src/world/tiles/visibility.rs @@ -118,61 +118,73 @@ pub fn handle_tile_occlusion_updates( pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] { let mut visible_range = [0u32; 8]; - for mut camera_z in -Z_BELOW as i32..=Z_ABOVE as i32 { - camera_z *= ITILE_SIZE; - let mut is_visible = false; + let touches_air = 'neighbor_check: { + for x_offset in -1..=1 { + for y_offset in -1..=1 { + for z_offset in 0..=1 { + if x_offset == 0 && y_offset == 0 && z_offset == 0 { + continue; + } + let neighbor_pos = IVec3::new( + pos.x + x_offset * ITILE_SIZE, + pos.y + y_offset * ITILE_SIZE, + pos.z + z_offset * ITILE_SIZE, + ); + match tilemap.floor_tiles.get(&neighbor_pos) { + Some(&(id, _, _, _, _, _)) if id == 0 => break 'neighbor_check true, + None => {} + _ => {} + } + } + } + } + false + }; - if pos.z > camera_z { + if !touches_air { + return visible_range; + } + + let base_camera_index = (pos.z / ITILE_SIZE) + Z_BELOW as i32; + + if base_camera_index >= 0 && base_camera_index <= Z_TOTAL as i32 { + if touches_air { + let z2 = base_camera_index as usize; + visible_range[z2 / 32] |= 1 << (z2 % 32); + } + } + + let mut occluded = false; + + for z_offset in 1..=(Z_TOTAL as i32 + Z_BELOW as i32 + 1) { + let check_z = pos.z + z_offset * ITILE_SIZE; + let camera_z_index = (check_z / ITILE_SIZE) + Z_BELOW as i32; + + if camera_z_index < 0 { + match tilemap.floor_tiles.get(&IVec3::new(pos.x, pos.y, check_z)) { + Some(&(_, _, _, visibly_transparent, _, _)) => occluded = !visibly_transparent, + None => occluded = false, + } continue; } - let mut is_occluded = false; - - let v_check_height: i32 = Z_TOTAL as i32 - (camera_z / ITILE_SIZE) + Z_BELOW as i32 + 1; - 'vertical_check: for z_offset in 1..v_check_height { - let above_pos = IVec3::new(pos.x, pos.y, pos.z + (z_offset * ITILE_SIZE)); - if above_pos.z <= camera_z { - if let Some(&(_, _, _, visibly_transparent, _, _)) = - tilemap.floor_tiles.get(&above_pos) - { - if !visibly_transparent { - is_occluded = true; - break 'vertical_check; - } - } - } else { - break 'vertical_check; - } - } - if !is_occluded { - 'neighbor_check: for x_offset in -1..=1 { - for y_offset in -1..=1 { - for z_offset in 0..=1 { - if x_offset == 0 && y_offset == 0 && z_offset == 0 { - continue; - } - let neighbor_pos = IVec3::new( - pos.x + x_offset * ITILE_SIZE, - pos.y + y_offset * ITILE_SIZE, - pos.z + z_offset * ITILE_SIZE, - ); - - if let Some(&(id, _, _, _, _, _)) = tilemap.floor_tiles.get(&neighbor_pos) { - if id == 0 { - // id 0 = air tile - is_visible = true; - break 'neighbor_check; - } - } - } - } - } + if camera_z_index > Z_TOTAL as i32 { + break; } - if is_visible { - let z2 = ((camera_z / ITILE_SIZE) + Z_BELOW as i32) as usize; + if !occluded { + let z2 = camera_z_index as usize; visible_range[z2 / 32] |= 1 << ((z2 % 32) as u32); } + + let tile = tilemap.floor_tiles.get(&IVec3::new(pos.x, pos.y, check_z)); + + match tile { + Some(&(_, _, _, visibly_transparent, _, _)) => { + occluded = !visibly_transparent; + } + None => occluded = false, + } } visible_range