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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ pub mod gpu;
|
|||||||
pub mod light;
|
pub mod light;
|
||||||
pub mod obj;
|
pub mod obj;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
pub mod texture;
|
||||||
pub mod vertex;
|
pub mod vertex;
|
||||||
pub mod mesh;
|
pub mod mesh;
|
||||||
pub mod camera;
|
pub mod camera;
|
||||||
@@ -10,3 +11,4 @@ pub use gpu::{GpuContext, DEPTH_FORMAT};
|
|||||||
pub use light::{CameraUniform, LightUniform};
|
pub use light::{CameraUniform, LightUniform};
|
||||||
pub use mesh::Mesh;
|
pub use mesh::Mesh;
|
||||||
pub use camera::{Camera, FpsController};
|
pub use camera::{Camera, FpsController};
|
||||||
|
pub use texture::GpuTexture;
|
||||||
|
|||||||
235
crates/voltex_renderer/src/texture.rs
Normal file
235
crates/voltex_renderer/src/texture.rs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
pub struct BmpImage {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub pixels: Vec<u8>, // RGBA
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_bmp(data: &[u8]) -> Result<BmpImage, String> {
|
||||||
|
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<u8> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user