Skip to main content

av_denoise/nlmeans/motion/
mod.rs

1mod analyse;
2mod chain;
3mod compensate;
4mod confidence;
5mod pyramid;
6
7#[cfg(all(test, any(feature = "vulkan", feature = "metal")))]
8pub(crate) use analyse::mv_field_byte_offset;
9pub(crate) use analyse::{confidence_byte_offset, run_analyse, run_seeded_refine};
10#[cfg(all(test, any(feature = "vulkan", feature = "metal")))]
11pub(crate) use chain::{neighbour_idx_for_k, pair_byte_offset};
12pub(crate) use chain::{run_pair_analyse, zero_pair_slot};
13pub(crate) use compensate::run_compensate;
14pub(crate) use confidence::{run_confidence_for_neighbour, sad_noise_floor, thsad};
15use cubecl::prelude::*;
16use cubecl::server::Handle;
17pub(crate) use pyramid::{pyramid_pixels_per_frame, run_pyramid_build};
18
19use crate::nlmeans::align::StorageAlign;
20
21/// How motion compensation is configured for a denoise pass.
22///
23/// `None` disables motion compensation entirely (zero-cost; no extra
24/// buffers are allocated). `Mvtools` enables an MVTools-inspired
25/// per-block estimator and warps neighbours toward the centre at
26/// denoise time.
27#[non_exhaustive]
28#[derive(Debug, Default, Clone, Copy, PartialEq)]
29pub enum MotionCompensationMode {
30    #[default]
31    None,
32    Mvtools {
33        /// Side length of each motion-estimation block in pixels at
34        /// the finest pyramid level.
35        blksize: u32,
36        /// Overlap between neighbouring blocks in pixels. Must be
37        /// strictly less than `blksize` so the step (`blksize - overlap`)
38        /// stays positive. Values > 0 reserve room for raised-cosine
39        /// blending in the compensate step (v1 uses a winner-block rule).
40        overlap: u32,
41        /// Pixel search radius at the *finest* pyramid level. The
42        /// coarse pass uses the same radius on the `/2` image so its
43        /// effective reach is doubled.
44        search_radius: u32,
45        /// Number of pyramid levels. `1` disables the hierarchical
46        /// coarse pass; `2` adds a `/2` coarse pass that seeds the
47        /// fine pass. Bounded by [`MAX_PYRAMID_LEVELS`].
48        pyramid_levels: u32,
49        /// How temporal MVs are estimated. `Auto` (the default) picks
50        /// the strategy from the temporal radius. Callers normally
51        /// leave this at the default. Explicit `Direct`/`Chained` are
52        /// mainly useful for pinning a variant in tests and benches.
53        estimation: MotionEstimation,
54    },
55}
56
57/// Strategy for estimating a temporal neighbour's motion vector.
58#[non_exhaustive]
59#[derive(Debug, Default, Clone, Copy, PartialEq)]
60pub enum MotionEstimation {
61    /// Resolve to `Direct` or `Chained` from the temporal radius at
62    /// denoiser construction. See [`MotionEstimation::resolve`] for the
63    /// exact rule and its empirical basis.
64    #[default]
65    Auto,
66    /// Match every neighbour directly against the centre frame at the
67    /// configured search radius. Cost scales with the temporal radius,
68    /// since each neighbour repeats the full coarse+fine search.
69    Direct,
70    /// Estimate motion between adjacent frames only, once per pushed
71    /// frame, then compose the per-step vectors into a seed for each
72    /// neighbour and correct residual drift with a small seeded
73    /// refinement search.
74    Chained {
75        /// Search radius for the seeded refinement pass, in pixels at
76        /// the finest pyramid level. Small because the composed seed
77        /// already carries most of the true displacement.
78        refine_radius: u32,
79    },
80}
81
82/// Default refinement radius for [`MotionEstimation::Chained`].
83pub const DEFAULT_REFINE_RADIUS: u32 = 2;
84
85/// Temporal radius at or above which [`MotionEstimation::Auto`]
86/// resolves to `Chained` instead of `Direct`. Below this, `Direct`
87/// tracks slightly better since the true motion still fits inside its
88/// own search window. At or above it, `Chained` stays in-window and is
89/// faster, since its reach scales with the radius instead of being
90/// capped by a fixed search window.
91pub const CHAINED_RADIUS_THRESHOLD: u32 = 3;
92
93impl MotionEstimation {
94    /// Convenience constructor for `Chained` with the library default
95    /// refinement radius.
96    pub fn chained_default() -> Self {
97        Self::Chained {
98            refine_radius: DEFAULT_REFINE_RADIUS,
99        }
100    }
101
102    /// Resolve `Auto` against the temporal radius, returning a concrete
103    /// `Direct` or `Chained` estimation. Never returns `Auto`. `Direct`
104    /// and `Chained` pass through unchanged, regardless of
105    /// `temporal_radius`. See [`CHAINED_RADIUS_THRESHOLD`] for the
106    /// threshold this applies.
107    pub fn resolve(self, temporal_radius: u32) -> Self {
108        match self {
109            Self::Auto if temporal_radius >= CHAINED_RADIUS_THRESHOLD => Self::chained_default(),
110            Self::Auto => Self::Direct,
111            other => other,
112        }
113    }
114
115    /// Reject a refinement radius the seeded fine kernel can't honour.
116    pub(crate) fn validate(&self) -> Result<(), anyhow::Error> {
117        let Self::Chained { refine_radius } = *self else {
118            return Ok(());
119        };
120
121        if refine_radius == 0 || refine_radius > MAX_SEARCH_RADIUS {
122            anyhow::bail!(
123                "motion-estimation refine_radius={refine_radius} must be in 1..={MAX_SEARCH_RADIUS}"
124            );
125        }
126
127        Ok(())
128    }
129}
130
131/// Default block size used when callers don't override it. Matches the
132/// MVTools default and lines up well with NLM's typical patch sizes.
133pub const DEFAULT_BLKSIZE: u32 = 16;
134/// Default block overlap (= `blksize / 2`).
135pub const DEFAULT_OVERLAP: u32 = 8;
136/// Default finest-level search radius. With a 2-level pyramid this
137/// reaches motion up to roughly ±12 pixels at the finest scale.
138pub const DEFAULT_SEARCH_RADIUS: u32 = 4;
139/// Default number of pyramid levels. `2` gives a single `/2` coarse
140/// pass, enough to handle most heavy-motion anime while keeping the
141/// kernel count manageable.
142pub const DEFAULT_PYRAMID_LEVELS: u32 = 2;
143
144/// Hard ceiling on `pyramid_levels`. Each extra level halves the
145/// resolution and adds an analyse-kernel launch per neighbour; 3 is
146/// already overkill for 1080p content.
147pub const MAX_PYRAMID_LEVELS: u32 = 3;
148/// Hard ceiling on `search_radius`. The analyse kernel SAD-sweeps a
149/// `(2·r + 1)²` window per block, so the cost is quadratic.
150pub const MAX_SEARCH_RADIUS: u32 = 8;
151/// Hard ceiling on `blksize`. Above this the per-block SMEM tile is
152/// uncomfortably large on RDNA-class GPUs.
153pub const MAX_BLKSIZE: u32 = 32;
154
155impl MotionCompensationMode {
156    /// Convenience constructor for `Mvtools` with library defaults.
157    ///
158    /// Pins `estimation` to `Direct` rather than the field's own
159    /// `Auto` default, so it never switches to `Chained` at larger
160    /// temporal radii the way an `Auto` configuration does.
161    pub fn mvtools_default() -> Self {
162        Self::Mvtools {
163            blksize: DEFAULT_BLKSIZE,
164            overlap: DEFAULT_OVERLAP,
165            search_radius: DEFAULT_SEARCH_RADIUS,
166            pyramid_levels: DEFAULT_PYRAMID_LEVELS,
167            estimation: MotionEstimation::Direct,
168        }
169    }
170
171    /// Whether motion compensation is active at all.
172    pub(crate) fn is_active(self) -> bool {
173        !matches!(self, Self::None)
174    }
175
176    /// Resolved estimation strategy for this mode at `temporal_radius`.
177    /// `None` when this mode isn't `Mvtools`. Never `Auto`, see
178    /// [`MotionEstimation::resolve`]. The single source every
179    /// estimation-dependent decision site (pair-ring allocation,
180    /// push-time pair-analyse gating, the submit-path dispatch branch)
181    /// goes through.
182    pub(crate) fn resolved_estimation(&self, temporal_radius: u32) -> Option<MotionEstimation> {
183        match *self {
184            Self::Mvtools { estimation, .. } => Some(estimation.resolve(temporal_radius)),
185            Self::None => None,
186        }
187    }
188
189    /// Reject parameter combinations that the kernels can't honour.
190    pub fn validate(&self) -> Result<(), anyhow::Error> {
191        let Self::Mvtools {
192            blksize,
193            overlap,
194            search_radius,
195            pyramid_levels,
196            estimation,
197        } = *self
198        else {
199            return Ok(());
200        };
201
202        if blksize < 4 {
203            anyhow::bail!("motion-compensation blksize={blksize} is too small; minimum is 4 pixels per side");
204        }
205        if blksize > MAX_BLKSIZE {
206            anyhow::bail!(
207                "motion-compensation blksize={blksize} exceeds the supported maximum ({MAX_BLKSIZE})"
208            );
209        }
210        if blksize % 2 != 0 {
211            anyhow::bail!(
212                "motion-compensation blksize={blksize} must be even so the /2 coarse level is well-defined"
213            );
214        }
215        if overlap >= blksize {
216            anyhow::bail!(
217                "motion-compensation overlap={overlap} must be strictly less than blksize ({blksize}) so step > 0"
218            );
219        }
220        if search_radius == 0 || search_radius > MAX_SEARCH_RADIUS {
221            anyhow::bail!(
222                "motion-compensation search_radius={search_radius} must be in 1..={MAX_SEARCH_RADIUS}"
223            );
224        }
225        if pyramid_levels == 0 || pyramid_levels > MAX_PYRAMID_LEVELS {
226            anyhow::bail!(
227                "motion-compensation pyramid_levels={pyramid_levels} must be in 1..={MAX_PYRAMID_LEVELS}"
228            );
229        }
230
231        estimation.validate()?;
232
233        Ok(())
234    }
235}
236
237/// Per-denoiser MC state, owned by `NlmDenoiser` when MC is active.
238///
239/// Cached at construction time so the hot dispatch path doesn't
240/// re-pattern-match the enum on every call. Holds only the fields the
241/// analyse and compensate dispatchers actually read. The full
242/// configuration lives on [`MotionCompensationMode`].
243#[derive(Debug, Clone)]
244pub(crate) struct MotionCtx {
245    pub blksize: u32,
246    pub step: u32,
247    pub search_radius: u32,
248    pub pyramid_levels: u32,
249    pub blocks_x: u32,
250    pub blocks_y: u32,
251    /// Alignment every buffer this context slices per-slot must respect
252    /// (the MV field, the confidence buffer, the pair ring, and the
253    /// pyramid). Read from the runtime, see [`StorageAlign`].
254    pub align: StorageAlign,
255}
256
257impl MotionCtx {
258    pub fn new(mode: MotionCompensationMode, width: u32, height: u32, align: StorageAlign) -> Option<Self> {
259        let MotionCompensationMode::Mvtools {
260            blksize,
261            overlap,
262            search_radius,
263            pyramid_levels,
264            estimation: _,
265        } = mode
266        else {
267            return None;
268        };
269
270        let step = blksize - overlap;
271        let blocks_x = width.div_ceil(step).max(1);
272        let blocks_y = height.div_ceil(step).max(1);
273
274        Some(Self {
275            blksize,
276            step,
277            search_radius,
278            pyramid_levels,
279            blocks_x,
280            blocks_y,
281            align,
282        })
283    }
284
285    /// MV-field slot count per neighbour. One i16x2 per block.
286    pub fn mv_slots_per_neighbour(&self) -> usize {
287        (self.blocks_x * self.blocks_y) as usize
288    }
289
290    /// Padded per-neighbour MV-field stride in bytes. Two `i32`
291    /// components (`dx`, `dy`) per block, rounded up to the runtime's
292    /// buffer-binding alignment, the same convention
293    /// [`Self::confidence_bytes_per_neighbour`] uses. `wgpu` rejects a
294    /// bind-group offset that isn't a multiple of its
295    /// `min_storage_buffer_offset_alignment`, and an odd block count
296    /// leaves the unpadded 8-byte-per-block stride short of that
297    /// boundary.
298    pub(crate) fn mv_field_bytes_per_neighbour(&self) -> u64 {
299        let blocks = (self.blocks_x as u64) * (self.blocks_y as u64);
300        self.align.pad_bytes(blocks * 2 * size_of::<i32>() as u64)
301    }
302
303    /// Padded per-neighbour confidence-buffer stride in bytes. One
304    /// `f32` per block, rounded up to the runtime's buffer-binding
305    /// alignment, the same convention
306    /// [`Self::mv_field_bytes_per_neighbour`] uses for the MV field.
307    pub(crate) fn confidence_bytes_per_neighbour(&self) -> u64 {
308        let blocks = (self.blocks_x as u64) * (self.blocks_y as u64);
309        self.align.pad_bytes(blocks * size_of::<f32>() as u64)
310    }
311
312    /// i32 elements per pair-ring direction sub-array, one `(dx, dy)`
313    /// per block. This is the unpadded element count a direction's
314    /// data actually spans, used as the zero-fill length in
315    /// `zero_pair_slot` and as the input to
316    /// [`Self::pair_direction_bytes`]'s padding.
317    pub(crate) fn pair_direction_len(&self) -> u32 {
318        self.blocks_x * self.blocks_y * 2
319    }
320
321    /// Padded per-direction pair-ring stride in bytes, rounded up to
322    /// the runtime's buffer-binding alignment, the same convention
323    /// [`Self::confidence_bytes_per_neighbour`] uses. Both
324    /// `pair_byte_offset` (the host-side write and zero-fill offset)
325    /// and the chain-compose kernel's own internal read stride use
326    /// this padded value, so a direction's data starts at the same
327    /// place for every reader and writer.
328    pub(crate) fn pair_direction_bytes(&self) -> u64 {
329        self.align
330            .pad_bytes(self.pair_direction_len() as u64 * size_of::<i32>() as u64)
331    }
332
333    /// Padded per-slot pair-ring stride in bytes, both directions back
334    /// to back.
335    pub(crate) fn pair_slot_bytes(&self) -> u64 {
336        2 * self.pair_direction_bytes()
337    }
338
339    /// Padded per-direction pair-ring stride in i32 elements. The
340    /// chain-compose kernel reads the whole pair ring as one unsliced
341    /// array and strides through it with this value, matching
342    /// [`Self::pair_direction_bytes`] exactly.
343    pub(crate) fn pair_direction_stride(&self) -> u32 {
344        (self.pair_direction_bytes() / size_of::<i32>() as u64) as u32
345    }
346
347    /// Padded per-slot pair-ring stride in i32 elements, both
348    /// directions back to back.
349    pub(crate) fn pair_slot_stride(&self) -> u32 {
350        2 * self.pair_direction_stride()
351    }
352
353    /// Block geometry for the no-MC confidence pass. Uses the
354    /// library's default block size and overlap, a single pyramid
355    /// level (no coarse pass), and zero search radius (a static
356    /// per-block SAD, no motion search). Used when confidence
357    /// weighting is active but no `Mvtools` mode was configured to
358    /// derive geometry from.
359    pub(crate) fn confidence_only(width: u32, height: u32, align: StorageAlign) -> Self {
360        Self::new(
361            MotionCompensationMode::Mvtools {
362                blksize: DEFAULT_BLKSIZE,
363                overlap: DEFAULT_OVERLAP,
364                search_radius: 0,
365                pyramid_levels: 1,
366                estimation: MotionEstimation::Direct,
367            },
368            width,
369            height,
370            align,
371        )
372        .expect("Mvtools variant always yields Some")
373    }
374}
375
376/// Pair-ring slot count for a temporal radius, `2 * radius`.
377///
378/// The pair ring stores one adjacent-frame motion field per gap
379/// between consecutive frames in the temporal window. A window of
380/// `2 * radius + 1` frames has exactly `2 * radius` such gaps, and a
381/// gap's pair field is only ever read by composition while both its
382/// frames remain in some window, a span of exactly `2 * radius`
383/// consecutive frame pushes. Sizing the ring at `2 * radius` slots
384/// means a slot's next reuse lands exactly when its previous contents
385/// stop being needed, never before (see
386/// `NlmDenoiser::pair_slot` for the derivation this relies on).
387pub(crate) fn pair_ring_slot_count(temporal_radius: u32) -> u32 {
388    2 * temporal_radius
389}
390
391/// Build the per-frame pyramid for the slot just uploaded by
392/// `push_frame`. Always extracts level-0 luma, and also builds the
393/// downscale chain when `pyramid_levels > 1`. A thin wrapper around
394/// [`run_pyramid_build`], which already handles both cases on its own.
395#[allow(clippy::too_many_arguments)]
396pub(crate) fn build_pyramid_for_slot<R: Runtime>(
397    client: &ComputeClient<R>,
398    mc: &MotionCtx,
399    width: u32,
400    height: u32,
401    frame_count: u32,
402    slot: u32,
403    full_res: &Handle,
404    pyramid: &Handle,
405    stored_ch: u32,
406) -> Result<(), anyhow::Error> {
407    run_pyramid_build::<R>(
408        client,
409        mc,
410        width,
411        height,
412        frame_count,
413        slot,
414        full_res,
415        pyramid,
416        stored_ch,
417    )
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn none_is_inactive() {
426        let m = MotionCompensationMode::None;
427        assert!(!m.is_active());
428        m.validate().unwrap();
429    }
430
431    #[test]
432    fn mvtools_default_is_active() {
433        let m = MotionCompensationMode::mvtools_default();
434        assert!(m.is_active());
435        m.validate().unwrap();
436    }
437
438    #[test]
439    fn validate_rejects_tiny_blksize() {
440        let m = MotionCompensationMode::Mvtools {
441            blksize: 2,
442            overlap: 0,
443            search_radius: 4,
444            pyramid_levels: 2,
445            estimation: MotionEstimation::Direct,
446        };
447        assert!(m.validate().is_err());
448    }
449
450    #[test]
451    fn validate_rejects_odd_blksize() {
452        let m = MotionCompensationMode::Mvtools {
453            blksize: 9,
454            overlap: 0,
455            search_radius: 4,
456            pyramid_levels: 2,
457            estimation: MotionEstimation::Direct,
458        };
459        assert!(m.validate().is_err());
460    }
461
462    #[test]
463    fn validate_rejects_overlap_equal_to_blksize() {
464        let m = MotionCompensationMode::Mvtools {
465            blksize: 16,
466            overlap: 16,
467            search_radius: 4,
468            pyramid_levels: 2,
469            estimation: MotionEstimation::Direct,
470        };
471        // overlap == blksize would give step=0.
472        assert!(m.validate().is_err());
473    }
474
475    #[test]
476    fn validate_accepts_half_overlap() {
477        let m = MotionCompensationMode::Mvtools {
478            blksize: 16,
479            overlap: 8,
480            search_radius: 4,
481            pyramid_levels: 2,
482            estimation: MotionEstimation::Direct,
483        };
484        m.validate().unwrap();
485    }
486
487    #[test]
488    fn validate_rejects_zero_search_radius() {
489        let m = MotionCompensationMode::Mvtools {
490            blksize: 16,
491            overlap: 4,
492            search_radius: 0,
493            pyramid_levels: 2,
494            estimation: MotionEstimation::Direct,
495        };
496        assert!(m.validate().is_err());
497    }
498
499    #[test]
500    fn validate_rejects_zero_pyramid_levels() {
501        let m = MotionCompensationMode::Mvtools {
502            blksize: 16,
503            overlap: 4,
504            search_radius: 4,
505            pyramid_levels: 0,
506            estimation: MotionEstimation::Direct,
507        };
508        assert!(m.validate().is_err());
509    }
510
511    #[test]
512    fn chained_default_is_valid() {
513        let m = MotionCompensationMode::Mvtools {
514            blksize: 16,
515            overlap: 8,
516            search_radius: 4,
517            pyramid_levels: 2,
518            estimation: MotionEstimation::chained_default(),
519        };
520        m.validate().unwrap();
521        assert_eq!(
522            m,
523            MotionCompensationMode::Mvtools {
524                blksize: 16,
525                overlap: 8,
526                search_radius: 4,
527                pyramid_levels: 2,
528                estimation: MotionEstimation::Chained {
529                    refine_radius: DEFAULT_REFINE_RADIUS
530                },
531            }
532        );
533    }
534
535    #[test]
536    fn validate_rejects_zero_refine_radius() {
537        let m = MotionCompensationMode::Mvtools {
538            blksize: 16,
539            overlap: 8,
540            search_radius: 4,
541            pyramid_levels: 2,
542            estimation: MotionEstimation::Chained { refine_radius: 0 },
543        };
544        assert!(m.validate().is_err());
545    }
546
547    #[test]
548    fn validate_rejects_refine_radius_above_max() {
549        let m = MotionCompensationMode::Mvtools {
550            blksize: 16,
551            overlap: 8,
552            search_radius: 4,
553            pyramid_levels: 2,
554            estimation: MotionEstimation::Chained {
555                refine_radius: MAX_SEARCH_RADIUS + 1,
556            },
557        };
558        assert!(m.validate().is_err());
559    }
560
561    #[test]
562    fn validate_accepts_refine_radius_at_max() {
563        let m = MotionCompensationMode::Mvtools {
564            blksize: 16,
565            overlap: 8,
566            search_radius: 4,
567            pyramid_levels: 2,
568            estimation: MotionEstimation::Chained {
569                refine_radius: MAX_SEARCH_RADIUS,
570            },
571        };
572        m.validate().unwrap();
573    }
574
575    #[test]
576    fn motion_estimation_default_is_auto() {
577        assert_eq!(MotionEstimation::default(), MotionEstimation::Auto);
578    }
579
580    #[test]
581    fn resolve_auto_below_threshold_gives_direct() {
582        assert_eq!(MotionEstimation::Auto.resolve(1), MotionEstimation::Direct);
583        assert_eq!(MotionEstimation::Auto.resolve(2), MotionEstimation::Direct);
584    }
585
586    #[test]
587    fn resolve_auto_at_and_above_threshold_gives_chained_default() {
588        assert_eq!(
589            MotionEstimation::Auto.resolve(CHAINED_RADIUS_THRESHOLD),
590            MotionEstimation::chained_default()
591        );
592        assert_eq!(
593            MotionEstimation::Auto.resolve(8),
594            MotionEstimation::chained_default()
595        );
596    }
597
598    #[test]
599    fn resolve_leaves_explicit_direct_unchanged_at_every_radius() {
600        for radius in 1..=8u32 {
601            assert_eq!(MotionEstimation::Direct.resolve(radius), MotionEstimation::Direct);
602        }
603    }
604
605    #[test]
606    fn resolve_leaves_explicit_chained_unchanged_at_every_radius() {
607        let chained = MotionEstimation::Chained { refine_radius: 5 };
608        for radius in 1..=8u32 {
609            assert_eq!(chained.resolve(radius), chained);
610        }
611    }
612
613    #[test]
614    fn validate_accepts_auto() {
615        let m = MotionCompensationMode::Mvtools {
616            blksize: 16,
617            overlap: 8,
618            search_radius: 4,
619            pyramid_levels: 2,
620            estimation: MotionEstimation::Auto,
621        };
622        m.validate().unwrap();
623    }
624
625    #[test]
626    fn resolved_estimation_is_none_when_mode_is_none() {
627        assert_eq!(MotionCompensationMode::None.resolved_estimation(4), None);
628    }
629
630    #[test]
631    fn resolved_estimation_resolves_auto_from_the_mode() {
632        let m = MotionCompensationMode::Mvtools {
633            blksize: 16,
634            overlap: 8,
635            search_radius: 4,
636            pyramid_levels: 2,
637            estimation: MotionEstimation::Auto,
638        };
639        assert_eq!(m.resolved_estimation(1), Some(MotionEstimation::Direct));
640        assert_eq!(
641            m.resolved_estimation(4),
642            Some(MotionEstimation::chained_default())
643        );
644    }
645
646    #[test]
647    fn pair_ring_slot_count_is_double_radius() {
648        assert_eq!(pair_ring_slot_count(3), 6);
649        assert_eq!(pair_ring_slot_count(1), 2);
650    }
651
652    #[test]
653    fn motion_ctx_blocks_match_step() {
654        let mode = MotionCompensationMode::Mvtools {
655            blksize: 16,
656            overlap: 8,
657            search_radius: 4,
658            pyramid_levels: 2,
659            estimation: MotionEstimation::Direct,
660        };
661        let ctx = MotionCtx::new(mode, 1920, 1080, StorageAlign::new(32)).unwrap();
662        assert_eq!(ctx.step, 8);
663        assert_eq!(ctx.blocks_x, 1920u32.div_ceil(8));
664        assert_eq!(ctx.blocks_y, 1080u32.div_ceil(8));
665    }
666}