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:
2026-03-24 19:52:35 +09:00
parent 04ca5df062
commit 71f6081dc9
2 changed files with 237 additions and 0 deletions

View File

@@ -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;

View 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());
}
}