use anyhow::{Context, Result};
use image::{DynamicImage, GenericImageView};
use std::path::{Path, PathBuf};
use wgpu::*;
const FACE_SUFFIXES: [&str; 6] = ["px", "nx", "ny", "py", "pz", "nz"];
const SUPPORTED_EXTENSIONS: [&str; 4] = ["png", "jpg", "jpeg", "hdr"];
pub struct CubemapTexture {
pub texture: Texture,
pub view: TextureView,
pub sampler: Sampler,
pub face_size: u32,
pub is_hdr: bool,
}
impl CubemapTexture {
pub fn placeholder(device: &Device, queue: &Queue) -> Self {
let texture = device.create_texture(&TextureDescriptor {
label: Some("Cubemap Placeholder"),
size: Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 6,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
});
for layer in 0..6u32 {
queue.write_texture(
TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: Origin3d {
x: 0,
y: 0,
z: layer,
},
aspect: TextureAspect::All,
},
&[0u8, 0, 0, 0],
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4),
rows_per_image: Some(1),
},
Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
}
let view = texture.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::Cube),
..Default::default()
});
let sampler = Self::create_sampler(device);
Self {
texture,
view,
sampler,
face_size: 1,
is_hdr: false,
}
}
pub fn from_prefix(device: &Device, queue: &Queue, prefix: &Path) -> Result<Self> {
let face_paths = Self::find_face_files(prefix)?;
let is_hdr = face_paths[0]
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("hdr"));
let first_img = image::open(&face_paths[0])
.with_context(|| format!("Failed to load: {}", face_paths[0].display()))?;
let (width, height) = first_img.dimensions();
if width != height {
anyhow::bail!("Cubemap faces must be square, got {}x{}", width, height);
}
let face_size = width;
let (format, bytes_per_pixel) = if is_hdr {
(TextureFormat::Rgba16Float, 8u32) } else {
(TextureFormat::Rgba8UnormSrgb, 4u32)
};
let texture = device.create_texture(&TextureDescriptor {
label: Some("Cubemap Texture"),
size: Extent3d {
width: face_size,
height: face_size,
depth_or_array_layers: 6,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
});
Self::upload_face(
queue,
&texture,
&first_img,
0,
face_size,
bytes_per_pixel,
is_hdr,
)?;
for (layer, path) in face_paths.iter().enumerate().skip(1) {
let img =
image::open(path).with_context(|| format!("Failed to load: {}", path.display()))?;
let (w, h) = img.dimensions();
if w != face_size || h != face_size {
anyhow::bail!(
"Face size mismatch: expected {}x{}, got {}x{} for {}",
face_size,
face_size,
w,
h,
path.display()
);
}
Self::upload_face(
queue,
&texture,
&img,
layer as u32,
face_size,
bytes_per_pixel,
is_hdr,
)?;
}
let view = texture.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::Cube),
..Default::default()
});
let sampler = Self::create_sampler(device);
log::info!(
"Loaded {} cubemap ({}x{} per face) from {}",
if is_hdr { "HDR" } else { "LDR" },
face_size,
face_size,
prefix.display()
);
Ok(Self {
texture,
view,
sampler,
face_size,
is_hdr,
})
}
fn find_face_files(prefix: &Path) -> Result<[PathBuf; 6]> {
let parent = prefix.parent().unwrap_or(Path::new("."));
let stem = prefix.file_name().and_then(|s| s.to_str()).unwrap_or("");
let mut paths: [Option<PathBuf>; 6] = Default::default();
for (i, suffix) in FACE_SUFFIXES.iter().enumerate() {
for ext in &SUPPORTED_EXTENSIONS {
let filename = format!("{}-{}.{}", stem, suffix, ext);
let path = parent.join(&filename);
if path.exists() {
paths[i] = Some(path);
break;
}
}
if paths[i].is_none() {
anyhow::bail!(
"Missing cubemap face: {}-{}.{{png,jpg,jpeg,hdr}}",
prefix.display(),
suffix
);
}
}
Ok(paths.map(|p| p.expect("All cubemap face paths must be Some after validation above")))
}
fn upload_face(
queue: &Queue,
texture: &Texture,
img: &DynamicImage,
layer: u32,
face_size: u32,
bytes_per_pixel: u32,
is_hdr: bool,
) -> Result<()> {
let img = img.flipv();
let data: Vec<u8> = if is_hdr {
let rgb32f = img.to_rgb32f();
rgb32f
.pixels()
.flat_map(|p| {
let r = half::f16::from_f32(p.0[0]);
let g = half::f16::from_f32(p.0[1]);
let b = half::f16::from_f32(p.0[2]);
let a = half::f16::from_f32(1.0);
[
r.to_le_bytes(),
g.to_le_bytes(),
b.to_le_bytes(),
a.to_le_bytes(),
]
.into_iter()
.flatten()
})
.collect()
} else {
img.to_rgba8().into_raw()
};
queue.write_texture(
TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: Origin3d {
x: 0,
y: 0,
z: layer,
},
aspect: TextureAspect::All,
},
&data,
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_pixel * face_size),
rows_per_image: Some(face_size),
},
Extent3d {
width: face_size,
height: face_size,
depth_or_array_layers: 1,
},
);
Ok(())
}
fn create_sampler(device: &Device) -> Sampler {
device.create_sampler(&SamplerDescriptor {
label: Some("Cubemap Sampler"),
address_mode_u: AddressMode::ClampToEdge,
address_mode_v: AddressMode::ClampToEdge,
address_mode_w: AddressMode::ClampToEdge,
mag_filter: FilterMode::Linear,
min_filter: FilterMode::Linear,
mipmap_filter: FilterMode::Linear,
..Default::default()
})
}
pub fn resolution(&self) -> [f32; 4] {
[self.face_size as f32, self.face_size as f32, 1.0, 0.0]
}
}