pebble/wgpu/cubemap.rs
1use crate::{
2 assets::{handle::Handle, storage::Assets, upload::Asset},
3 ecs::system::Res,
4 wgpu::{
5 backend::WGPUBackend,
6 flags::TextureUsages,
7 gpu_context::GpuContext,
8 mipmap::MipmapGenerator,
9 texture_format::TextureFormat,
10 textures::{bytes_per_pixel, check_texture_dimensions, decode_file, write_texture_level0},
11 },
12};
13
14/// Source data for [`GPUCubemap`]. Fields are private — the only way to
15/// construct one is [`CubemapBuilder`]: `CubemapBuilder::from_files(...).build()`.
16pub struct Cubemap {
17 /// Edge length in pixels — cubemap faces are always square.
18 size: u32,
19 /// GPU pixel format to upload as. Defaults to `Rgba8UnormSrgb`.
20 format: TextureFormat,
21 /// `Some` uploads 6 faces of pixel data up front (wgpu's expected
22 /// order: +X, -X, +Y, -Y, +Z, -Z). `None` allocates an empty cubemap
23 /// meant to be filled later by rendering into per-face views — e.g. an
24 /// environment-map capture pass — in which case [`wgpu_descriptor`](Self::wgpu_descriptor)
25 /// adds `RENDER_ATTACHMENT` usage instead of requiring upload data.
26 faces: Option<[Vec<u8>; 6]>,
27 /// File paths for each of the 6 faces (same order as `faces`), decoded
28 /// through the same loader used by `GPUTexture`/`GPUTextureArray`.
29 face_files: Option<[&'static str; 6]>,
30 /// Whether to generate a full mip chain (via [`MipmapGenerator`]). Only
31 /// applies when uploading pixel data (`faces`/`face_files` set) —
32 /// meaningless for an [`empty`](CubemapBuilder::empty) render-target cubemap.
33 generate_mips: bool,
34}
35
36impl Cubemap {
37 /// `render_target` is set for an empty capture-target cubemap (see
38 /// [`empty`](CubemapBuilder::empty)), rendered into directly. Separately
39 /// from that, `mip_count > 1` also needs `RENDER_ATTACHMENT` — mips
40 /// beyond level 0 are rendered into by [`MipmapGenerator::generate_mips`](super::mipmap::MipmapGenerator::generate_mips)
41 /// regardless of whether the base texture is a capture target or one
42 /// uploaded from real face data, so the two conditions are OR'd rather
43 /// than `render_target` alone deciding the usage.
44 fn wgpu_descriptor(&self, mip_count: u32, render_target: bool) -> wgpu::TextureDescriptor<'_> {
45 let mut usage = super::mipmap::texture_usage(mip_count);
46 if render_target {
47 usage |= TextureUsages::RENDER_ATTACHMENT.into();
48 }
49
50 wgpu::TextureDescriptor {
51 label: None,
52 size: wgpu::Extent3d {
53 width: self.size,
54 height: self.size,
55 depth_or_array_layers: 6,
56 },
57 mip_level_count: mip_count,
58 sample_count: 1,
59 dimension: wgpu::TextureDimension::D2,
60 format: self.format.into(),
61 usage,
62 view_formats: &[],
63 }
64 }
65}
66
67/// Builds a [`Cubemap`] — load 6 faces from files, supply raw bytes for each
68/// face directly, or allocate an empty cubemap with no initial data. Start
69/// from [`from_files`](Self::from_files)/[`from_faces`](Self::from_faces)/[`empty`](Self::empty),
70/// chain the setters below, then finish with
71/// [`build`](Self::build)/[`build_asset`](Self::build_asset).
72pub struct CubemapBuilder {
73 size: u32,
74 format: TextureFormat,
75 faces: Option<[Vec<u8>; 6]>,
76 face_files: Option<[&'static str; 6]>,
77 generate_mips: bool,
78}
79
80impl CubemapBuilder {
81 /// Load 6 faces from files (+X, -X, +Y, -Y, +Z, -Z). Size is inferred from the first face.
82 pub fn from_files(size: u32, files: [&'static str; 6]) -> Self {
83 Self {
84 size,
85 format: TextureFormat::Rgba8UnormSrgb,
86 faces: None,
87 face_files: Some(files),
88 generate_mips: false,
89 }
90 }
91
92 /// Supply raw pixel bytes for each face (+X, -X, +Y, -Y, +Z, -Z).
93 pub fn from_faces(size: u32, format: TextureFormat, faces: [Vec<u8>; 6]) -> Self {
94 Self {
95 size,
96 format,
97 faces: Some(faces),
98 face_files: None,
99 generate_mips: false,
100 }
101 }
102
103 /// Allocate an empty cubemap for use as a render target (e.g. environment capture).
104 pub fn empty(size: u32, format: TextureFormat) -> Self {
105 Self {
106 size,
107 format,
108 faces: None,
109 face_files: None,
110 generate_mips: false,
111 }
112 }
113
114 /// Override the format set by whichever constructor was used (all
115 /// three default to or take `format` directly — this exists for the
116 /// builder-chain case, e.g. `CubemapBuilder::empty(size, format).with_mips()`
117 /// followed later by a format change, without re-specifying `size`).
118 pub fn with_format(mut self, format: TextureFormat) -> Self {
119 self.format = format;
120 self
121 }
122
123 /// Enable full mip chain generation.
124 pub fn with_mips(mut self) -> Self {
125 self.generate_mips = true;
126 self
127 }
128
129 /// Logs a WARN for `with_mips()` called on an [`empty`](Self::empty)
130 /// cubemap — per `generate_mips`'s own doc, mip generation only applies
131 /// when uploading real face data, so this combination is a no-op.
132 fn validate(&self) {
133 if self.generate_mips && self.faces.is_none() && self.face_files.is_none() {
134 tracing::warn!(
135 "CubemapBuilder: with_mips() has no effect on an empty() render-target cubemap \
136 — mip generation only applies when faces/face_files supply pixel data"
137 );
138 }
139 }
140
141 /// Consume the builder and return the finished [`Cubemap`] value.
142 pub fn build(self) -> Cubemap {
143 self.validate();
144 Cubemap {
145 size: self.size,
146 format: self.format,
147 faces: self.faces,
148 face_files: self.face_files,
149 generate_mips: self.generate_mips,
150 }
151 }
152
153 /// Consume the builder, insert into `assets` under `name`, and return
154 /// the resulting [`Handle<Cubemap>`].
155 pub fn build_asset(self, name: &str, assets: &mut Assets<Cubemap>) -> Handle<Cubemap> {
156 let cubemap = self.build();
157 assets.insert(name, cubemap)
158 }
159}
160
161/// A cubemap uploaded to the GPU, ready to bind (e.g. via
162/// [`BindingInstanceEntry::Cubemap`](super::instance::BindingInstanceEntry::Cubemap)).
163/// Opaque — bind it via
164/// [`BindGroupBuilder::texture_cubemap`](super::buffers::BindGroupBuilder::texture_cubemap).
165///
166/// [`empty`](CubemapBuilder::empty)'s documented use case — rendering
167/// into per-face views for environment capture (a skybox capture, an
168/// irradiance/specular IBL prefilter pass, a reflection probe, ...) — is
169/// [`face_attachment`](Self::face_attachment).
170pub struct GPUCubemap {
171 texture: wgpu::Texture,
172 view: wgpu::TextureView,
173 size: u32,
174 format: TextureFormat,
175 ctx: GpuContext,
176}
177
178impl GPUCubemap {
179 /// Overwrites one face's level-0 pixel data (+X, -X, +Y, -Y, +Z, -Z is
180 /// `face` 0..=5, matching [`CubemapBuilder::from_faces`]'s order).
181 /// See [`GPUTexture::write`](super::textures::GPUTexture::write) for the
182 /// same caveat about mip levels not being regenerated.
183 pub fn write_face(&self, face: u32, pixels: &[u8]) {
184 write_texture_level0(self.ctx.queue(), &self.texture, face, self.format.into(), self.size, self.size, pixels);
185 }
186
187 /// A render-target view onto one face at one mip level, for rendering
188 /// into directly — an environment-map capture, a specular IBL prefilter
189 /// pass writing successive mip levels, a reflection probe. `face` is
190 /// `0..=5` in the same order as [`CubemapBuilder::from_faces`]'s
191 /// array (+X, -X, +Y, -Y, +Z, -Z); `mip_level` is `0` unless this
192 /// cubemap was built [`with_mips`](CubemapBuilder::with_mips), in
193 /// which case a prefilter pass typically writes one mip level per
194 /// roughness step.
195 ///
196 /// Only meaningful for a cubemap allocated with `RENDER_ATTACHMENT`
197 /// usage, which [`CubemapBuilder::empty`] sets automatically.
198 /// Panics if `face` is out of range.
199 pub fn face_attachment(&self, face: u32, mip_level: u32) -> super::texture_view::TextureView {
200 assert!(face < 6, "GPUCubemap::face_attachment: face {face} out of range (0..=5)");
201 let view = self.texture.create_view(&wgpu::TextureViewDescriptor {
202 dimension: Some(wgpu::TextureViewDimension::D2),
203 base_mip_level: mip_level,
204 mip_level_count: Some(1),
205 base_array_layer: face,
206 array_layer_count: Some(1),
207 ..Default::default()
208 });
209 super::texture_view::TextureView::from_raw(view, self.texture.clone())
210 }
211
212 /// Edge length in pixels.
213 pub fn size(&self) -> u32 {
214 self.size
215 }
216
217 pub(crate) fn view(&self) -> &wgpu::TextureView {
218 &self.view
219 }
220}
221
222impl Asset<WGPUBackend> for GPUCubemap {
223 type Source = Cubemap;
224 type Deps<'a> = Res<'a, MipmapGenerator>;
225
226 fn upload<'a>(
227 source: &Cubemap,
228 backend: &WGPUBackend,
229 mipmap_generator: &Res<'a, MipmapGenerator>,
230 ) -> Option<Self> {
231 let faces: Option<[Vec<u8>; 6]> = if let Some(files) = &source.face_files {
232 let mut out: [Vec<u8>; 6] = Default::default();
233 for (i, path) in files.iter().enumerate() {
234 let (w, h, data) = decode_file(path, source.format.into())?;
235 if w != source.size || h != source.size {
236 tracing::error!(
237 "CubemapSpec: face {i} ('{path}') is {w}x{h}, expected {0}x{0}",
238 source.size
239 );
240 return None;
241 }
242 out[i] = data;
243 }
244 Some(out)
245 } else {
246 source.faces.clone()
247 };
248
249 check_texture_dimensions(&backend.device, "GPUCubemap", source.size, source.size);
250
251 let mip_count = super::mipmap::mip_count(source.size, source.generate_mips);
252
253 let texture = backend
254 .device
255 .create_texture(&source.wgpu_descriptor(mip_count, faces.is_none()));
256
257 if let Some(faces) = &faces {
258 for (face, data) in faces.iter().enumerate() {
259 backend.queue.write_texture(
260 wgpu::TexelCopyTextureInfo {
261 texture: &texture,
262 mip_level: 0,
263 origin: wgpu::Origin3d {
264 x: 0,
265 y: 0,
266 z: face as u32,
267 },
268 aspect: wgpu::TextureAspect::All,
269 },
270 data,
271 wgpu::TexelCopyBufferLayout {
272 offset: 0,
273 bytes_per_row: Some(bytes_per_pixel(source.format.into()) * source.size),
274 rows_per_image: Some(source.size),
275 },
276 wgpu::Extent3d {
277 width: source.size,
278 height: source.size,
279 depth_or_array_layers: 1,
280 },
281 );
282 }
283
284 if mip_count > 1 {
285 mipmap_generator.generate_mips(
286 &backend.device,
287 &backend.queue,
288 &texture,
289 source.format.into(),
290 mip_count,
291 6,
292 );
293 }
294 }
295
296 let view = texture.create_view(&wgpu::TextureViewDescriptor {
297 dimension: Some(wgpu::TextureViewDimension::Cube),
298 ..Default::default()
299 });
300 Some(Self {
301 texture,
302 view,
303 size: source.size,
304 format: source.format,
305 ctx: GpuContext::from_backend(backend),
306 })
307 }
308}
309
310crate::wgpu::plugin_macros::mipmap_asset_plugin! {
311 /// Registers the [`GPUCubemap`] asset pipeline (`Assets<Cubemap>`
312 /// → `ProcessedAssets<GPUCubemap>`), plus the [`MipmapGenerator`] it
313 /// depends on for `generate_mips`. Included by
314 /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if you're
315 /// assembling the `wgpu` module's plugins by hand.
316 CubemapPlugin, GPUCubemap
317}