From 71f6081dc9e77277cc5a7b1f1e2e542143766824 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 19:52:35 +0900 Subject: [PATCH] feat(renderer): add BMP texture loader and GPU texture upload Implements parse_bmp (24/32-bit uncompressed BMP to RGBA), GpuTexture with wgpu 28.0 write_texture API (TexelCopyTextureInfo/TexelCopyBufferLayout), bind_group_layout, white_1x1 fallback, and 3 BMP parser unit tests. Co-Authored-By: Claude Sonnet 4.6 --- crates/voltex_renderer/src/lib.rs | 2 + crates/voltex_renderer/src/texture.rs | 235 ++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 crates/voltex_renderer/src/texture.rs diff --git a/crates/voltex_renderer/src/lib.rs b/crates/voltex_renderer/src/lib.rs index 77e26f1..233b17e 100644 --- a/crates/voltex_renderer/src/lib.rs +++ b/crates/voltex_renderer/src/lib.rs @@ -2,6 +2,7 @@ pub mod gpu; pub mod light; pub mod obj; pub mod pipeline; +pub mod texture; pub mod vertex; pub mod mesh; pub mod camera; @@ -10,3 +11,4 @@ pub use gpu::{GpuContext, DEPTH_FORMAT}; pub use light::{CameraUniform, LightUniform}; pub use mesh::Mesh; pub use camera::{Camera, FpsController}; +pub use texture::GpuTexture; diff --git a/crates/voltex_renderer/src/texture.rs b/crates/voltex_renderer/src/texture.rs new file mode 100644 index 0000000..0b95f33 --- /dev/null +++ b/crates/voltex_renderer/src/texture.rs @@ -0,0 +1,235 @@ +pub struct BmpImage { + pub width: u32, + pub height: u32, + pub pixels: Vec, // RGBA +} + +pub fn parse_bmp(data: &[u8]) -> Result { + if data.len() < 54 { + return Err(format!("BMP too small: {} bytes", data.len())); + } + if data[0] != b'B' || data[1] != b'M' { + return Err("Not a BMP file: missing 'BM' signature".to_string()); + } + + let pixel_offset = u32::from_le_bytes(data[10..14].try_into().unwrap()) as usize; + let width_raw = i32::from_le_bytes(data[18..22].try_into().unwrap()); + let height_raw = i32::from_le_bytes(data[22..26].try_into().unwrap()); + let bpp = u16::from_le_bytes(data[28..30].try_into().unwrap()); + let compression = u32::from_le_bytes(data[30..34].try_into().unwrap()); + + if bpp != 24 && bpp != 32 { + return Err(format!("Unsupported BMP bpp: {} (only 24 and 32 supported)", bpp)); + } + if compression != 0 { + return Err(format!("Unsupported BMP compression: {} (only 0/uncompressed supported)", compression)); + } + + let width = width_raw.unsigned_abs(); + let height = height_raw.unsigned_abs(); + let bottom_up = height_raw > 0; + + let bytes_per_pixel = (bpp as usize) / 8; + let row_size = ((bpp as usize * width as usize + 31) / 32) * 4; + let required_data_size = pixel_offset + row_size * height as usize; + + if data.len() < required_data_size { + return Err(format!( + "BMP data too small: need {} bytes, got {}", + required_data_size, + data.len() + )); + } + + let mut pixels = vec![0u8; (width * height * 4) as usize]; + + for row in 0..height as usize { + let src_row = if bottom_up { height as usize - 1 - row } else { row }; + let row_start = pixel_offset + src_row * row_size; + + for col in 0..width as usize { + let src_offset = row_start + col * bytes_per_pixel; + let dst_offset = (row * width as usize + col) * 4; + + // BMP stores BGR(A), convert to RGBA + let b = data[src_offset]; + let g = data[src_offset + 1]; + let r = data[src_offset + 2]; + let a = if bytes_per_pixel == 4 { data[src_offset + 3] } else { 255 }; + + pixels[dst_offset] = r; + pixels[dst_offset + 1] = g; + pixels[dst_offset + 2] = b; + pixels[dst_offset + 3] = a; + } + } + + Ok(BmpImage { width, height, pixels }) +} + +pub struct GpuTexture { + pub texture: wgpu::Texture, + pub view: wgpu::TextureView, + pub sampler: wgpu::Sampler, + pub bind_group: wgpu::BindGroup, +} + +impl GpuTexture { + pub fn from_rgba( + device: &wgpu::Device, + queue: &wgpu::Queue, + width: u32, + height: u32, + pixels: &[u8], + layout: &wgpu::BindGroupLayout, + ) -> Self { + let size = wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("BmpTexture"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * width), + rows_per_image: Some(height), + }, + size, + ); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("BmpSampler"), + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + address_mode_w: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Linear, + ..Default::default() + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("BmpBindGroup"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + Self { texture, view, sampler, bind_group } + } + + pub fn white_1x1( + device: &wgpu::Device, + queue: &wgpu::Queue, + layout: &wgpu::BindGroupLayout, + ) -> Self { + Self::from_rgba(device, queue, 1, 1, &[255, 255, 255, 255], layout) + } + + pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("TextureBindGroupLayout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_bmp_24bit(width: u32, height: u32, pixel_bgr: [u8; 3]) -> Vec { + let row_size = ((24 * width + 31) / 32 * 4) as usize; + let pixel_data_size = row_size * height as usize; + let file_size = 54 + pixel_data_size; + let mut data = vec![0u8; file_size]; + data[0] = b'B'; + data[1] = b'M'; + data[2..6].copy_from_slice(&(file_size as u32).to_le_bytes()); + data[10..14].copy_from_slice(&54u32.to_le_bytes()); + data[14..18].copy_from_slice(&40u32.to_le_bytes()); + data[18..22].copy_from_slice(&(width as i32).to_le_bytes()); + data[22..26].copy_from_slice(&(height as i32).to_le_bytes()); + data[26..28].copy_from_slice(&1u16.to_le_bytes()); + data[28..30].copy_from_slice(&24u16.to_le_bytes()); + for row in 0..height { + for col in 0..width { + let offset = 54 + (row as usize) * row_size + (col as usize) * 3; + data[offset] = pixel_bgr[0]; + data[offset + 1] = pixel_bgr[1]; + data[offset + 2] = pixel_bgr[2]; + } + } + data + } + + #[test] + fn test_parse_bmp_24bit() { + let bmp = make_bmp_24bit(2, 2, [255, 0, 0]); // BGR blue + let img = parse_bmp(&bmp).unwrap(); + assert_eq!(img.width, 2); + assert_eq!(img.height, 2); + assert_eq!(img.pixels[0], 0); // R + assert_eq!(img.pixels[1], 0); // G + assert_eq!(img.pixels[2], 255); // B + assert_eq!(img.pixels[3], 255); // A + } + + #[test] + fn test_parse_bmp_not_bmp() { + let data = vec![0u8; 100]; + assert!(parse_bmp(&data).is_err()); + } + + #[test] + fn test_parse_bmp_too_small() { + let data = vec![0u8; 10]; + assert!(parse_bmp(&data).is_err()); + } +}