Skip to main content

azul_core/
gpu.rs

1//! GPU value caching for CSS transforms and opacity.
2//!
3//! This module manages the synchronization between DOM CSS properties (transforms and opacity)
4//! and GPU-side keys used by WebRender. It tracks changes to transform and opacity values
5//! and generates events when values are added, changed, or removed.
6//!
7//! # Performance
8//!
9//! The cache uses CPU feature detection (SSE/AVX on x86_64) to optimize transform calculations.
10//! Values are only recalculated when CSS properties change, minimizing GPU updates.
11//!
12//! # Architecture
13//!
14//! - `GpuValueCache`: Stores current transform/opacity keys and values for all nodes
15//! - `GpuEventChanges`: Contains delta events for transform/opacity changes
16//! - `GpuTransformKeyEvent`: Events for transform additions, changes, and removals
17//!
18//! The cache is synchronized with the `StyledDom` on each frame, generating minimal
19//! update events to send to the GPU.
20
21use alloc::vec::Vec;
22#[cfg(feature = "std")]
23use std::collections::HashMap;
24#[cfg(not(feature = "std"))]
25use alloc::collections::BTreeMap as HashMap;
26use core::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
27
28use azul_css::props::style::StyleTransformOrigin;
29
30use crate::{
31    dom::{DomId, NodeId},
32    resources::{OpacityKey, TransformKey},
33    styled_dom::StyledDom,
34    transform::{ComputedTransform3D, RotationMode, INITIALIZED, USE_AVX, USE_SSE},
35};
36
37/// Caches GPU transform and opacity keys and their current values for all nodes.
38///
39/// This cache stores the `WebRender` keys and computed values for nodes with
40/// CSS transforms or opacity. It's synchronized with the `StyledDom` to detect
41/// changes and generate minimal update events.
42#[derive(Default, Debug, Clone)]
43pub struct GpuValueCache {
44    /// Vertical scrollbar thumb transform keys (keyed by scrollable node ID)
45    pub transform_keys: HashMap<NodeId, TransformKey>,
46    /// Current vertical scrollbar thumb transform values
47    pub current_transform_values: HashMap<NodeId, ComputedTransform3D>,
48    /// Horizontal scrollbar thumb transform keys (keyed by scrollable node ID)
49    pub h_transform_keys: HashMap<NodeId, TransformKey>,
50    /// Current horizontal scrollbar thumb transform values
51    pub h_current_transform_values: HashMap<NodeId, ComputedTransform3D>,
52    /// CSS transform keys (keyed by node ID) — for CSS `transform` property animation.
53    /// Separate from scrollbar transform keys to avoid `SpatialTreeItemKey` collisions.
54    pub css_transform_keys: HashMap<NodeId, TransformKey>,
55    /// Current CSS transform values (keyed by node ID)
56    pub css_current_transform_values: HashMap<NodeId, ComputedTransform3D>,
57    /// CSS opacity keys (keyed by node ID)
58    pub opacity_keys: HashMap<NodeId, OpacityKey>,
59    /// Current CSS opacity values (keyed by node ID)
60    pub current_opacity_values: HashMap<NodeId, f32>,
61    /// Vertical scrollbar opacity keys (keyed by DOM ID and scrollable node ID)
62    pub scrollbar_v_opacity_keys: HashMap<(DomId, NodeId), OpacityKey>,
63    /// Horizontal scrollbar opacity keys (keyed by DOM ID and scrollable node ID)
64    pub scrollbar_h_opacity_keys: HashMap<(DomId, NodeId), OpacityKey>,
65    /// Current vertical scrollbar opacity values
66    pub scrollbar_v_opacity_values: HashMap<(DomId, NodeId), f32>,
67    /// Current horizontal scrollbar opacity values
68    pub scrollbar_h_opacity_values: HashMap<(DomId, NodeId), f32>,
69}
70
71/// Represents a change to a GPU transform key.
72///
73/// These events are generated when synchronizing the cache with the `StyledDom`
74/// and are used to update `WebRender`'s transform state efficiently.
75#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
76pub enum GpuTransformKeyEvent {
77    /// A new transform was added to a node
78    Added(NodeId, TransformKey, ComputedTransform3D),
79    /// An existing transform was modified (includes old and new values)
80    Changed(
81        NodeId,
82        TransformKey,
83        ComputedTransform3D,
84        ComputedTransform3D,
85    ),
86    /// A transform was removed from a node
87    Removed(NodeId, TransformKey),
88}
89
90impl GpuValueCache {
91    /// Creates an empty GPU value cache.
92    #[must_use] pub fn empty() -> Self {
93        Self::default()
94    }
95
96    /// Synchronizes the cache with the current `StyledDom`, generating change events
97    /// for CSS transform and opacity additions, modifications, and removals.
98    ///
99    /// Split into read-only `compute_*_events` passes (which diff against the cache)
100    /// and `apply_*_events` passes (which mutate it).
101    #[must_use]
102    pub fn synchronize(&mut self, styled_dom: &StyledDom) -> GpuEventChanges {
103        Self::init_simd_features();
104
105        let transform_key_changes = self.compute_transform_events(styled_dom);
106        self.apply_transform_events(&transform_key_changes);
107
108        let opacity_key_changes = self.compute_opacity_events(styled_dom);
109        self.apply_opacity_events(&opacity_key_changes);
110
111        GpuEventChanges {
112            transform_key_changes,
113            opacity_key_changes,
114            scrollbar_opacity_changes: Vec::new(), // Filled by separate synchronization
115        }
116    }
117
118    /// One-time CPU feature detection (SSE/AVX) for the transform math fast paths.
119    #[allow(clippy::missing_const_for_fn)] // non-x86_64 body is empty; x86_64 uses atomics
120    fn init_simd_features() {
121        #[cfg(target_arch = "x86_64")]
122        unsafe {
123            if !INITIALIZED.load(AtomicOrdering::SeqCst) {
124                use core::arch::x86_64::__cpuid;
125
126                let mut cpuid = __cpuid(0);
127                let n_ids = cpuid.eax;
128
129                if n_ids > 0 {
130                    // cpuid instruction is present
131                    cpuid = __cpuid(1);
132                    USE_SSE.store((cpuid.edx & (1_u32 << 25)) != 0, AtomicOrdering::SeqCst);
133                    USE_AVX.store((cpuid.ecx & (1_u32 << 28)) != 0, AtomicOrdering::SeqCst);
134                }
135                INITIALIZED.store(true, AtomicOrdering::SeqCst);
136            }
137        }
138    }
139
140    /// Computes CSS-transform change events against the cached values (read-only).
141    fn compute_transform_events(&self, styled_dom: &StyledDom) -> Vec<GpuTransformKeyEvent> {
142        let css_property_cache = styled_dom.get_css_property_cache();
143        let node_data = styled_dom.node_data.as_container();
144        let node_states = styled_dom.styled_nodes.as_container();
145
146        let default_transform_origin = StyleTransformOrigin::default();
147
148        // calculate the transform values of every single node that has a non-default transform.
149        //
150        // GPU fast path: `has_transform` is a single bit in the compact cache.
151        // The overwhelmingly common case is "no transform set", which now reads one
152        // byte and bails — no cascade walk. Only nodes that actually have a
153        // transform pay the slow-walk cost (required to retrieve the parsed value).
154        let mut events = (0..styled_dom.node_data.len())
155            .filter_map(|node_id| {
156                let node_id = NodeId::new(node_id);
157                let styled_node_state = &node_states[node_id].styled_node_state;
158                // Bit-check short-circuit: only proceed if the node might have a transform.
159                if styled_node_state.is_normal() {
160                    if let Some(ref cc) = css_property_cache.compact_cache {
161                        // M12.7: short-circuit the empty-map get. hashbrown's
162                        // empty-map probe touches the static empty control-group,
163                        // which mis-lifts to wasm (out-of-bounds access); the web
164                        // headless layout uses a fresh (empty) GpuValueCache. An
165                        // empty map has no entry anyway, and is_empty() is len-based
166                        // (no probe), so the result is identical on desktop.
167                        if !cc.has_transform(node_id.index())
168                            && (self.css_current_transform_values.is_empty()
169                                || !self.css_current_transform_values.contains_key(&node_id))
170                        {
171                            return None;
172                        }
173                    }
174                }
175                let node_data = &node_data[node_id];
176                let current_transform = css_property_cache
177                    .get_transform(node_data, &node_id, styled_node_state)?
178                    .get_property()
179                    .map(|t| {
180                        // TODO: look up the parent nodes size properly to resolve animation of
181                        // transforms with %
182                        let parent_size_width = 0.0;
183                        let parent_size_height = 0.0;
184                        let transform_origin = css_property_cache.get_transform_origin(
185                            node_data,
186                            &node_id,
187                            styled_node_state,
188                        );
189                        let transform_origin = transform_origin
190                            .as_ref()
191                            .and_then(|o| o.get_property())
192                            .unwrap_or(&default_transform_origin);
193
194                        ComputedTransform3D::from_style_transform_vec(
195                            t.as_ref(),
196                            transform_origin,
197                            parent_size_width,
198                            parent_size_height,
199                            RotationMode::ForWebRender,
200                        )
201                    });
202
203                let existing_transform = if self.css_current_transform_values.is_empty() {
204                    None
205                } else {
206                    self.css_current_transform_values.get(&node_id)
207                };
208
209                match (existing_transform, current_transform) {
210                    (None, None) => None, // no new transform, no old transform
211                    (None, Some(new)) => Some(GpuTransformKeyEvent::Added(
212                        node_id,
213                        TransformKey::unique(),
214                        new,
215                    )),
216                    (Some(old), Some(new)) => Some(GpuTransformKeyEvent::Changed(
217                        node_id,
218                        self.css_transform_keys.get(&node_id).copied()?,
219                        *old,
220                        new,
221                    )),
222                    (Some(_old), None) => Some(GpuTransformKeyEvent::Removed(
223                        node_id,
224                        self.css_transform_keys.get(&node_id).copied()?,
225                    )),
226                }
227            })
228            .collect::<Vec<GpuTransformKeyEvent>>();
229
230        // Structural shrink: any cached transform key whose node no longer
231        // exists in the (smaller) DOM is never visited by the loop above, so it
232        // would leak on the GPU. Emit an explicit Removed for those.
233        let node_count = styled_dom.node_data.len();
234        for (node_id, key) in &self.css_transform_keys {
235            if node_id.index() >= node_count {
236                events.push(GpuTransformKeyEvent::Removed(*node_id, *key));
237            }
238        }
239
240        events
241    }
242
243    /// Applies transform key changes (additions/removals) to the cache.
244    fn apply_transform_events(&mut self, events: &[GpuTransformKeyEvent]) {
245        // remove / add the CSS transform keys accordingly
246        for event in events {
247            match &event {
248                GpuTransformKeyEvent::Added(node_id, key, matrix) => {
249                    self.css_transform_keys.insert(*node_id, *key);
250                    self.css_current_transform_values.insert(*node_id, *matrix);
251                }
252                GpuTransformKeyEvent::Changed(node_id, _key, _old_state, new_state) => {
253                    self.css_current_transform_values.insert(*node_id, *new_state);
254                }
255                GpuTransformKeyEvent::Removed(node_id, _key) => {
256                    self.css_transform_keys.remove(node_id);
257                    self.css_current_transform_values.remove(node_id);
258                }
259            }
260        }
261    }
262
263    /// Computes opacity change events against the cached values (read-only).
264    fn compute_opacity_events(&self, styled_dom: &StyledDom) -> Vec<GpuOpacityKeyEvent> {
265        let css_property_cache = styled_dom.get_css_property_cache();
266        let node_data = styled_dom.node_data.as_container();
267        let node_states = styled_dom.styled_nodes.as_container();
268
269        // calculate the opacity of every single node that has a non-default opacity
270        //
271        // GPU fast path: compact cache encodes opacity as a single u8. Nodes with
272        // no author-set opacity (the common case) have `OPACITY_SENTINEL` and
273        // return immediately — no cascade walk. Only non-default opacities
274        // generate key events.
275        let mut events = (0..styled_dom.node_data.len())
276            .filter_map(|node_id| {
277                let node_id = NodeId::new(node_id);
278                let styled_node_state = &node_states[node_id].styled_node_state;
279
280                // Fast-path opacity read via compact cache.
281                let mut compact_opacity: Option<f32> = None;
282                if styled_node_state.is_normal() {
283                    if let Some(ref cc) = css_property_cache.compact_cache {
284                        let raw = cc.get_opacity_raw(node_id.index());
285                        compact_opacity = if raw == azul_css::compact_cache::OPACITY_SENTINEL {
286                            // unset → default (1.0) — bail out unless we had a prior opacity key
287                            self.current_opacity_values.get(&node_id)?;
288                            None
289                        } else {
290                            Some(f32::from(raw) / 254.0)
291                        };
292                    }
293                }
294
295                let node_data = &node_data[node_id];
296                let current_opacity: Option<f32> = if let Some(v) = compact_opacity {
297                    // Fast path: value already read from compact cache.
298                    Some(v)
299                } else if styled_node_state.is_normal() && css_property_cache.compact_cache.is_some() {
300                    // Fast path: sentinel — unset → default (1.0, treated as None here).
301                    None
302                } else {
303                    css_property_cache
304                        .get_opacity(node_data, &node_id, styled_node_state)?
305                        .get_property()
306                        .map(|p| p.inner.normalized())
307                };
308                let existing_opacity = self.current_opacity_values.get(&node_id);
309
310                match (existing_opacity, current_opacity) {
311                    (None, None) => None, // no new opacity, no old opacity
312                    (None, Some(new)) => Some(GpuOpacityKeyEvent::Added(
313                        node_id,
314                        OpacityKey::unique(),
315                        new,
316                    )),
317                    (Some(old), Some(new)) => Some(GpuOpacityKeyEvent::Changed(
318                        node_id,
319                        self.opacity_keys.get(&node_id).copied()?,
320                        *old,
321                        new,
322                    )),
323                    (Some(_old), None) => Some(GpuOpacityKeyEvent::Removed(
324                        node_id,
325                        self.opacity_keys.get(&node_id).copied()?,
326                    )),
327                }
328            })
329            .collect::<Vec<GpuOpacityKeyEvent>>();
330
331        // Structural shrink: emit Removed for cached opacity keys whose node no
332        // longer exists in the (smaller) DOM (never visited by the loop above).
333        let node_count = styled_dom.node_data.len();
334        for (node_id, key) in &self.opacity_keys {
335            if node_id.index() >= node_count {
336                events.push(GpuOpacityKeyEvent::Removed(*node_id, *key));
337            }
338        }
339
340        events
341    }
342
343    /// Applies opacity key changes (additions/removals) to the cache.
344    fn apply_opacity_events(&mut self, events: &[GpuOpacityKeyEvent]) {
345        // remove / add the opacity keys accordingly
346        for event in events {
347            match &event {
348                GpuOpacityKeyEvent::Added(node_id, key, opacity) => {
349                    self.opacity_keys.insert(*node_id, *key);
350                    self.current_opacity_values.insert(*node_id, *opacity);
351                }
352                GpuOpacityKeyEvent::Changed(node_id, _key, _old_state, new_state) => {
353                    self.current_opacity_values.insert(*node_id, *new_state);
354                }
355                GpuOpacityKeyEvent::Removed(node_id, _key) => {
356                    self.opacity_keys.remove(node_id);
357                    self.current_opacity_values.remove(node_id);
358                }
359            }
360        }
361    }
362}
363
364/// Represents a change to a scrollbar opacity key.
365///
366/// Scrollbar opacity is managed separately from CSS opacity to enable
367/// independent fading animations without affecting element opacity.
368#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
369pub enum GpuScrollbarOpacityEvent {
370    /// A vertical scrollbar was added to a node
371    VerticalAdded(DomId, NodeId, OpacityKey, f32),
372    /// A vertical scrollbar opacity was changed
373    VerticalChanged(DomId, NodeId, OpacityKey, f32, f32),
374    /// A vertical scrollbar was removed from a node
375    VerticalRemoved(DomId, NodeId, OpacityKey),
376    /// A horizontal scrollbar was added to a node
377    HorizontalAdded(DomId, NodeId, OpacityKey, f32),
378    /// A horizontal scrollbar opacity was changed
379    HorizontalChanged(DomId, NodeId, OpacityKey, f32, f32),
380    /// A horizontal scrollbar was removed from a node
381    HorizontalRemoved(DomId, NodeId, OpacityKey),
382}
383
384/// Contains all GPU-related change events from a cache synchronization.
385///
386/// This structure groups transform, opacity, and scrollbar opacity changes together
387/// for efficient batch processing when updating `WebRender`.
388#[derive(Default, Debug, Clone, PartialEq, PartialOrd)]
389pub struct GpuEventChanges {
390    /// All transform key changes (additions, modifications, removals)
391    pub transform_key_changes: Vec<GpuTransformKeyEvent>,
392    /// All opacity key changes (additions, modifications, removals)
393    pub opacity_key_changes: Vec<GpuOpacityKeyEvent>,
394    /// All scrollbar opacity key changes (additions, modifications, removals)
395    pub scrollbar_opacity_changes: Vec<GpuScrollbarOpacityEvent>,
396}
397
398impl GpuEventChanges {
399    /// Creates an empty set of GPU event changes.
400    #[must_use] pub fn empty() -> Self {
401        Self::default()
402    }
403
404    /// Returns `true` if there are no transform, opacity, or scrollbar opacity changes.
405    #[must_use] pub const fn is_empty(&self) -> bool {
406        self.transform_key_changes.is_empty()
407            && self.opacity_key_changes.is_empty()
408            && self.scrollbar_opacity_changes.is_empty()
409    }
410
411    /// Merges another `GpuEventChanges` into this one, consuming the other.
412    ///
413    /// This is useful for combining changes from multiple sources.
414    pub fn merge(&mut self, other: &mut Self) {
415        self.transform_key_changes.append(&mut other.transform_key_changes);
416        self.opacity_key_changes.append(&mut other.opacity_key_changes);
417        self.scrollbar_opacity_changes.append(&mut other.scrollbar_opacity_changes);
418    }
419}
420
421/// Represents a change to a GPU opacity key.
422///
423/// These events are generated when synchronizing the cache with the `StyledDom`
424/// and are used to update `WebRender`'s opacity state efficiently.
425#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
426pub enum GpuOpacityKeyEvent {
427    /// A new opacity was added to a node
428    Added(NodeId, OpacityKey, f32),
429    /// An existing opacity was modified (includes old and new values)
430    Changed(NodeId, OpacityKey, f32, f32),
431    /// An opacity was removed from a node
432    Removed(NodeId, OpacityKey),
433}
434
435#[cfg(test)]
436#[allow(clippy::float_cmp)] // GPU cache values must round-trip bit-exactly, not "approximately"
437mod autotest_generated {
438    use azul_css::css::Css;
439
440    use super::*;
441    use crate::dom::Dom;
442
443    /// A `StyledDom` with exactly one node (the body) and no CSS at all:
444    /// compact cache present, `has_transform` unset, opacity == `OPACITY_SENTINEL`.
445    fn plain_styled_dom() -> StyledDom {
446        let mut dom = Dom::create_body();
447        StyledDom::create(&mut dom, Css::empty())
448    }
449
450    /// body > div.<class>, styled by `css_src`.
451    fn styled_dom_from_css(css_src: &str, class: &str) -> StyledDom {
452        let css = azul_css::parser2::new_from_str(css_src).0;
453        let mut dom = Dom::create_body()
454            .with_children(vec![Dom::create_div().with_class(class.to_string().into())].into());
455        StyledDom::create(&mut dom, css)
456    }
457
458    /// A matrix stuffed with every hostile f32 the transform math can produce.
459    fn hostile_matrix() -> ComputedTransform3D {
460        ComputedTransform3D::new(
461            f32::NAN,
462            f32::INFINITY,
463            f32::NEG_INFINITY,
464            f32::MIN,
465            f32::MAX,
466            0.0,
467            -0.0,
468            f32::EPSILON,
469            1.0,
470            2.0,
471            3.0,
472            4.0,
473            5.0,
474            6.0,
475            7.0,
476            8.0,
477        )
478    }
479
480    fn scale_matrix(s: f32) -> ComputedTransform3D {
481        ComputedTransform3D::new(
482            s, 0.0, 0.0, 0.0, 0.0, s, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
483        )
484    }
485
486    // ---------------------------------------------------------------------
487    // constructors / neutral elements
488    // ---------------------------------------------------------------------
489
490    #[test]
491    fn gpu_value_cache_empty_holds_no_keys_and_no_values() {
492        let cache = GpuValueCache::empty();
493        assert!(cache.transform_keys.is_empty());
494        assert!(cache.current_transform_values.is_empty());
495        assert!(cache.h_transform_keys.is_empty());
496        assert!(cache.h_current_transform_values.is_empty());
497        assert!(cache.css_transform_keys.is_empty());
498        assert!(cache.css_current_transform_values.is_empty());
499        assert!(cache.opacity_keys.is_empty());
500        assert!(cache.current_opacity_values.is_empty());
501        assert!(cache.scrollbar_v_opacity_keys.is_empty());
502        assert!(cache.scrollbar_h_opacity_keys.is_empty());
503        assert!(cache.scrollbar_v_opacity_values.is_empty());
504        assert!(cache.scrollbar_h_opacity_values.is_empty());
505    }
506
507    #[test]
508    fn gpu_event_changes_empty_equals_default_and_is_empty() {
509        let changes = GpuEventChanges::empty();
510        assert_eq!(changes, GpuEventChanges::default());
511        assert!(changes.is_empty());
512        assert_eq!(changes.transform_key_changes.len(), 0);
513        assert_eq!(changes.opacity_key_changes.len(), 0);
514        assert_eq!(changes.scrollbar_opacity_changes.len(), 0);
515    }
516
517    // ---------------------------------------------------------------------
518    // GpuEventChanges::is_empty (predicate)
519    // ---------------------------------------------------------------------
520
521    #[test]
522    fn is_empty_is_false_when_any_single_event_vec_is_populated() {
523        let node = NodeId::ZERO;
524
525        let mut only_transform = GpuEventChanges::empty();
526        only_transform
527            .transform_key_changes
528            .push(GpuTransformKeyEvent::Removed(node, TransformKey::unique()));
529        assert!(!only_transform.is_empty());
530
531        let mut only_opacity = GpuEventChanges::empty();
532        only_opacity
533            .opacity_key_changes
534            .push(GpuOpacityKeyEvent::Removed(node, OpacityKey::unique()));
535        assert!(!only_opacity.is_empty());
536
537        // scrollbar changes alone must also flip the predicate — is_empty() has to
538        // check all three vecs, not just the two the CSS passes fill.
539        let mut only_scrollbar = GpuEventChanges::empty();
540        only_scrollbar
541            .scrollbar_opacity_changes
542            .push(GpuScrollbarOpacityEvent::VerticalRemoved(
543                DomId::ROOT_ID,
544                node,
545                OpacityKey::unique(),
546            ));
547        assert!(!only_scrollbar.is_empty());
548    }
549
550    // ---------------------------------------------------------------------
551    // GpuEventChanges::merge
552    // ---------------------------------------------------------------------
553
554    #[test]
555    fn merge_moves_every_event_and_drains_the_source() {
556        let node = NodeId::new(7);
557
558        let mut target = GpuEventChanges::empty();
559        target.transform_key_changes.push(GpuTransformKeyEvent::Added(
560            node,
561            TransformKey::unique(),
562            ComputedTransform3D::IDENTITY,
563        ));
564
565        let mut source = GpuEventChanges::empty();
566        source
567            .transform_key_changes
568            .push(GpuTransformKeyEvent::Removed(node, TransformKey::unique()));
569        source
570            .opacity_key_changes
571            .push(GpuOpacityKeyEvent::Added(node, OpacityKey::unique(), 0.25));
572        source
573            .scrollbar_opacity_changes
574            .push(GpuScrollbarOpacityEvent::HorizontalAdded(
575                DomId::ROOT_ID,
576                node,
577                OpacityKey::unique(),
578                1.0,
579            ));
580
581        target.merge(&mut source);
582
583        assert!(source.is_empty(), "merge must consume the source");
584        assert_eq!(target.transform_key_changes.len(), 2);
585        assert_eq!(target.opacity_key_changes.len(), 1);
586        assert_eq!(target.scrollbar_opacity_changes.len(), 1);
587        // append semantics: self's events keep their position, other's are pushed after.
588        assert!(matches!(
589            target.transform_key_changes[0],
590            GpuTransformKeyEvent::Added(..)
591        ));
592        assert!(matches!(
593            target.transform_key_changes[1],
594            GpuTransformKeyEvent::Removed(..)
595        ));
596    }
597
598    #[test]
599    fn merge_with_an_empty_set_is_the_identity_in_both_directions() {
600        let node = NodeId::new(1);
601        let mut populated = GpuEventChanges::empty();
602        populated
603            .opacity_key_changes
604            .push(GpuOpacityKeyEvent::Added(node, OpacityKey::unique(), 0.5));
605        let snapshot = populated.clone();
606
607        // x.merge(empty) == x
608        let mut empty = GpuEventChanges::empty();
609        populated.merge(&mut empty);
610        assert_eq!(populated, snapshot);
611        assert!(empty.is_empty());
612
613        // empty.merge(x) == x
614        let mut target = GpuEventChanges::empty();
615        let mut source = snapshot.clone();
616        target.merge(&mut source);
617        assert_eq!(target, snapshot);
618        assert!(source.is_empty());
619    }
620
621    #[test]
622    fn merging_large_event_vectors_does_not_panic_and_is_idempotent_when_drained() {
623        let mut target = GpuEventChanges::empty();
624        let mut source = GpuEventChanges::empty();
625        for i in 0..10_000usize {
626            target
627                .opacity_key_changes
628                .push(GpuOpacityKeyEvent::Removed(NodeId::new(i), OpacityKey::unique()));
629            source
630                .opacity_key_changes
631                .push(GpuOpacityKeyEvent::Removed(NodeId::new(i), OpacityKey::unique()));
632        }
633
634        target.merge(&mut source);
635        assert_eq!(target.opacity_key_changes.len(), 20_000);
636        assert!(source.is_empty());
637
638        // merging an already-drained source a second time must be a no-op, not a duplicate
639        target.merge(&mut source);
640        assert_eq!(target.opacity_key_changes.len(), 20_000);
641    }
642
643    // ---------------------------------------------------------------------
644    // apply_transform_events (private)
645    // ---------------------------------------------------------------------
646
647    #[test]
648    fn apply_transform_events_on_an_empty_slice_is_a_noop() {
649        let mut cache = GpuValueCache::empty();
650        cache.apply_transform_events(&[]);
651        assert!(cache.css_transform_keys.is_empty());
652        assert!(cache.css_current_transform_values.is_empty());
653    }
654
655    #[test]
656    fn apply_transform_events_keeps_keys_and_values_in_lockstep() {
657        let node = NodeId::new(3);
658        let key = TransformKey::unique();
659        let mut cache = GpuValueCache::empty();
660
661        cache.apply_transform_events(&[GpuTransformKeyEvent::Added(
662            node,
663            key,
664            ComputedTransform3D::IDENTITY,
665        )]);
666        assert_eq!(cache.css_transform_keys.get(&node), Some(&key));
667        assert_eq!(
668            cache.css_current_transform_values.get(&node),
669            Some(&ComputedTransform3D::IDENTITY)
670        );
671
672        // Changed must swap the value and *keep* the existing key (a new key here
673        // would orphan the old one on the GPU).
674        let scaled = scale_matrix(2.0);
675        cache.apply_transform_events(&[GpuTransformKeyEvent::Changed(
676            node,
677            key,
678            ComputedTransform3D::IDENTITY,
679            scaled,
680        )]);
681        assert_eq!(cache.css_transform_keys.get(&node), Some(&key));
682        assert_eq!(cache.css_current_transform_values.get(&node), Some(&scaled));
683
684        cache.apply_transform_events(&[GpuTransformKeyEvent::Removed(node, key)]);
685        assert!(cache.css_transform_keys.is_empty());
686        assert!(cache.css_current_transform_values.is_empty());
687    }
688
689    #[test]
690    fn removing_an_uncached_or_out_of_range_node_does_not_panic() {
691        let mut cache = GpuValueCache::empty();
692        // NodeId::new(usize::MAX) is never a valid DOM index — apply_* only hashes it,
693        // so it must be tolerated rather than used as an array index.
694        cache.apply_transform_events(&[
695            GpuTransformKeyEvent::Removed(NodeId::new(usize::MAX), TransformKey::unique()),
696            GpuTransformKeyEvent::Removed(NodeId::ZERO, TransformKey::unique()),
697        ]);
698        cache.apply_opacity_events(&[GpuOpacityKeyEvent::Removed(
699            NodeId::new(usize::MAX),
700            OpacityKey::unique(),
701        )]);
702        assert!(cache.css_transform_keys.is_empty());
703        assert!(cache.css_current_transform_values.is_empty());
704        assert!(cache.opacity_keys.is_empty());
705        assert!(cache.current_opacity_values.is_empty());
706    }
707
708    #[test]
709    fn transform_events_are_applied_in_slice_order_within_one_batch() {
710        let node = NodeId::new(2);
711        let first = TransformKey::unique();
712        let second = TransformKey::unique();
713
714        // Added -> Removed inside one batch must end up removed.
715        let mut cache = GpuValueCache::empty();
716        cache.apply_transform_events(&[
717            GpuTransformKeyEvent::Added(node, first, ComputedTransform3D::IDENTITY),
718            GpuTransformKeyEvent::Removed(node, first),
719        ]);
720        assert!(cache.css_transform_keys.is_empty());
721        assert!(cache.css_current_transform_values.is_empty());
722
723        // Removed -> Added inside one batch must end up added.
724        let mut cache = GpuValueCache::empty();
725        cache.apply_transform_events(&[
726            GpuTransformKeyEvent::Removed(node, first),
727            GpuTransformKeyEvent::Added(node, second, ComputedTransform3D::IDENTITY),
728        ]);
729        assert_eq!(cache.css_transform_keys.get(&node), Some(&second));
730    }
731
732    #[test]
733    fn two_added_events_for_one_node_keep_only_the_last_key() {
734        let node = NodeId::ZERO;
735        let first = TransformKey::unique();
736        let second = TransformKey::unique();
737        assert_ne!(
738            first, second,
739            "TransformKey::unique() must never hand out the same id twice"
740        );
741
742        let mut cache = GpuValueCache::empty();
743        cache.apply_transform_events(&[
744            GpuTransformKeyEvent::Added(node, first, ComputedTransform3D::IDENTITY),
745            GpuTransformKeyEvent::Added(node, second, scale_matrix(3.0)),
746        ]);
747        assert_eq!(cache.css_transform_keys.len(), 1);
748        assert_eq!(cache.css_transform_keys.get(&node), Some(&second));
749        assert_eq!(cache.css_current_transform_values.get(&node), Some(&scale_matrix(3.0)));
750    }
751
752    #[test]
753    fn a_changed_event_for_an_uncached_node_inserts_a_value_but_no_key() {
754        // compute_transform_events can never emit this (it `?`s on an existing key),
755        // but apply_transform_events takes an arbitrary slice and must not panic.
756        let node = NodeId::new(5);
757        let mut cache = GpuValueCache::empty();
758        cache.apply_transform_events(&[GpuTransformKeyEvent::Changed(
759            node,
760            TransformKey::unique(),
761            ComputedTransform3D::IDENTITY,
762            scale_matrix(2.0),
763        )]);
764        assert_eq!(cache.css_current_transform_values.get(&node), Some(&scale_matrix(2.0)));
765        assert!(
766            cache.css_transform_keys.is_empty(),
767            "Changed only writes the value map; the key map stays untouched"
768        );
769    }
770
771    #[test]
772    fn non_finite_matrices_are_stored_verbatim_and_never_compare_equal() {
773        let node = NodeId::new(1);
774        let mut cache = GpuValueCache::empty();
775        cache.apply_transform_events(&[GpuTransformKeyEvent::Added(
776            node,
777            TransformKey::unique(),
778            hostile_matrix(),
779        )]);
780
781        let stored = cache
782            .css_current_transform_values
783            .get(&node)
784            .copied()
785            .expect("the matrix must be cached even when it is full of NaN/Inf");
786        assert!(stored.m[0][0].is_nan());
787        assert!(stored.m[0][1].is_infinite() && stored.m[0][1].is_sign_positive());
788        assert!(stored.m[0][2].is_infinite() && stored.m[0][2].is_sign_negative());
789        assert_eq!(stored.m[0][3], f32::MIN);
790        assert_eq!(stored.m[1][0], f32::MAX);
791
792        // Consequence worth pinning: a NaN matrix is not PartialEq-equal to itself, so
793        // no caller may use `old == new` to suppress a redundant GPU update.
794        let a = hostile_matrix();
795        let b = hostile_matrix();
796        assert_ne!(a, b);
797    }
798
799    // ---------------------------------------------------------------------
800    // apply_opacity_events (private)
801    // ---------------------------------------------------------------------
802
803    #[test]
804    fn apply_opacity_events_add_change_remove_round_trip() {
805        let node = NodeId::new(4);
806        let key = OpacityKey::unique();
807        let mut cache = GpuValueCache::empty();
808
809        cache.apply_opacity_events(&[GpuOpacityKeyEvent::Added(node, key, 0.25)]);
810        assert_eq!(cache.opacity_keys.get(&node), Some(&key));
811        assert_eq!(cache.current_opacity_values.get(&node), Some(&0.25));
812
813        cache.apply_opacity_events(&[GpuOpacityKeyEvent::Changed(node, key, 0.25, 0.75)]);
814        assert_eq!(cache.opacity_keys.get(&node), Some(&key));
815        assert_eq!(cache.current_opacity_values.get(&node), Some(&0.75));
816
817        cache.apply_opacity_events(&[GpuOpacityKeyEvent::Removed(node, key)]);
818        assert!(cache.opacity_keys.is_empty());
819        assert!(cache.current_opacity_values.is_empty());
820
821        // a second Removed for the same node is a no-op, not a panic
822        cache.apply_opacity_events(&[GpuOpacityKeyEvent::Removed(node, key)]);
823        assert!(cache.opacity_keys.is_empty());
824    }
825
826    #[test]
827    fn apply_opacity_events_stores_out_of_range_values_verbatim() {
828        // The cache does no clamping of its own — pin that, so a future "helpful"
829        // clamp shows up as a test change rather than a silent behaviour change.
830        let cases = [
831            f32::NAN,
832            f32::INFINITY,
833            f32::NEG_INFINITY,
834            -1.0,
835            2.0,
836            1e30,
837            -0.0,
838        ];
839        let mut cache = GpuValueCache::empty();
840        for (i, value) in cases.iter().enumerate() {
841            cache.apply_opacity_events(&[GpuOpacityKeyEvent::Added(
842                NodeId::new(i),
843                OpacityKey::unique(),
844                *value,
845            )]);
846        }
847
848        assert_eq!(cache.current_opacity_values.len(), cases.len());
849        assert_eq!(cache.opacity_keys.len(), cache.current_opacity_values.len());
850        assert!(cache.current_opacity_values[&NodeId::new(0)].is_nan());
851        assert!(cache.current_opacity_values[&NodeId::new(1)].is_infinite());
852        assert_eq!(cache.current_opacity_values[&NodeId::new(3)], -1.0);
853        assert_eq!(cache.current_opacity_values[&NodeId::new(4)], 2.0);
854        assert_eq!(cache.current_opacity_values[&NodeId::new(5)], 1e30);
855    }
856
857    // ---------------------------------------------------------------------
858    // init_simd_features (private)
859    // ---------------------------------------------------------------------
860
861    #[test]
862    fn init_simd_features_is_idempotent() {
863        GpuValueCache::init_simd_features();
864        GpuValueCache::init_simd_features();
865
866        #[cfg(target_arch = "x86_64")]
867        {
868            assert!(
869                INITIALIZED.load(AtomicOrdering::SeqCst),
870                "the one-time init flag must be set after the first call"
871            );
872            let sse = USE_SSE.load(AtomicOrdering::SeqCst);
873            let avx = USE_AVX.load(AtomicOrdering::SeqCst);
874            GpuValueCache::init_simd_features();
875            assert_eq!(sse, USE_SSE.load(AtomicOrdering::SeqCst));
876            assert_eq!(avx, USE_AVX.load(AtomicOrdering::SeqCst));
877        }
878    }
879
880    // ---------------------------------------------------------------------
881    // synchronize / compute_* (against a real StyledDom)
882    // ---------------------------------------------------------------------
883
884    #[test]
885    fn synchronize_on_a_transform_and_opacity_free_dom_emits_nothing() {
886        let styled = plain_styled_dom();
887        let mut cache = GpuValueCache::empty();
888
889        let changes = cache.synchronize(&styled);
890
891        assert!(changes.is_empty());
892        assert!(cache.css_transform_keys.is_empty());
893        assert!(cache.opacity_keys.is_empty());
894    }
895
896    #[test]
897    fn synchronize_never_fills_scrollbar_opacity_changes() {
898        // Documented contract: scrollbar opacity is filled by a *separate* pass, so
899        // synchronize() must leave that vec empty (and not touch the scrollbar maps).
900        let styled = plain_styled_dom();
901        let mut cache = GpuValueCache::empty();
902        cache
903            .scrollbar_v_opacity_keys
904            .insert((DomId::ROOT_ID, NodeId::ZERO), OpacityKey::unique());
905        cache
906            .scrollbar_v_opacity_values
907            .insert((DomId::ROOT_ID, NodeId::ZERO), 0.5);
908
909        let changes = cache.synchronize(&styled);
910
911        assert!(changes.scrollbar_opacity_changes.is_empty());
912        assert_eq!(cache.scrollbar_v_opacity_keys.len(), 1);
913        assert_eq!(cache.scrollbar_v_opacity_values.len(), 1);
914    }
915
916    #[test]
917    fn opacity_round_trips_from_css_through_the_compact_cache_quantizer() {
918        // compact.rs encodes opacity as `(o * 254.0).round() as u8`; gpu.rs decodes it
919        // as `raw / 254.0`. Every value must survive that round-trip within one step,
920        // and must never leave [0, 1] (which WebRender would reject).
921        const STEP: f32 = 1.0 / 254.0;
922        for (css_value, expected) in [
923            ("0", 0.0f32),
924            ("0.25", 0.25),
925            ("0.5", 0.5),
926            ("1", 1.0),
927            ("50%", 0.5),
928            ("100%", 1.0),
929        ] {
930            let styled = styled_dom_from_css(&format!(".fade {{ opacity: {css_value}; }}"), "fade");
931            let mut cache = GpuValueCache::empty();
932
933            let changes = cache.synchronize(&styled);
934
935            let decoded = changes
936                .opacity_key_changes
937                .iter()
938                .find_map(|e| match e {
939                    GpuOpacityKeyEvent::Added(_, _, v) => Some(*v),
940                    _ => None,
941                })
942                .unwrap_or_else(|| panic!("`opacity: {css_value}` produced no Added event"));
943
944            assert!(
945                (decoded - expected).abs() <= STEP,
946                "`opacity: {css_value}` decoded as {decoded}, expected ~{expected}"
947            );
948            assert!(
949                (0.0..=1.0).contains(&decoded),
950                "`opacity: {css_value}` decoded to {decoded}, outside [0, 1]"
951            );
952        }
953    }
954
955    #[test]
956    fn re_synchronizing_an_unchanged_dom_never_mints_a_second_key() {
957        let styled = styled_dom_from_css(".fade { opacity: 0.5; }", "fade");
958        let mut cache = GpuValueCache::empty();
959
960        let first = cache.synchronize(&styled);
961        let added_first = first
962            .opacity_key_changes
963            .iter()
964            .filter(|e| matches!(e, GpuOpacityKeyEvent::Added(..)))
965            .count();
966        assert_eq!(added_first, 1, "the .fade div must get exactly one opacity key");
967        let keys_after_first = cache.opacity_keys.clone();
968
969        let second = cache.synchronize(&styled);
970
971        assert!(
972            !second
973                .opacity_key_changes
974                .iter()
975                .any(|e| matches!(e, GpuOpacityKeyEvent::Added(..))),
976            "re-syncing an unchanged DOM must not allocate a new OpacityKey (GPU key leak)"
977        );
978        assert_eq!(
979            cache.opacity_keys, keys_after_first,
980            "the OpacityKey of a node must be stable across syncs"
981        );
982    }
983
984    #[test]
985    fn synchronize_evicts_cached_keys_for_nodes_that_no_longer_exist() {
986        // Structural shrink: the DOM got smaller, so cached keys for now-missing nodes
987        // are never visited by the per-node loop and would leak on the GPU.
988        let styled = plain_styled_dom();
989        assert_eq!(styled.node_data.len(), 1);
990
991        let ghost_a = NodeId::new(9_999);
992        let ghost_b = NodeId::new(usize::MAX); // must be "out of range", not an overflow
993        let mut cache = GpuValueCache::empty();
994        for ghost in [ghost_a, ghost_b] {
995            cache.css_transform_keys.insert(ghost, TransformKey::unique());
996            cache
997                .css_current_transform_values
998                .insert(ghost, ComputedTransform3D::IDENTITY);
999            cache.opacity_keys.insert(ghost, OpacityKey::unique());
1000            cache.current_opacity_values.insert(ghost, 0.5);
1001        }
1002
1003        let changes = cache.synchronize(&styled);
1004
1005        assert_eq!(changes.transform_key_changes.len(), 2);
1006        assert_eq!(changes.opacity_key_changes.len(), 2);
1007        assert!(changes
1008            .transform_key_changes
1009            .iter()
1010            .all(|e| matches!(e, GpuTransformKeyEvent::Removed(..))));
1011        assert!(changes
1012            .opacity_key_changes
1013            .iter()
1014            .all(|e| matches!(e, GpuOpacityKeyEvent::Removed(..))));
1015        assert!(cache.css_transform_keys.is_empty());
1016        assert!(cache.css_current_transform_values.is_empty());
1017        assert!(cache.opacity_keys.is_empty());
1018        assert!(cache.current_opacity_values.is_empty());
1019    }
1020
1021    #[test]
1022    fn a_cached_opacity_is_evicted_when_the_node_loses_its_opacity() {
1023        // Node still exists, but the DOM no longer sets `opacity` on it: the
1024        // (Some(old), None) arm must fire a Removed so the OpacityKey is freed.
1025        let styled = plain_styled_dom();
1026        let node = NodeId::ZERO;
1027        let key = OpacityKey::unique();
1028
1029        let mut cache = GpuValueCache::empty();
1030        cache.opacity_keys.insert(node, key);
1031        cache.current_opacity_values.insert(node, 0.5);
1032
1033        let changes = cache.synchronize(&styled);
1034
1035        assert_eq!(
1036            changes.opacity_key_changes,
1037            vec![GpuOpacityKeyEvent::Removed(node, key)]
1038        );
1039        assert!(cache.opacity_keys.is_empty());
1040        assert!(cache.current_opacity_values.is_empty());
1041    }
1042
1043    #[test]
1044    fn a_cached_transform_is_evicted_when_the_node_loses_its_transform() {
1045        // Same scenario as the opacity test above, for transforms: the node is still in
1046        // the DOM but no longer carries a `transform` property (e.g. its class was
1047        // dropped between frames). The (Some(old), None) arm of compute_transform_events
1048        // must fire a Removed — otherwise the TransformKey leaks on the GPU and the
1049        // cache keeps serving a stale matrix forever.
1050        let styled = plain_styled_dom();
1051        let node = NodeId::ZERO;
1052        let key = TransformKey::unique();
1053
1054        let mut cache = GpuValueCache::empty();
1055        cache.css_transform_keys.insert(node, key);
1056        cache
1057            .css_current_transform_values
1058            .insert(node, ComputedTransform3D::IDENTITY);
1059
1060        let changes = cache.synchronize(&styled);
1061
1062        assert_eq!(
1063            changes.transform_key_changes,
1064            vec![GpuTransformKeyEvent::Removed(node, key)]
1065        );
1066        assert!(
1067            cache.css_transform_keys.is_empty(),
1068            "a stale CSS transform key was not evicted"
1069        );
1070        assert!(
1071            cache.css_current_transform_values.is_empty(),
1072            "a stale CSS transform value was not evicted"
1073        );
1074    }
1075
1076    #[test]
1077    fn synchronize_survives_malformed_and_unicode_css() {
1078        for css_src in [
1079            "",
1080            ".fade { opacity: ; }",
1081            ".fade { opacity: 🦀; }",
1082            ".fade { opacity: -1; }",
1083            ".fade { opacity: 99999999999; }",
1084            ".fade { opacity: NaN; }",
1085            ".fade { transform: }",
1086            ".fade { transform: rotate(NaN); }",
1087            ".fade { transform: rotate(1e400deg); }",
1088            ".fade { transform: rotate(); }",
1089        ] {
1090            let styled = styled_dom_from_css(css_src, "fade");
1091            let mut cache = GpuValueCache::empty();
1092
1093            let _ = cache.synchronize(&styled);
1094
1095            // Whatever the parser salvaged, the cache invariants must survive it.
1096            assert_eq!(
1097                cache.css_transform_keys.len(),
1098                cache.css_current_transform_values.len(),
1099                "transform key/value maps drifted apart (css: {css_src:?})"
1100            );
1101            assert_eq!(
1102                cache.opacity_keys.len(),
1103                cache.current_opacity_values.len(),
1104                "opacity key/value maps drifted apart (css: {css_src:?})"
1105            );
1106            for value in cache.current_opacity_values.values() {
1107                assert!(
1108                    !value.is_nan(),
1109                    "a NaN opacity reached the GPU cache (css: {css_src:?})"
1110                );
1111                assert!(
1112                    (0.0..=1.0).contains(value),
1113                    "an out-of-range opacity ({value}) reached the GPU cache (css: {css_src:?})"
1114                );
1115            }
1116        }
1117    }
1118
1119    #[test]
1120    fn synchronize_on_a_500_node_dom_keeps_every_key_in_range() {
1121        let css = azul_css::parser2::new_from_str(
1122            ".t { transform: rotate(45deg); } .o { opacity: 0.25; }",
1123        )
1124        .0;
1125        let children = (0..500usize)
1126            .map(|i| {
1127                let class = if i % 2 == 0 { "t" } else { "o" };
1128                Dom::create_div().with_class(class.to_string().into())
1129            })
1130            .collect::<Vec<Dom>>();
1131        let mut dom = Dom::create_body().with_children(children.into());
1132        let styled = StyledDom::create(&mut dom, css);
1133
1134        let mut cache = GpuValueCache::empty();
1135        let changes = cache.synchronize(&styled);
1136
1137        // On a fresh cache every event has to be an Added, so the event count and the
1138        // resulting key count must agree exactly.
1139        assert_eq!(
1140            changes.transform_key_changes.len(),
1141            cache.css_transform_keys.len()
1142        );
1143        assert_eq!(changes.opacity_key_changes.len(), cache.opacity_keys.len());
1144        assert_eq!(
1145            cache.css_transform_keys.len(),
1146            cache.css_current_transform_values.len()
1147        );
1148        assert_eq!(cache.opacity_keys.len(), cache.current_opacity_values.len());
1149
1150        // No cached key may point outside the DOM.
1151        let node_count = styled.node_data.len();
1152        assert!(cache.css_transform_keys.keys().all(|n| n.index() < node_count));
1153        assert!(cache.opacity_keys.keys().all(|n| n.index() < node_count));
1154    }
1155}