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