Skip to main content

gpui/
image_texture.rs

1//! Validated immutable upload data for opt-in source-sized image textures.
2use anyhow::{Result, ensure};
3use std::sync::Arc;
4
5/// Storage and color contract for source-sized image textures.
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum ImageTextureFormat {
8    /// Straight alpha, encoded sRGB.
9    Rgba8Srgb,
10    /// Premultiplied, bounded linear Display P3 / D65, little-endian binary16.
11    LinearP3Rgba16Float,
12    /// Premultiplied, bounded linear Rec.2020 / D65 SDR, little-endian binary16.
13    LinearRec2020Rgba16Float,
14}
15
16/// Full mip chain with a validated storage, color and alpha contract.
17/// This is an upload description, not a device resource. Backends retain normal
18/// scene ordering and coverage when sampling it at a different display size.
19#[derive(Clone, Debug)]
20pub struct ImageTextureData {
21    width: u32,
22    height: u32,
23    levels: Arc<[Vec<u8>]>,
24    byte_len: usize,
25    format: ImageTextureFormat,
26}
27impl ImageTextureData {
28    /// Maximum combined mip bytes for one source (64 MiB).
29    pub const MAX_BYTES: usize = 64 * 1024 * 1024;
30    /// Validate a complete mip chain, including odd and one-dimensional sizes.
31    /// Every level halves each dimension, rounding down and clamping to one.
32    pub fn new(width: u32, height: u32, levels: Vec<Vec<u8>>) -> Result<Self> {
33        Self::with_format(width, height, levels, ImageTextureFormat::Rgba8Srgb, false)
34    }
35    /// Validate a complete bounded premultiplied linear-P3 RGBA16F mip chain.
36    /// Rejects non-finite, negative, out-of-range and invalid associated pixels.
37    pub fn new_linear_p3_rgba16f(width: u32, height: u32, levels: Vec<Vec<u8>>) -> Result<Self> {
38        Self::with_format(
39            width,
40            height,
41            levels,
42            ImageTextureFormat::LinearP3Rgba16Float,
43            false,
44        )
45    }
46    /// Validate a complete bounded premultiplied linear Rec.2020 SDR mip chain.
47    /// Native wide placement converts to extended composition primaries.
48    pub fn new_linear_rec2020_rgba16f(
49        width: u32,
50        height: u32,
51        levels: Vec<Vec<u8>>,
52    ) -> Result<Self> {
53        Self::with_format(
54            width,
55            height,
56            levels,
57            ImageTextureFormat::LinearRec2020Rgba16Float,
58            false,
59        )
60    }
61    /// A single immutable mip page; the canvas selects the pyramid level.
62    pub fn page(
63        width: u32,
64        height: u32,
65        bytes: Vec<u8>,
66        format: ImageTextureFormat,
67    ) -> Result<Self> {
68        Self::with_format(width, height, vec![bytes], format, true)
69    }
70    fn with_format(
71        width: u32,
72        height: u32,
73        levels: Vec<Vec<u8>>,
74        format: ImageTextureFormat,
75        page: bool,
76    ) -> Result<Self> {
77        let bpp = if format == ImageTextureFormat::Rgba8Srgb {
78            4
79        } else {
80            8
81        };
82        ensure!(
83            width > 0 && height > 0 && width <= 16384 && height <= 16384,
84            "invalid image texture dimensions"
85        );
86        let count = (32 - width.max(height).leading_zeros()) as usize;
87        ensure!(
88            levels.len() == if page { 1 } else { count },
89            "image requires a complete mip chain or an explicit single-level page"
90        );
91        let mut byte_len = 0usize;
92        for (level, bytes) in levels.iter().enumerate() {
93            let w = (width >> level).max(1) as usize;
94            let h = (height >> level).max(1) as usize;
95            ensure!(bytes.len() == w * h * bpp, "invalid image mip byte count");
96            if bpp == 8 {
97                for pixel in bytes.chunks_exact(8) {
98                    let c: [u16; 4] = std::array::from_fn(|i| {
99                        u16::from_le_bytes([pixel[i * 2], pixel[i * 2 + 1]])
100                    });
101                    ensure!(
102                        c[3] <= 0x3c00 && c[..3].iter().all(|v| *v <= c[3]),
103                        "RGBA16F requires bounded premultiplied linear RGB"
104                    );
105                }
106            }
107            byte_len += bytes.len();
108            ensure!(byte_len <= Self::MAX_BYTES, "image texture exceeds 64 MiB");
109        }
110        Ok(Self {
111            width,
112            height,
113            levels: levels.into(),
114            byte_len,
115            format,
116        })
117    }
118    /// Validated source storage/color contract.
119    pub fn format(&self) -> ImageTextureFormat {
120        self.format
121    }
122    /// Unpadded bytes per source pixel.
123    pub fn bytes_per_pixel(&self) -> usize {
124        if self.format == ImageTextureFormat::Rgba8Srgb {
125            4
126        } else {
127            8
128        }
129    }
130    /// Source width in physical pixels.
131    pub fn width(&self) -> u32 {
132        self.width
133    }
134    /// Source height in physical pixels.
135    pub fn height(&self) -> u32 {
136        self.height
137    }
138    /// Complete immutable mip chain, largest level first.
139    pub fn levels(&self) -> &[Vec<u8>] {
140        &self.levels
141    }
142    /// Bytes in a 256-byte-row-aligned staging buffer for the complete chain.
143    pub fn staging_byte_len(&self) -> usize {
144        (0..self.levels.len())
145            .map(|level| {
146                let row = (((self.width >> level).max(1) as usize) * self.bytes_per_pixel())
147                    .div_ceil(256)
148                    * 256;
149                row * (self.height >> level).max(1) as usize
150            })
151            .sum()
152    }
153    /// Combined unpadded upload and GPU payload size.
154    pub fn byte_len(&self) -> usize {
155        self.byte_len
156    }
157}
158#[cfg(test)]
159mod tests {
160    use super::*;
161    #[test]
162    fn validates_full_odd_and_thin_chains() {
163        assert!(ImageTextureData::new(3, 1, vec![vec![0; 12], vec![0; 4]]).is_ok());
164        assert!(ImageTextureData::new(1, 4, vec![vec![0; 16], vec![0; 8], vec![0; 4]]).is_ok());
165        assert!(ImageTextureData::new(3, 1, vec![vec![0; 12]]).is_err());
166        assert!(ImageTextureData::new(0, 1, vec![]).is_err());
167        assert!(ImageTextureData::new(1, 1, vec![vec![0; 3]]).is_err());
168    }
169}
170
171/// Shared admission counters for one scene preparation. Old custom producers
172/// ignore this optional context; image producers can share loader-specific limits.
173#[derive(Default)]
174pub struct PreparationFrame {
175    uploads: std::sync::Mutex<std::collections::HashMap<u64, (usize, usize)>>,
176}
177impl PreparationFrame {
178    /// Charge one source handoff. The key identifies a retained policy owner.
179    /// A rejected reservation changes no counters. Already-retained outputs do
180    /// not need a reservation; limits apply only to newly recorded image copies.
181    pub fn try_reserve_upload(
182        &self,
183        key: u64,
184        bytes: usize,
185        max_bytes: usize,
186        max_uploads: usize,
187    ) -> bool {
188        let mut uploads = self.uploads.lock().unwrap();
189        let usage = uploads.entry(key).or_default();
190        if usage.1 >= max_uploads || bytes > max_bytes.saturating_sub(usage.0) {
191            return false;
192        }
193        usage.0 += bytes;
194        usage.1 += 1;
195        true
196    }
197}
198
199/// Resource-only cache statistics. All allocated resources, including idle and
200/// GPU-borrowed ones, count against capacity until evicted or the pool is dropped.
201#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
202pub struct ImageStagingPoolStats {
203    /// Capacity currently owned by the pool and its leases.
204    pub allocated_bytes: usize,
205    /// Bytes currently idle and eligible for reuse.
206    pub cached_bytes: usize,
207    /// Successful backend resource allocations.
208    pub allocations: usize,
209    /// Successful checkouts of a previously idle resource.
210    pub reuses: usize,
211}
212struct PoolState<T> {
213    idle: Vec<(u64, usize, T)>,
214    stats: ImageStagingPoolStats,
215}
216/// Backend SPI for bounded reusable staging resources. A lease may return to
217/// this cache only after the host's completion guard has released its last Arc.
218/// Backends own resource creation, mapping, synchronization and device identity.
219pub struct ImageStagingPool<T> {
220    capacity: usize,
221    state: std::sync::Mutex<PoolState<T>>,
222}
223/// Exclusive staging ownership; sharing this lease delays reuse until all
224/// completion guards and staging snapshots have released it.
225pub struct ImageStagingLease<T> {
226    resource: Option<T>,
227    reused: bool,
228    pool: std::sync::Weak<ImageStagingPool<T>>,
229    key: u64,
230    bytes: usize,
231}
232struct AllocationReservation<'a, T> {
233    pool: &'a ImageStagingPool<T>,
234    bytes: usize,
235    committed: bool,
236}
237impl<T> Drop for AllocationReservation<'_, T> {
238    fn drop(&mut self) {
239        if !self.committed {
240            self.pool.state.lock().unwrap().stats.allocated_bytes -= self.bytes;
241        }
242    }
243}
244impl<T> ImageStagingPool<T> {
245    /// Create an empty cache with a hard resource-byte capacity.
246    pub fn new(capacity: usize) -> Arc<Self> {
247        Arc::new(Self {
248            capacity,
249            state: std::sync::Mutex::new(PoolState {
250                idle: vec![],
251                stats: ImageStagingPoolStats::default(),
252            }),
253        })
254    }
255    /// Allocate outside the pool lock. Idle mismatches are evicted to make room;
256    /// borrowed resources are never evicted or reused. Errors roll back capacity.
257    pub fn checkout(
258        self: &Arc<Self>,
259        key: u64,
260        bytes: usize,
261        create: impl FnOnce() -> Result<T>,
262    ) -> Result<ImageStagingLease<T>> {
263        ensure!(
264            bytes > 0 && bytes <= self.capacity,
265            "invalid staging resource size"
266        );
267        let mut state = self.state.lock().unwrap();
268        if let Some(index) = state
269            .idle
270            .iter()
271            .position(|(k, b, _)| *k == key && *b == bytes)
272        {
273            let (_, _, resource) = state.idle.swap_remove(index);
274            state.stats.cached_bytes -= bytes;
275            state.stats.reuses += 1;
276            return Ok(ImageStagingLease {
277                resource: Some(resource),
278                reused: true,
279                pool: Arc::downgrade(self),
280                key,
281                bytes,
282            });
283        }
284        ensure!(
285            bytes <= self.capacity,
286            "staging resource exceeds pool capacity"
287        );
288        while bytes > self.capacity.saturating_sub(state.stats.allocated_bytes) {
289            let Some((_, evicted, resource)) = state.idle.pop() else {
290                anyhow::bail!("staging pool is busy");
291            };
292            state.stats.allocated_bytes -= evicted;
293            state.stats.cached_bytes -= evicted;
294            drop(resource);
295        }
296        state.stats.allocated_bytes += bytes;
297        drop(state);
298        let mut reservation = AllocationReservation {
299            pool: self,
300            bytes,
301            committed: false,
302        };
303        let resource = create()?;
304        self.state.lock().unwrap().stats.allocations += 1;
305        reservation.committed = true;
306        Ok(ImageStagingLease {
307            resource: Some(resource),
308            reused: false,
309            pool: Arc::downgrade(self),
310            key,
311            bytes,
312        })
313    }
314
315    /// Read allocation and idle-cache counters.
316    pub fn stats(&self) -> ImageStagingPoolStats {
317        self.state.lock().unwrap().stats
318    }
319}
320impl<T> ImageStagingLease<T> {
321    /// Whether this checkout reuses a resource instead of allocating one.
322    pub fn was_reused(&self) -> bool {
323        self.reused
324    }
325    /// Access the backend resource while this exclusive lease is held.
326    pub fn resource(&self) -> &T {
327        self.resource.as_ref().unwrap()
328    }
329    /// Discard a mapping/driver failure instead of returning a resource whose
330    /// state is no longer suitable for reuse.
331    pub fn discard(mut self) {
332        self.resource.take();
333        if let Some(pool) = self.pool.upgrade() {
334            pool.state.lock().unwrap().stats.allocated_bytes -= self.bytes;
335        }
336    }
337}
338impl<T> Drop for ImageStagingLease<T> {
339    fn drop(&mut self) {
340        if let Some(resource) = self.resource.take()
341            && let Some(pool) = self.pool.upgrade()
342        {
343            let mut state = pool.state.lock().unwrap();
344            state.stats.cached_bytes += self.bytes;
345            state.idle.push((self.key, self.bytes, resource));
346        }
347    }
348}
349
350#[cfg(test)]
351mod resource_tests {
352    use super::*;
353    #[test]
354    fn frame_admission_shares_counts_and_bytes_without_charging_rejection() {
355        let frame = PreparationFrame::default();
356        assert!(frame.try_reserve_upload(1, 6, 10, 2));
357        assert!(!frame.try_reserve_upload(1, 5, 10, 2));
358        assert!(frame.try_reserve_upload(1, 4, 10, 2));
359        assert!(!frame.try_reserve_upload(1, 0, 10, 2));
360        assert!(frame.try_reserve_upload(2, 10, 10, 1));
361        assert!(PreparationFrame::default().try_reserve_upload(1, 10, 10, 1));
362    }
363    #[test]
364    fn pool_honors_outstanding_leases_eviction_and_failed_allocations() -> Result<()> {
365        let pool = ImageStagingPool::new(16);
366        let lease = Arc::new(pool.checkout(1, 16, || Ok(vec![7; 16]))?);
367        let guard = lease.clone();
368        drop(lease);
369        assert!(pool.checkout(1, 16, || Ok(vec![0; 16])).is_err());
370        drop(guard);
371        let reused = pool.checkout(1, 16, || panic!("must reuse"))?;
372        assert!(reused.was_reused());
373        assert_eq!(reused.resource(), &vec![7; 16]);
374        drop(reused);
375        let failure: Result<ImageStagingLease<Vec<u8>>> =
376            pool.checkout(2, 8, || anyhow::bail!("allocation failed"));
377        assert!(failure.is_err());
378        assert_eq!(pool.stats().allocated_bytes, 0);
379        let discarded = pool.checkout(3, 8, || Ok(vec![3; 8]))?;
380        discarded.discard();
381        assert_eq!(pool.stats().allocated_bytes, 0);
382        assert_eq!(pool.stats().cached_bytes, 0);
383        assert_eq!(pool.stats().reuses, 1);
384        let panic = std::panic::catch_unwind(|| {
385            let _ = pool.checkout(4, 8, || -> Result<Vec<u8>> {
386                panic!("driver allocation panic")
387            });
388        });
389        assert!(panic.is_err());
390        assert_eq!(pool.stats().allocated_bytes, 0);
391        Ok(())
392    }
393}
394
395/// Opt-in linear-sRGB image sampling parameters. Only bounded encoded-sRGB
396/// sources are supported here; this is not a wide/HDR texture descriptor.
397/// RGB rows multiply decoded [R,G,B,1], then vignette darkens, then RGB clips
398/// and encodes to SDR sRGB. Alpha is preserved before native coverage/opacity.
399#[derive(Clone, Copy, Debug, PartialEq)]
400pub struct LinearImagePaint {
401    rows: [[f32; 4]; 3],
402    vignette: [f32; 4],
403    p3: bool,
404}
405impl LinearImagePaint {
406    /// Vignette = intensity, inner radius, positive feather, roundness. Radius
407    /// units refer to the shortest source edge; roundness 0 follows its ellipse.
408    /// Reject coefficients too large for finite f32 sampling arithmetic.
409    pub fn new(rows: [[f32; 4]; 3], vignette: [f32; 4]) -> Result<Self> {
410        ensure!(
411            rows.iter().all(|row| row.iter().all(|v| v.is_finite())
412                && row.iter().map(|v| f64::from(v.abs())).sum::<f64>() <= f64::from(f32::MAX) / 4.),
413            "image matrix exceeds f32 sampling range"
414        );
415        let [intensity, radius, feather, roundness] = vignette;
416        ensure!(
417            vignette.iter().all(|v| v.is_finite())
418                && (0.0..=1.0).contains(&intensity)
419                && radius >= 0.
420                && feather >= f32::MIN_POSITIVE
421                && (0.0..=1.0).contains(&roundness),
422            "invalid image vignette"
423        );
424        Ok(Self {
425            rows,
426            vignette,
427            p3: false,
428        })
429    }
430    /// Select linear Display P3 working primaries.
431    pub fn with_p3(mut self, p3: bool) -> Self {
432        self.p3 = p3;
433        self
434    }
435    /// Whether rows operate in linear Display P3.
436    pub fn is_p3(self) -> bool {
437        self.p3
438    }
439    /// Affine rows evaluated in the selected linear working primaries.
440    pub fn rows(self) -> [[f32; 4]; 3] {
441        self.rows
442    }
443    /// Intensity, inner radius, feather and roundness in source-image units.
444    pub fn vignette(self) -> [f32; 4] {
445        self.vignette
446    }
447}
448
449/// Source interpretation and requested presentation. Wide requires an accepted
450/// native wide-SDR window; browser/wgpu hosts must reject it.
451#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
452pub enum ImageColorMode {
453    #[default]
454    /// Straight encoded-sRGB input.
455    EncodedSrgb,
456    /// Associated linear-P3 input mapped to clipped sRGB.
457    LinearP3Preview,
458    /// Associated linear-P3 input presented by the native wide-SDR compositor.
459    LinearP3Wide,
460    /// Associated linear Rec.2020 SDR input mapped to clipped sRGB.
461    LinearRec2020Preview,
462    /// Associated linear Rec.2020 SDR input converted to native extended composition.
463    LinearRec2020Wide,
464}
465impl ImageColorMode {
466    /// Stable renderer uniform value.
467    pub fn shader_value(self) -> f32 {
468        match self {
469            Self::EncodedSrgb => 0.,
470            Self::LinearP3Preview => 1.,
471            Self::LinearP3Wide => 2.,
472            Self::LinearRec2020Preview => 3.,
473            Self::LinearRec2020Wide => 4.,
474        }
475    }
476}