Skip to main content

nvidia_omm/
lib.rs

1#[cfg(any(nvidia_omm_native, test))]
2mod ffi;
3
4#[cfg(nvidia_omm_native)]
5mod native;
6
7#[cfg(not(nvidia_omm_native))]
8mod stub;
9
10#[cfg(nvidia_omm_native)]
11pub use native::{AlphaTexture, Baker};
12
13#[cfg(not(nvidia_omm_native))]
14pub use stub::{AlphaTexture, Baker};
15
16#[cfg(any(nvidia_omm_native, test))]
17use anyhow::{Context as _, Result, ensure};
18
19pub const ALPHA_CUTOFF: f32 = 0.5;
20pub const FOUR_STATE_FORMAT: u16 = 2;
21pub const SDK_VERSION: &str = "1.9.2";
22
23#[derive(Clone, Copy, Debug)]
24pub struct AlphaMip<'a> {
25    /// Must not exceed the preceding mip's width (equal dimensions are allowed).
26    pub width: u32,
27    /// Must not exceed the preceding mip's height (equal dimensions are allowed).
28    pub height: u32,
29    /// Bytes between row starts; zero means `width`. Final-row padding is optional.
30    pub row_pitch: u32,
31    pub data: &'a [u8],
32}
33
34#[cfg(any(nvidia_omm_native, test))]
35impl AlphaMip<'_> {
36    fn validate_all(mips: &[Self]) -> Result<()> {
37        ensure!(
38            !mips.is_empty(),
39            "nvidia omm texture requires at least one mip"
40        );
41        ensure!(
42            mips.windows(2)
43                .all(|pair| pair[1].width <= pair[0].width && pair[1].height <= pair[0].height),
44            "nvidia omm mip dimensions must be non-increasing"
45        );
46
47        for mip in mips {
48            ensure!(
49                mip.width > 0 && mip.height > 0,
50                "nvidia omm mip extent must be nonzero"
51            );
52
53            let row_pitch = if mip.row_pitch == 0 {
54                mip.width
55            } else {
56                mip.row_pitch
57            };
58
59            ensure!(
60                row_pitch >= mip.width,
61                "nvidia omm mip row pitch is smaller than its width"
62            );
63
64            let pitch = usize::try_from(row_pitch).context("nvidia omm row pitch overflow")?;
65            let preceding_rows =
66                usize::try_from(mip.height - 1).context("nvidia omm mip height overflow")?;
67            let width = usize::try_from(mip.width).context("nvidia omm mip width overflow")?;
68            let required = preceding_rows
69                .checked_mul(pitch)
70                .and_then(|offset| offset.checked_add(width))
71                .context("nvidia omm mip byte size overflow")?;
72
73            ensure!(
74                mip.data.len() >= required,
75                "nvidia omm mip data is truncated"
76            );
77        }
78
79        Ok(())
80    }
81}
82
83#[derive(Clone, Copy, Debug, PartialEq)]
84pub struct BakeConfig {
85    /// Must be positive with a finite, normal `f32` square. Baking also rejects
86    /// geometry whose derived subdivision arithmetic is unsafe in OMM 1.9.2.
87    pub dynamic_subdivision_scale: f32,
88    pub rejection_threshold: f32,
89    pub max_subdivision_level: u8,
90    pub max_array_data_size: u32,
91    pub max_workload_size: u64,
92    pub internal_threads: bool,
93    pub validation: bool,
94}
95
96impl Default for BakeConfig {
97    fn default() -> Self {
98        Self {
99            dynamic_subdivision_scale: 2.0,
100            rejection_threshold: 0.0,
101            max_subdivision_level: 8,
102            max_array_data_size: u32::MAX,
103            max_workload_size: u64::MAX,
104            internal_threads: true,
105            validation: cfg!(debug_assertions),
106        }
107    }
108}
109
110#[derive(Clone, Copy, Debug)]
111pub struct BakeInput<'a> {
112    /// Finite UVs, including wrapped coordinates outside [0, 1]. Baking
113    /// conservatively validates both SDK subdivision heuristics and requires
114    /// referenced mip-0 texel coordinates to have magnitude below 2^30.
115    /// Texel bounding box products, with raster headroom, must fit in `i32`.
116    pub texture_coordinates: &'a [[f32; 2]],
117    pub indices: &'a [u32],
118    pub config: BakeConfig,
119}
120
121#[cfg(any(nvidia_omm_native, test))]
122impl BakeInput<'_> {
123    fn validate(self, texture_size: [u32; 2]) -> Result<()> {
124        ensure!(
125            !self.indices.is_empty() && self.indices.len().is_multiple_of(3),
126            "nvidia omm indices must contain complete triangles"
127        );
128        ensure!(
129            !self.texture_coordinates.is_empty(),
130            "nvidia omm texture coordinates must not be empty"
131        );
132        ensure!(
133            self.indices
134                .iter()
135                .all(|&index| (index as usize) < self.texture_coordinates.len()),
136            "nvidia omm index exceeds the texture-coordinate count"
137        );
138        ensure!(
139            self.config.dynamic_subdivision_scale.is_finite()
140                && self.config.dynamic_subdivision_scale > 0.0,
141            "nvidia omm dynamic subdivision scale must be finite and positive"
142        );
143        ensure!(
144            (0.0..=1.0).contains(&self.config.rejection_threshold),
145            "nvidia omm rejection threshold must be in [0, 1]"
146        );
147        ensure!(
148            self.config.max_subdivision_level <= 12,
149            "nvidia omm subdivision level exceeds 12"
150        );
151
152        // OMM 1.9.2 bake_cpu_impl.cpp ComputeAreaHeuristic casts the ratio to
153        // uint32_t BEFORE clamping. Use f32 throughout, including length(cross)
154        // (not abs(cross)), so validation catches the SDK's intermediate overflow.
155        let scale = self.config.dynamic_subdivision_scale;
156        let target_area = scale * scale;
157
158        ensure!(
159            target_area.is_normal(),
160            "nvidia omm squared subdivision scale must be finite and normal"
161        );
162
163        #[expect(
164            clippy::cast_precision_loss,
165            reason = "match the SDK's uint2 to float2 conversion"
166        )]
167        let size = texture_size.map(|value| value as f32);
168
169        for uv in self.texture_coordinates {
170            ensure!(
171                uv.iter().all(|value| value.is_finite()),
172                "nvidia omm texture coordinates must be finite"
173            );
174        }
175
176        for indices in self.indices.chunks_exact(3) {
177            let uv = [indices[0], indices[1], indices[2]]
178                .map(|index| self.texture_coordinates[index as usize]);
179            let p = uv.map(|uv| [uv[0] * size[0], uv[1] * size[1]]);
180            let a = [p[2][0] - p[0][0], p[2][1] - p[0][1]];
181            let b = [p[1][0] - p[0][0], p[1][1] - p[0][1]];
182            let cross = a[0] * b[1] - a[1] * b[0];
183            let pixel_area = 0.5 * (cross * cross).sqrt();
184            let ratio = pixel_area / target_area;
185
186            ensure!(
187                ratio.is_finite() && (0.0..4_294_967_296.0).contains(&ratio),
188                "nvidia omm dynamic subdivision area ratio is outside uint32 range"
189            );
190
191            // Degenerate triangles use ComputeEdgeHeuristic instead. Validate
192            // both paths rather than depend on the SDK's degeneracy rounding.
193            for (start, end) in [(0, 1), (0, 2), (1, 2)] {
194                let edge = [
195                    (uv[end][0] - uv[start][0]) * size[0],
196                    (uv[end][1] - uv[start][1]) * size[1],
197                ];
198                let length_squared = edge[0] * edge[0] + edge[1] * edge[1];
199
200                ensure!(
201                    length_squared.is_finite(),
202                    "nvidia omm dynamic subdivision edge length must be finite"
203                );
204
205                // Finite f32 lengths and a positive normal squared scale bound
206                // log2(length_squared)/2 - log2(scale) well inside int32 range.
207            }
208
209            // Even a zero-area triangle can have huge finite coordinates. The
210            // SDK later casts UVs/texel positions to int32; leave headroom for
211            // interpolation and pixel offsets rather than letting it reach UB.
212            ensure!(
213                p.iter()
214                    .flatten()
215                    .all(|value| value.abs() < 1_073_741_824.0),
216                "nvidia omm texture coordinates exceed the safe raster range"
217            );
218
219            // ComputeWorkloadSize multiplies int32 AABB dimensions BEFORE its
220            // uint64 cast. Match its f32 subtract-then-scale rounding, also bound
221            // raster scale-then-subtract, and leave room for floor/ceil, the
222            // half-texel offset and inclusive pixel endpoints. Use f64 for the
223            // padded product so i32::MAX is not rounded up to 2^31.
224            let extent = [0, 1].map(|axis| {
225                let min_uv = uv[0][axis].min(uv[1][axis]).min(uv[2][axis]);
226                let max_uv = uv[0][axis].max(uv[1][axis]).max(uv[2][axis]);
227                let workload_extent = (max_uv - min_uv) * size[axis];
228                let min_p = p[0][axis].min(p[1][axis]).min(p[2][axis]);
229                let max_p = p[0][axis].max(p[1][axis]).max(p[2][axis]);
230                f64::from(workload_extent)
231                    .max(f64::from(max_p) - f64::from(min_p))
232                    .ceil()
233                    + 4.0
234            });
235
236            ensure!(
237                extent[0] * extent[1] <= f64::from(i32::MAX),
238                "nvidia omm texel bounding box product exceeds the safe int32 range"
239            );
240        }
241
242        Ok(())
243    }
244}
245
246#[derive(Clone, Debug, PartialEq)]
247pub struct BakeOutput {
248    pub array_data: Box<[u8]>,
249    pub descriptors: Box<[OpacityMicromapDescriptor]>,
250    pub descriptor_usage: Box<[OpacityMicromapUsage]>,
251    pub indices: Box<[i32]>,
252    pub index_usage: Box<[OpacityMicromapUsage]>,
253    pub statistics: Statistics,
254}
255
256#[cfg(nvidia_omm_native)]
257impl BakeOutput {
258    unsafe fn copy<T: Copy>(pointer: *const T, count: u32) -> Result<Box<[T]>> {
259        if count == 0 {
260            return Ok(Box::new([]));
261        }
262
263        ensure!(
264            !pointer.is_null(),
265            "nvidia omm returned a null output pointer"
266        );
267
268        Ok(unsafe { std::slice::from_raw_parts(pointer, count as usize) }.into())
269    }
270
271    unsafe fn from_native(output: ffi::BakeResult) -> Result<Self> {
272        Ok(Self {
273            array_data: unsafe { Self::copy(output.array_data, output.array_data_size) }?,
274            descriptors: unsafe { Self::copy(output.descriptors, output.descriptor_count) }?,
275            descriptor_usage: unsafe {
276                Self::copy(output.descriptor_usage, output.descriptor_usage_count)
277            }?,
278            indices: unsafe { Self::copy(output.indices, output.index_count) }?,
279            index_usage: unsafe { Self::copy(output.index_usage, output.index_usage_count) }?,
280            statistics: output.statistics,
281        })
282    }
283}
284
285impl Baker {
286    /// Returns the bundled native backend path, when available.
287    #[must_use]
288    pub fn bundled_backend_path() -> Option<&'static std::path::Path> {
289        option_env!("NVIDIA_OMM_BACKEND").map(std::path::Path::new)
290    }
291
292    /// Reports whether this build includes the native backend.
293    #[must_use]
294    pub const fn native_backend_available() -> bool {
295        cfg!(nvidia_omm_native)
296    }
297}
298
299#[derive(Clone, Copy, Debug, Eq, PartialEq)]
300#[repr(C)]
301pub struct OpacityMicromapDescriptor {
302    pub data_offset: u32,
303    pub subdivision_level: u16,
304    pub format: u16,
305}
306
307#[derive(Clone, Copy, Debug, Eq, PartialEq)]
308#[repr(C)]
309pub struct OpacityMicromapUsage {
310    pub count: u32,
311    pub subdivision_level: u16,
312    pub format: u16,
313}
314
315#[derive(Clone, Copy, Debug, Default, PartialEq)]
316#[repr(C)]
317pub struct Statistics {
318    pub opaque: u64,
319    pub transparent: u64,
320    pub unknown_transparent: u64,
321    pub unknown_opaque: u64,
322    pub fully_opaque: u32,
323    pub fully_transparent: u32,
324    pub fully_unknown_opaque: u32,
325    pub fully_unknown_transparent: u32,
326    pub known_area: f32,
327}
328
329#[cfg(test)]
330mod test {
331    use {
332        super::*,
333        std::mem::{align_of, offset_of, size_of},
334    };
335
336    #[test]
337    fn invalid_geometry_is_rejected() {
338        let texture_coordinates = [[0.0, 0.0]; 3];
339
340        assert!(
341            BakeInput {
342                texture_coordinates: &texture_coordinates,
343                indices: &[0, 1],
344                config: BakeConfig::default(),
345            }
346            .validate([2, 2])
347            .is_err()
348        );
349    }
350
351    #[test]
352    fn invalid_texture_is_rejected() {
353        assert!(
354            AlphaMip::validate_all(&[AlphaMip {
355                width: 2,
356                height: 2,
357                row_pitch: 1,
358                data: &[0; 4],
359            }])
360            .is_err()
361        );
362    }
363
364    #[test]
365    fn mip_data_only_requires_pixels_in_the_final_row() {
366        #[cfg(nvidia_omm_native)]
367        let baker = Baker::new(Baker::bundled_backend_path().unwrap()).unwrap();
368
369        for (width, height, row_pitch, required) in [
370            (2, 2, 4, 6),
371            (2, 1, u32::MAX, 2),
372            (2, 2, 0, 4),
373            (2, 2, 2, 4),
374        ] {
375            let data = vec![0; required];
376            let mip = AlphaMip {
377                width,
378                height,
379                row_pitch,
380                data: &data,
381            };
382            AlphaMip::validate_all(&[mip]).unwrap();
383
384            #[cfg(nvidia_omm_native)]
385            {
386                let texture = baker.create_texture(&[mip]).unwrap();
387                let output = baker
388                    .bake(
389                        &texture,
390                        BakeInput {
391                            texture_coordinates: &[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
392                            indices: &[0, 1, 2],
393                            config: BakeConfig::default(),
394                        },
395                    )
396                    .unwrap();
397                assert_eq!(output.indices.as_ref(), &[-1]);
398            }
399
400            let truncated = AlphaMip {
401                data: &data[..required - 1],
402                ..mip
403            };
404            assert_eq!(
405                AlphaMip::validate_all(&[truncated])
406                    .unwrap_err()
407                    .to_string(),
408                "nvidia omm mip data is truncated"
409            );
410            #[cfg(nvidia_omm_native)]
411            assert!(baker.create_texture(&[truncated]).is_err());
412        }
413    }
414
415    #[test]
416    fn oversized_mip_data_is_rejected_without_wrapping() {
417        let error = AlphaMip::validate_all(&[AlphaMip {
418            width: u32::MAX,
419            height: u32::MAX,
420            row_pitch: u32::MAX,
421            data: &[],
422        }])
423        .unwrap_err();
424
425        if usize::BITS >= 64 {
426            assert_eq!(error.to_string(), "nvidia omm mip data is truncated");
427        } else {
428            assert_eq!(error.to_string(), "nvidia omm mip byte size overflow");
429        }
430    }
431
432    #[test]
433    fn mip_dimensions_must_be_non_increasing() {
434        #[cfg(nvidia_omm_native)]
435        let baker = Baker::new(Baker::bundled_backend_path().unwrap()).unwrap();
436
437        for dimensions in [
438            [[2, 2], [4, 2], [1, 1]],
439            [[2, 2], [2, 4], [1, 1]],
440            [[4, 4], [1, 2], [2, 1]],
441            [[4, 4], [2, 1], [1, 2]],
442        ] {
443            let mips = dimensions.map(|[width, height]| AlphaMip {
444                width,
445                height,
446                row_pitch: 0,
447                data: &[0; 16],
448            });
449            let error = AlphaMip::validate_all(&mips).unwrap_err().to_string();
450
451            assert_eq!(error, "nvidia omm mip dimensions must be non-increasing");
452
453            #[cfg(nvidia_omm_native)]
454            assert_eq!(
455                baker.create_texture(&mips).err().unwrap().to_string(),
456                error
457            );
458        }
459
460        for dimensions in [
461            [[4, 4], [2, 2], [1, 1]],
462            [[4, 4], [4, 4], [4, 4]],
463            [[4, 3], [3, 3], [3, 1]],
464        ] {
465            let mips = dimensions.map(|[width, height]| AlphaMip {
466                width,
467                height,
468                row_pitch: 0,
469                data: &[0; 16],
470            });
471            AlphaMip::validate_all(&mips).unwrap();
472        }
473    }
474
475    #[test]
476    fn unsafe_texel_bounding_box_products_are_rejected() {
477        #[cfg(nvidia_omm_native)]
478        let baker = Baker::new(Baker::bundled_backend_path().unwrap()).unwrap();
479
480        #[cfg(nvidia_omm_native)]
481        let texture = baker
482            .create_texture(&[AlphaMip {
483                width: 2,
484                height: 2,
485                row_pitch: 0,
486                data: &[0; 4],
487            }])
488            .unwrap();
489
490        for uv in [
491            [[0.0, 0.0], [32768.0, 32768.0], [16384.0, 16384.0]],
492            [[-16384.0, -16384.0], [16384.0, 16384.0], [0.0, 0.0]],
493            // The unpadded product fits, but raster rounding/headroom does not.
494            [[0.0, 0.0], [23169.0, 23169.0], [0.0, 0.0]],
495            [[0.0, 0.0], [23168.25, 23168.25], [0.0, 0.0]],
496        ] {
497            for validation in [false, true] {
498                let input = BakeInput {
499                    texture_coordinates: &uv,
500                    indices: &[0, 1, 2],
501                    config: BakeConfig {
502                        dynamic_subdivision_scale: 2.0,
503                        max_subdivision_level: 0,
504                        max_workload_size: 1,
505                        validation,
506                        ..BakeConfig::default()
507                    },
508                };
509                let error = input.validate([2, 2]).unwrap_err().to_string();
510
511                assert_eq!(
512                    error,
513                    "nvidia omm texel bounding box product exceeds the safe int32 range"
514                );
515
516                #[cfg(nvidia_omm_native)]
517                assert_eq!(baker.bake(&texture, input).unwrap_err().to_string(), error);
518            }
519        }
520
521        for end in [[23168.0, 23168.0], [50000.0, 1.0]] {
522            BakeInput {
523                texture_coordinates: &[[0.0, 0.0], end, [0.0, 0.0]],
524                indices: &[0, 1, 2],
525                config: BakeConfig::default(),
526            }
527            .validate([2, 2])
528            .unwrap();
529        }
530    }
531
532    #[test]
533    fn unsafe_subdivision_arithmetic_is_rejected() {
534        let triangle = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
535
536        #[cfg(nvidia_omm_native)]
537        let baker = Baker::new(Baker::bundled_backend_path().unwrap()).unwrap();
538
539        #[cfg(nvidia_omm_native)]
540        let texture = baker
541            .create_texture(&[AlphaMip {
542                width: 2,
543                height: 2,
544                row_pitch: 0,
545                data: &[0; 4],
546            }])
547            .unwrap();
548
549        for (name, uv, scale) in [
550            ("squared scale underflow", triangle, 1e-30),
551            ("subnormal squared scale", triangle, 1e-20),
552            ("squared scale overflow", triangle, f32::MAX),
553            (
554                "finite ratio beyond uint32 limit",
555                triangle,
556                2.0_f32.powi(-16),
557            ),
558            (
559                "finite ratio at uint32 limit",
560                [triangle[0], triangle[1], [0.0, 0.5]],
561                2.0_f32.powi(-16),
562            ),
563            (
564                "ratio overflow",
565                [[0.0, 0.0], [2.0, 0.0], [0.0, 2.0]],
566                1.1e-19,
567            ),
568            ("zero scale", triangle, 0.0),
569            ("negative scale", triangle, -1.0),
570            ("nan scale", triangle, f32::NAN),
571            ("infinite scale", triangle, f32::INFINITY),
572            ("nan uv", [[f32::NAN, 0.0], triangle[1], triangle[2]], 2.0),
573            (
574                "infinite uv",
575                [[0.0, f32::INFINITY], triangle[1], triangle[2]],
576                2.0,
577            ),
578            ("negative infinite uv", [[f32::NEG_INFINITY, 0.0]; 3], 2.0),
579            ("texel overflow", [[f32::MAX, 0.0]; 3], 2.0),
580            (
581                "edge overflow",
582                [[-1e20, 0.0], [1e20, 0.0], [0.0, 0.0]],
583                2.0,
584            ),
585            (
586                "cross squared overflow",
587                [[0.0, 0.0], [1e10, 0.0], [0.0, 1e10]],
588                1e10,
589            ),
590            ("huge translated point", [[1e30, 1e30]; 3], 2.0),
591        ] {
592            // Neither SDK validation nor a zero subdivision cap prevents the cast.
593            for validation in [false, true] {
594                let input = BakeInput {
595                    texture_coordinates: &uv,
596                    indices: &[0, 1, 2],
597                    config: BakeConfig {
598                        dynamic_subdivision_scale: scale,
599                        max_subdivision_level: 0,
600                        validation,
601                        ..BakeConfig::default()
602                    },
603                };
604                let error = input.validate([2, 2]).expect_err(name).to_string();
605
606                assert!(error.starts_with("nvidia omm"), "{name}: {error}");
607
608                #[cfg(nvidia_omm_native)]
609                assert_eq!(
610                    baker.bake(&texture, input).unwrap_err().to_string(),
611                    error,
612                    "{name}"
613                );
614            }
615        }
616    }
617
618    #[test]
619    fn ordinary_subdivision_arithmetic_is_accepted() {
620        for uv in [
621            [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
622            [[0.0, 0.0], [0.0, 1.0], [1.0, 0.0]],
623            [[-1.0, -1.0], [2.0, -1.0], [-1.0, 2.0]],
624            [[0.0, 0.0]; 3],
625            [[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]],
626        ] {
627            for scale in [2.0, 1e19] {
628                BakeInput {
629                    texture_coordinates: &uv,
630                    indices: &[0, 1, 2],
631                    config: BakeConfig {
632                        dynamic_subdivision_scale: scale,
633                        ..BakeConfig::default()
634                    },
635                }
636                .validate([2, 2])
637                .unwrap();
638            }
639        }
640
641        let input = BakeInput {
642            texture_coordinates: &[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
643            indices: &[0, 1, 2],
644            config: BakeConfig {
645                dynamic_subdivision_scale: 2.0_f32.powi(-15),
646                ..BakeConfig::default()
647            },
648        };
649
650        assert!(input.validate([2, 2]).is_ok());
651        assert!(input.validate([4, 4]).is_err());
652    }
653
654    #[cfg(nvidia_omm_native)]
655    #[test]
656    fn native_baker_returns_owned_special_indices() {
657        let baker = Baker::new(Baker::bundled_backend_path().unwrap()).unwrap();
658        let texture = baker
659            .create_texture(&[AlphaMip {
660                width: 2,
661                height: 2,
662                row_pitch: 0,
663                data: &[0; 4],
664            }])
665            .unwrap();
666        let output = baker
667            .bake(
668                &texture,
669                BakeInput {
670                    texture_coordinates: &[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
671                    indices: &[0, 1, 2],
672                    config: BakeConfig::default(),
673                },
674            )
675            .unwrap();
676
677        assert_eq!(output.indices.as_ref(), &[-1]);
678        assert_eq!(output.statistics.fully_transparent, 1);
679    }
680
681    #[test]
682    fn native_ffi_layouts_are_stable() {
683        assert_eq!(align_of::<OpacityMicromapDescriptor>(), 4);
684        assert_eq!(offset_of!(OpacityMicromapDescriptor, subdivision_level), 4);
685        assert_eq!(size_of::<OpacityMicromapDescriptor>(), 8);
686        assert_eq!(size_of::<OpacityMicromapUsage>(), 8);
687        assert_eq!(size_of::<Statistics>(), 56);
688        assert_eq!(size_of::<ffi::TextureMip>(), 24);
689        assert_eq!(size_of::<ffi::BakeInput>(), 64);
690        assert_eq!(size_of::<ffi::BakeResult>(), 136);
691    }
692}