Skip to main content

fission_shell_winit/
pipeline.rs

1use crate::web_backend::WebSurfaceFrame;
2use anyhow::Result;
3use fission_core::diff::diff_ir;
4use fission_core::env::{AnimationStateMap, Env, VideoStateMap, WebStateMap};
5use fission_core::lowering::build_layout_tree;
6use fission_core::registry::AnimationPropertyId;
7use fission_core::scrollbar::scrollbar_geometry_for_node;
8use fission_core::ui::custom_render::downcast_render_object;
9use fission_core::{LayoutPoint, ScrollStateMap};
10use fission_diagnostics::prelude as diag;
11use fission_diagnostics::{SnapshotBlob, SnapshotKind, SnapshotProvider};
12use fission_ir::{
13    CompositeScalar, CoreIR, EmbedKind, FlexDirection, LayoutOp, NodeId, Op, WidgetNodeId,
14};
15use fission_layout::{LayoutEngine, LayoutInputNode, LayoutRect, LayoutSize, LayoutSnapshot};
16use fission_render::{
17    embed_surface_id, BoxShadow, Color as RenderColor, DisplayList, DisplayOp, Fill, LayerClip,
18    RenderLayer, RenderNode, RenderScene, Renderer, Stroke,
19};
20use fission_shell::VideoSurfaceFrame;
21use serde::Serialize;
22use std::collections::{HashMap, HashSet};
23use std::hash::{Hash, Hasher};
24#[cfg(not(target_arch = "wasm32"))]
25use std::time::Instant;
26#[cfg(target_arch = "wasm32")]
27use web_time::Instant;
28
29fn render_trace_enabled() -> bool {
30    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
31    *ENABLED.get_or_init(|| std::env::var("FISSION_RENDER_TRACE").is_ok())
32}
33
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct InvalidationSet {
36    pub build: bool,
37    pub layout: bool,
38    pub paint: bool,
39    pub composite: bool,
40}
41
42impl InvalidationSet {
43    pub fn mark_build(&mut self) {
44        self.build = true;
45        self.layout = true;
46        self.paint = true;
47        self.composite = true;
48    }
49
50    pub fn mark_layout(&mut self) {
51        self.layout = true;
52        self.paint = true;
53        self.composite = true;
54    }
55
56    pub fn mark_paint(&mut self) {
57        self.paint = true;
58        self.composite = true;
59    }
60
61    pub fn mark_composite(&mut self) {
62        self.composite = true;
63    }
64
65    pub fn merge(&mut self, other: Self) {
66        self.build |= other.build;
67        self.layout |= other.layout;
68        self.paint |= other.paint;
69        self.composite |= other.composite;
70    }
71
72    pub fn any(self) -> bool {
73        self.build || self.layout || self.paint || self.composite
74    }
75
76    pub fn highest_class(self) -> &'static str {
77        if self.build {
78            "build"
79        } else if self.layout {
80            "layout"
81        } else if self.paint {
82            "paint"
83        } else if self.composite {
84            "composite"
85        } else {
86            "none"
87        }
88    }
89
90    pub fn labels(self) -> Vec<&'static str> {
91        let mut labels = Vec::new();
92        if self.build {
93            labels.push("build");
94        }
95        if self.layout {
96            labels.push("layout");
97        }
98        if self.paint {
99            labels.push("paint");
100        }
101        if self.composite {
102            labels.push("composite");
103        }
104        if labels.is_empty() {
105            labels.push("none");
106        }
107        labels
108    }
109}
110
111#[derive(Debug, Clone)]
112struct BoundaryCacheEntry {
113    hash: u64,
114    layer: RenderLayer,
115}
116
117#[derive(Debug, Clone)]
118struct OpacityBinding {
119    layer_path: Vec<usize>,
120    scalar: CompositeScalar,
121}
122
123#[derive(Debug, Clone)]
124struct TransformBinding {
125    layer_path: Vec<usize>,
126    rect: LayoutRect,
127    layout_transform: Option<[f32; 16]>,
128    scroll: Option<ScrollTransform>,
129    translate_x: Option<CompositeScalar>,
130    translate_y: Option<CompositeScalar>,
131    scale: Option<CompositeScalar>,
132    rotation: Option<CompositeScalar>,
133}
134
135#[derive(Debug, Clone)]
136struct ScrollbarBinding {
137    node_path: Vec<usize>,
138    node_id: NodeId,
139}
140
141#[derive(Debug, Clone)]
142struct ScrollTransform {
143    node_id: NodeId,
144    direction: FlexDirection,
145}
146
147#[derive(Debug, Clone, Default)]
148struct RetainedDynamicOps {
149    opacity: Vec<OpacityBinding>,
150    transform: Vec<TransformBinding>,
151    scrollbar: Vec<ScrollbarBinding>,
152}
153
154#[derive(Debug, Clone)]
155pub struct CompositorTexturePlan {
156    pub key: u64,
157    pub bounds: LayoutRect,
158    pub scene: Option<RenderScene>,
159    pub scene_cache_key: Option<u64>,
160    pub content_key: u64,
161    pub local_dynamic: bool,
162    pub composite_dynamic: bool,
163    pub opacity: f32,
164    pub transform: Option<[f32; 16]>,
165    pub transform_clip: bool,
166    pub clip: Option<LayerClip>,
167    pub children: Vec<CompositorTexturePlan>,
168    pub source_layer_path: Option<Vec<usize>>,
169}
170
171pub struct Pipeline {
172    pub prev_ir: Option<CoreIR>,
173    pub last_snapshot: Option<LayoutSnapshot>,
174    pub paint_cache: HashMap<NodeId, (u64, DisplayList)>,
175    boundary_cache: HashMap<NodeId, BoundaryCacheEntry>,
176    pub last_scroll_offsets: HashMap<NodeId, u32>,
177    pub video_surfaces: Vec<VideoSurfaceFrame>,
178    pub web_surfaces: Vec<WebSurfaceFrame>,
179    pub scene_3d_surfaces: Vec<(WidgetNodeId, LayoutRect, Vec<u8>)>,
180    pub last_viewport: Option<LayoutRect>,
181    pub layout_invariant_violation_count: u32,
182    pub layout_full_rebuild_count: u32,
183    retained_scene: Option<RenderScene>,
184    retained_dynamic_ops: RetainedDynamicOps,
185    layout_input_nodes: Vec<LayoutInputNode>,
186    pending_layout_dirty_nodes: HashSet<NodeId>,
187    pending_layout_invalidated: bool,
188    pending_layout_full: bool,
189    compositor_animation_keys: HashSet<(WidgetNodeId, AnimationPropertyId)>,
190    runtime_dynamic_nodes: HashSet<NodeId>,
191    scroll_nodes: HashSet<NodeId>,
192    runtime_dynamic_subtrees: HashMap<NodeId, bool>,
193    retained_texture_plans: Vec<CompositorTexturePlan>,
194    retained_texture_root_transform: Option<[f32; 16]>,
195}
196
197pub struct PipelineStats {
198    pub dirty_nodes: usize,
199    pub layout_updates: usize,
200    pub paint_misses: usize,
201    pub paint_hits: usize,
202    pub video_surfaces: usize,
203}
204
205impl Pipeline {
206    pub fn new() -> Self {
207        Self {
208            prev_ir: None,
209            last_snapshot: None,
210            paint_cache: HashMap::new(),
211            boundary_cache: HashMap::new(),
212            last_scroll_offsets: HashMap::new(),
213            video_surfaces: Vec::new(),
214            web_surfaces: Vec::new(),
215            scene_3d_surfaces: Vec::new(),
216            last_viewport: None,
217            layout_invariant_violation_count: 0,
218            layout_full_rebuild_count: 0,
219            retained_scene: None,
220            retained_dynamic_ops: RetainedDynamicOps::default(),
221            layout_input_nodes: Vec::new(),
222            pending_layout_dirty_nodes: HashSet::new(),
223            pending_layout_invalidated: false,
224            pending_layout_full: true,
225            compositor_animation_keys: HashSet::new(),
226            runtime_dynamic_nodes: HashSet::new(),
227            scroll_nodes: HashSet::new(),
228            runtime_dynamic_subtrees: HashMap::new(),
229            retained_texture_plans: Vec::new(),
230            retained_texture_root_transform: None,
231        }
232    }
233
234    pub fn take_video_surfaces(&mut self) -> Vec<VideoSurfaceFrame> {
235        std::mem::take(&mut self.video_surfaces)
236    }
237
238    pub fn take_web_surfaces(&mut self) -> Vec<WebSurfaceFrame> {
239        std::mem::take(&mut self.web_surfaces)
240    }
241
242    pub fn invalidate_layout_all(&mut self) {
243        self.pending_layout_full = true;
244        self.pending_layout_dirty_nodes.clear();
245    }
246
247    pub fn replace_ir(&mut self, next_ir: CoreIR, env: &Env) -> InvalidationSet {
248        let mut invalidation = InvalidationSet::default();
249        let mut rebuild_layout_tree = self.prev_ir.is_none();
250
251        if let Some(prev_ir) = &self.prev_ir {
252            let diff = diff_ir(prev_ir, &next_ir);
253            if !diff.dirty_layout.is_empty() {
254                invalidation.mark_layout();
255                self.pending_layout_invalidated = true;
256                self.pending_layout_dirty_nodes.extend(diff.dirty_layout);
257            }
258            if !diff.dirty_paint.is_empty() {
259                invalidation.mark_paint();
260            }
261            if !diff.dirty_composite.is_empty() {
262                invalidation.mark_composite();
263            }
264            rebuild_layout_tree = rebuild_layout_tree || invalidation.layout;
265        } else {
266            invalidation.mark_build();
267            self.pending_layout_full = true;
268            self.pending_layout_dirty_nodes.clear();
269        }
270
271        if rebuild_layout_tree {
272            self.layout_input_nodes = build_layout_tree(&next_ir, env);
273        }
274
275        if invalidation.layout {
276            self.pending_layout_full |= self.prev_ir.is_none();
277            self.clear_render_caches();
278        } else if invalidation.paint || invalidation.composite {
279            self.clear_render_caches();
280        }
281
282        self.prev_ir = Some(next_ir);
283        self.refresh_retained_metadata();
284        invalidation
285    }
286
287    pub fn classify_animation_updates(
288        &self,
289        changed: &[(WidgetNodeId, AnimationPropertyId)],
290    ) -> InvalidationSet {
291        let mut invalidation = InvalidationSet::default();
292        for key in changed {
293            if self.compositor_animation_keys.contains(key) {
294                invalidation.mark_composite();
295            } else {
296                invalidation.mark_build();
297            }
298        }
299        invalidation
300    }
301
302    pub fn ensure_layout(
303        &mut self,
304        viewport: LayoutRect,
305        layout_engine: &mut LayoutEngine,
306        scroll_map: &ScrollStateMap,
307    ) -> Result<usize> {
308        let viewport_changed = self.last_viewport.map(|v| v != viewport).unwrap_or(true);
309        let needs_full =
310            self.pending_layout_full || self.last_snapshot.is_none() || viewport_changed;
311
312        if !needs_full && !self.pending_layout_invalidated {
313            self.last_viewport = Some(viewport);
314            return Ok(0);
315        }
316
317        let start_layout = Instant::now();
318        let dirty_layout_nodes = if needs_full {
319            self.layout_input_nodes.len()
320        } else {
321            self.pending_layout_dirty_nodes.len()
322        };
323        let (snapshot, full_rebuild) = if needs_full {
324            self.layout_full_rebuild_count = self.layout_full_rebuild_count.saturating_add(1);
325            layout_engine.update(&self.layout_input_nodes);
326            let root_id = self
327                .prev_ir
328                .as_ref()
329                .and_then(|ir| ir.root)
330                .expect("no root in IR");
331            (
332                layout_engine.compute_layout(
333                    &self.layout_input_nodes,
334                    root_id,
335                    viewport.size,
336                    &|id| scroll_map.get_offset(id),
337                )?,
338                true,
339            )
340        } else {
341            layout_engine.update(&self.layout_input_nodes);
342            let root_id = self
343                .prev_ir
344                .as_ref()
345                .and_then(|ir| ir.root)
346                .expect("no root in IR");
347            (
348                layout_engine.compute_layout_incremental(
349                    &self.layout_input_nodes,
350                    root_id,
351                    viewport.size,
352                    &|id| scroll_map.get_offset(id),
353                    self.last_snapshot
354                        .as_ref()
355                        .expect("incremental layout requires a prior snapshot"),
356                    &self.pending_layout_dirty_nodes,
357                )?,
358                false,
359            )
360        };
361        self.last_snapshot = Some(snapshot);
362        self.last_viewport = Some(viewport);
363        self.pending_layout_dirty_nodes.clear();
364        self.pending_layout_invalidated = false;
365        self.pending_layout_full = false;
366        self.clear_render_caches();
367
368        let duration = start_layout.elapsed().as_nanos() as u64;
369        diag::emit(
370            diag::DiagCategory::Layout,
371            diag::DiagLevel::Debug,
372            diag::DiagEventKind::LayoutSummary {
373                nodes: self.layout_input_nodes.len() as u32,
374                dirty_count: dirty_layout_nodes as u32,
375                full_rebuild,
376                duration_ns: duration,
377            },
378        );
379
380        Ok(dirty_layout_nodes)
381    }
382
383    pub fn prepare_current(
384        &mut self,
385        render_viewport_size: LayoutSize,
386        layout_viewport_size: LayoutSize,
387        resize_preview: bool,
388        scroll_map: &ScrollStateMap,
389        animation_map: &AnimationStateMap,
390        video_map: &VideoStateMap,
391        web_map: &WebStateMap,
392    ) -> Result<PipelineStats> {
393        let render_viewport = LayoutRect::new(
394            0.0,
395            0.0,
396            render_viewport_size.width,
397            render_viewport_size.height,
398        );
399        let mut stats = PipelineStats {
400            dirty_nodes: if self.pending_layout_full || self.pending_layout_invalidated {
401                if self.pending_layout_full {
402                    self.layout_input_nodes.len()
403                } else {
404                    self.pending_layout_dirty_nodes.len()
405                }
406            } else {
407                0
408            },
409            layout_updates: 0,
410            paint_misses: 0,
411            paint_hits: 0,
412            video_surfaces: 0,
413        };
414
415        let ir = self.prev_ir.as_ref().expect("ir missing before render");
416        let snapshot = self
417            .last_snapshot
418            .as_ref()
419            .expect("snapshot missing before render");
420
421        self.video_surfaces.clear();
422        self.web_surfaces.clear();
423        self.scene_3d_surfaces.clear();
424        if let Some(root) = ir.root {
425            collect_video_surfaces(
426                root,
427                ir,
428                snapshot,
429                video_map,
430                web_map,
431                scroll_map,
432                LayoutPoint::ZERO,
433                &mut self.video_surfaces,
434                &mut self.web_surfaces,
435                &mut self.scene_3d_surfaces,
436            );
437        }
438        stats.video_surfaces = self.video_surfaces.len();
439
440        if self.retained_scene.is_none() {
441            if render_trace_enabled() {
442                eprintln!("[pipeline] rebuilding retained render scene");
443            }
444            if let Some(root) = ir.root {
445                let mut visited = HashSet::new();
446                let mut bindings = RetainedDynamicOps::default();
447                let content_root = generate_render_layer_recursive(
448                    root,
449                    ir,
450                    snapshot,
451                    scroll_map,
452                    animation_map,
453                    &mut self.paint_cache,
454                    &mut self.boundary_cache,
455                    &self.runtime_dynamic_subtrees,
456                    &mut stats.paint_misses,
457                    &mut stats.paint_hits,
458                    true,
459                    &mut visited,
460                    &mut bindings,
461                    vec![0, 0],
462                );
463                if let Some(content_root) = content_root {
464                    let mut presentation_root = RenderLayer::new(render_viewport);
465                    presentation_root.style.clip = Some(LayerClip::Rect(render_viewport));
466                    presentation_root
467                        .children
468                        .push(RenderNode::Layer(content_root));
469
470                    let mut scene = RenderScene::new(render_viewport);
471                    scene.roots.push(RenderNode::Layer(presentation_root));
472                    self.retained_scene = Some(scene);
473                    self.retained_dynamic_ops = bindings;
474                }
475            }
476        }
477
478        self.patch_retained_scene(
479            render_viewport_size,
480            layout_viewport_size,
481            resize_preview,
482            scroll_map,
483            animation_map,
484        );
485        let scene = self
486            .retained_scene
487            .as_ref()
488            .expect("retained render scene missing before render");
489        self.retained_texture_root_transform = scene.roots.first().and_then(|root| match root {
490            RenderNode::Layer(layer) => layer.style.transform,
491            RenderNode::Paint(_) => None,
492        });
493        if self.retained_texture_plans.is_empty() {
494            self.retained_texture_plans = self.build_texture_compositor_plans(scene);
495        } else {
496            patch_texture_compositor_plans(&mut self.retained_texture_plans, scene);
497        }
498
499        diag::emit(
500            diag::DiagCategory::Layout,
501            diag::DiagLevel::Debug,
502            diag::DiagEventKind::PaintSummary {
503                segments_reused: stats.paint_hits as u32,
504                segments_regenerated: stats.paint_misses as u32,
505                paint_ops_total: count_render_paint_ops(scene) as u32,
506            },
507        );
508
509        self.last_scroll_offsets = scroll_map
510            .offsets
511            .iter()
512            .map(|(id, offset)| (*id, offset.to_bits()))
513            .collect();
514
515        Ok(stats)
516    }
517
518    pub fn render_current(
519        &mut self,
520        render_viewport_size: LayoutSize,
521        layout_viewport_size: LayoutSize,
522        resize_preview: bool,
523        renderer: &mut dyn Renderer,
524        scroll_map: &ScrollStateMap,
525        animation_map: &AnimationStateMap,
526        video_map: &VideoStateMap,
527        web_map: &WebStateMap,
528    ) -> Result<PipelineStats> {
529        let stats = self.prepare_current(
530            render_viewport_size,
531            layout_viewport_size,
532            resize_preview,
533            scroll_map,
534            animation_map,
535            video_map,
536            web_map,
537        )?;
538        let scene = self
539            .retained_scene
540            .as_ref()
541            .expect("retained render scene missing before render");
542        renderer.render_scene(scene)?;
543        Ok(stats)
544    }
545
546    pub fn render(
547        &mut self,
548        next_ir: CoreIR,
549        viewport_size: LayoutSize,
550        layout_engine: &mut LayoutEngine,
551        scroll_map: &ScrollStateMap,
552        renderer: &mut dyn Renderer,
553        video_map: &VideoStateMap,
554        web_map: &WebStateMap,
555        env: &Env,
556    ) -> Result<PipelineStats> {
557        self.replace_ir(next_ir, env);
558        let viewport = LayoutRect::new(0.0, 0.0, viewport_size.width, viewport_size.height);
559        let layout_updates = self.ensure_layout(viewport, layout_engine, scroll_map)?;
560        let mut stats = self.render_current(
561            viewport_size,
562            viewport_size,
563            false,
564            renderer,
565            scroll_map,
566            &AnimationStateMap::default(),
567            video_map,
568            web_map,
569        )?;
570        stats.layout_updates = layout_updates;
571        Ok(stats)
572    }
573
574    fn refresh_retained_metadata(&mut self) {
575        self.compositor_animation_keys.clear();
576        self.runtime_dynamic_nodes.clear();
577        self.scroll_nodes.clear();
578        self.runtime_dynamic_subtrees.clear();
579        self.boundary_cache.clear();
580
581        let Some(ir) = self.prev_ir.as_ref() else {
582            return;
583        };
584
585        for node in ir.nodes.values() {
586            let mut node_is_runtime_dynamic =
587                matches!(node.op, Op::Layout(LayoutOp::Scroll { .. }));
588            if matches!(node.op, Op::Layout(LayoutOp::Scroll { .. })) {
589                self.scroll_nodes.insert(node.id);
590            }
591            if ir
592                .custom_render_objects
593                .get(&node.id)
594                .and_then(downcast_render_object)
595                .is_some_and(|render_object| render_object.is_runtime_dynamic())
596            {
597                node_is_runtime_dynamic = true;
598            }
599            if let Some(target) = node
600                .composite
601                .opacity
602                .as_ref()
603                .and_then(|value| value.animation_target)
604            {
605                self.compositor_animation_keys
606                    .insert((target, AnimationPropertyId::Opacity));
607                node_is_runtime_dynamic = true;
608            }
609            if let Some(target) = node
610                .composite
611                .translate_x
612                .as_ref()
613                .and_then(|value| value.animation_target)
614            {
615                self.compositor_animation_keys
616                    .insert((target, AnimationPropertyId::TranslateX));
617                node_is_runtime_dynamic = true;
618            }
619            if let Some(target) = node
620                .composite
621                .translate_y
622                .as_ref()
623                .and_then(|value| value.animation_target)
624            {
625                self.compositor_animation_keys
626                    .insert((target, AnimationPropertyId::TranslateY));
627                node_is_runtime_dynamic = true;
628            }
629            if let Some(target) = node
630                .composite
631                .scale
632                .as_ref()
633                .and_then(|value| value.animation_target)
634            {
635                self.compositor_animation_keys
636                    .insert((target, AnimationPropertyId::Scale));
637                node_is_runtime_dynamic = true;
638            }
639            if let Some(target) = node
640                .composite
641                .rotation
642                .as_ref()
643                .and_then(|value| value.animation_target)
644            {
645                self.compositor_animation_keys
646                    .insert((target, AnimationPropertyId::Rotation));
647                node_is_runtime_dynamic = true;
648            }
649            if node_is_runtime_dynamic {
650                self.runtime_dynamic_nodes.insert(node.id);
651            }
652        }
653
654        if let Some(root) = ir.root {
655            let mut memo = HashMap::new();
656            let _ = self.compute_runtime_dynamic_subtree(root, ir, &mut memo);
657            self.runtime_dynamic_subtrees = memo;
658        }
659    }
660
661    fn compute_runtime_dynamic_subtree(
662        &self,
663        node_id: NodeId,
664        ir: &CoreIR,
665        memo: &mut HashMap<NodeId, bool>,
666    ) -> bool {
667        if let Some(cached) = memo.get(&node_id) {
668            return *cached;
669        }
670
671        let Some(node) = ir.nodes.get(&node_id) else {
672            memo.insert(node_id, false);
673            return false;
674        };
675
676        let mut dynamic = matches!(node.op, Op::Layout(LayoutOp::Scroll { .. }));
677        dynamic |= node
678            .composite
679            .opacity
680            .as_ref()
681            .and_then(|value| value.animation_target)
682            .is_some();
683        dynamic |= node
684            .composite
685            .translate_x
686            .as_ref()
687            .and_then(|value| value.animation_target)
688            .is_some();
689        dynamic |= node
690            .composite
691            .translate_y
692            .as_ref()
693            .and_then(|value| value.animation_target)
694            .is_some();
695        dynamic |= node
696            .composite
697            .scale
698            .as_ref()
699            .and_then(|value| value.animation_target)
700            .is_some();
701        dynamic |= node
702            .composite
703            .rotation
704            .as_ref()
705            .and_then(|value| value.animation_target)
706            .is_some();
707
708        for child in &node.children {
709            dynamic |= self.compute_runtime_dynamic_subtree(*child, ir, memo);
710        }
711
712        memo.insert(node_id, dynamic);
713        dynamic
714    }
715
716    fn clear_render_caches(&mut self) {
717        if render_trace_enabled() {
718            eprintln!(
719                "[pipeline] clear_render_caches layout_full={} layout_invalidated={} retained_was_present={}",
720                self.pending_layout_full,
721                self.pending_layout_invalidated,
722                self.retained_scene.is_some()
723            );
724        }
725        self.paint_cache.clear();
726        self.boundary_cache.clear();
727        self.retained_scene = None;
728        self.retained_dynamic_ops = RetainedDynamicOps::default();
729        self.retained_texture_plans.clear();
730        self.retained_texture_root_transform = None;
731    }
732
733    fn patch_retained_scene(
734        &mut self,
735        render_viewport_size: LayoutSize,
736        layout_viewport_size: LayoutSize,
737        resize_preview: bool,
738        scroll_map: &ScrollStateMap,
739        animation_map: &AnimationStateMap,
740    ) {
741        let Some(scene) = self.retained_scene.as_mut() else {
742            return;
743        };
744
745        scene.bounds = LayoutRect::new(
746            0.0,
747            0.0,
748            render_viewport_size.width,
749            render_viewport_size.height,
750        );
751        let scene_bounds = scene.bounds;
752        if let Some(presentation_layer) = layer_mut_at_path(scene, &[0]) {
753            presentation_layer.bounds = scene_bounds;
754            presentation_layer.style.clip = Some(LayerClip::Rect(scene_bounds));
755            presentation_layer.style.transform = presentation_transform_matrix(
756                render_viewport_size,
757                layout_viewport_size,
758                resize_preview,
759            );
760        }
761
762        for binding in &self.retained_dynamic_ops.opacity {
763            let alpha =
764                resolve_scalar_value(&binding.scalar, animation_map, AnimationPropertyId::Opacity);
765            if let Some(layer) = layer_mut_at_path(scene, &binding.layer_path) {
766                layer.style.opacity = alpha;
767            }
768        }
769
770        for binding in &self.retained_dynamic_ops.transform {
771            if let Some(layer) = layer_mut_at_path(scene, &binding.layer_path) {
772                layer.style.transform =
773                    compose_dynamic_layer_transform(binding, scroll_map, animation_map);
774            }
775        }
776
777        let Some(ir) = self.prev_ir.as_ref() else {
778            return;
779        };
780        let Some(snapshot) = self.last_snapshot.as_ref() else {
781            return;
782        };
783        for binding in &self.retained_dynamic_ops.scrollbar {
784            let Some(scrollbar) = build_scrollbar_paint(ir, binding.node_id, snapshot, scroll_map)
785            else {
786                continue;
787            };
788            if let Some(RenderNode::Paint(list)) =
789                render_node_mut_at_path(scene, &binding.node_path)
790            {
791                *list = scrollbar;
792            }
793        }
794    }
795
796    pub fn retained_scene(&self) -> Option<&RenderScene> {
797        self.retained_scene.as_ref()
798    }
799
800    pub fn texture_compositor_plans(&self) -> &[CompositorTexturePlan] {
801        &self.retained_texture_plans
802    }
803
804    pub fn texture_compositor_root_transform(&self) -> Option<[f32; 16]> {
805        self.retained_texture_root_transform
806    }
807
808    fn build_texture_compositor_plans(&self, scene: &RenderScene) -> Vec<CompositorTexturePlan> {
809        let Some(split_layer_path) = find_texture_compositor_split_layer_path(scene) else {
810            return Vec::new();
811        };
812        let Some(split_layer) = layer_ref_at_path(scene, &split_layer_path) else {
813            return Vec::new();
814        };
815        let mut plans = Vec::new();
816        for (child_index, child) in split_layer.children.iter().enumerate() {
817            let mut child_path = split_layer_path.clone();
818            child_path.push(child_index);
819            if let Some(plan) = build_texture_plan_from_node(
820                child,
821                &child_path,
822                true,
823                &self.runtime_dynamic_nodes,
824                &self.scroll_nodes,
825                &self.runtime_dynamic_subtrees,
826            ) {
827                plans.push(plan);
828            }
829        }
830        if render_trace_enabled() {
831            for plan in &plans {
832                log_texture_plan(plan, 0);
833            }
834        }
835        plans
836    }
837}
838
839fn log_texture_plan(plan: &CompositorTexturePlan, depth: usize) {
840    let indent = "  ".repeat(depth);
841    eprintln!(
842        "[pipeline] {}plan key={} bounds=({}, {}, {}x{}) scene={} clip={} transform=({:.1},{:.1}) transform_clip={} children={}",
843        indent,
844        plan.key,
845        plan.bounds.origin.x,
846        plan.bounds.origin.y,
847        plan.bounds.size.width,
848        plan.bounds.size.height,
849        plan.scene.is_some(),
850        plan.clip.is_some(),
851        plan.transform.map(|m| m[12]).unwrap_or(0.0),
852        plan.transform.map(|m| m[13]).unwrap_or(0.0),
853        plan.transform_clip,
854        plan.children.len()
855    );
856    for child in &plan.children {
857        log_texture_plan(child, depth + 1);
858    }
859}
860
861fn layer_mut_at_path<'a>(
862    scene: &'a mut RenderScene,
863    path: &[usize],
864) -> Option<&'a mut RenderLayer> {
865    let (root_index, tail) = path.split_first()?;
866    let node = scene.roots.get_mut(*root_index)?;
867    layer_mut_in_node(node, tail)
868}
869
870fn render_node_mut_at_path<'a>(
871    scene: &'a mut RenderScene,
872    path: &[usize],
873) -> Option<&'a mut RenderNode> {
874    let (root_index, tail) = path.split_first()?;
875    let node = scene.roots.get_mut(*root_index)?;
876    render_node_mut_in_node(node, tail)
877}
878
879fn render_node_mut_in_node<'a>(
880    node: &'a mut RenderNode,
881    path: &[usize],
882) -> Option<&'a mut RenderNode> {
883    if path.is_empty() {
884        return Some(node);
885    }
886    match node {
887        RenderNode::Layer(layer) => {
888            let (child_index, tail) = path.split_first()?;
889            let child = layer.children.get_mut(*child_index)?;
890            render_node_mut_in_node(child, tail)
891        }
892        RenderNode::Paint(_) => None,
893    }
894}
895
896fn layer_mut_in_node<'a>(node: &'a mut RenderNode, path: &[usize]) -> Option<&'a mut RenderLayer> {
897    match node {
898        RenderNode::Layer(layer) => {
899            if path.is_empty() {
900                return Some(layer);
901            }
902            let (child_index, tail) = path.split_first()?;
903            let child = layer.children.get_mut(*child_index)?;
904            layer_mut_in_node(child, tail)
905        }
906        RenderNode::Paint(_) => None,
907    }
908}
909
910fn layer_ref_at_path<'a>(scene: &'a RenderScene, path: &[usize]) -> Option<&'a RenderLayer> {
911    let (root_index, tail) = path.split_first()?;
912    let node = scene.roots.get(*root_index)?;
913    layer_ref_in_node(node, tail)
914}
915
916fn layer_ref_in_node<'a>(node: &'a RenderNode, path: &[usize]) -> Option<&'a RenderLayer> {
917    match node {
918        RenderNode::Layer(layer) => {
919            if path.is_empty() {
920                return Some(layer);
921            }
922            let (child_index, tail) = path.split_first()?;
923            let child = layer.children.get(*child_index)?;
924            layer_ref_in_node(child, tail)
925        }
926        RenderNode::Paint(_) => None,
927    }
928}
929
930fn count_render_paint_ops(scene: &RenderScene) -> usize {
931    scene.roots.iter().map(count_render_node_paint_ops).sum()
932}
933
934fn count_render_node_paint_ops(node: &RenderNode) -> usize {
935    match node {
936        RenderNode::Paint(list) => list.ops.len(),
937        RenderNode::Layer(layer) => layer.children.iter().map(count_render_node_paint_ops).sum(),
938    }
939}
940
941fn render_node_bounds(node: &RenderNode) -> LayoutRect {
942    match node {
943        RenderNode::Paint(list) => list.bounds,
944        RenderNode::Layer(layer) => layer.bounds,
945    }
946}
947
948fn find_texture_compositor_split_layer_path(scene: &RenderScene) -> Option<Vec<usize>> {
949    let Some(RenderNode::Layer(presentation_root)) = scene.roots.first() else {
950        return None;
951    };
952    if presentation_root.children.len() != 1 {
953        return None;
954    }
955    let Some(RenderNode::Layer(layer)) = presentation_root.children.first() else {
956        return None;
957    };
958    let mut layer = layer;
959    let mut path = vec![0, 0];
960    loop {
961        let only_child = match layer.children.as_slice() {
962            [RenderNode::Layer(child)] => Some(child),
963            _ => None,
964        };
965        let is_plain_wrapper = layer.style.clip.is_none()
966            && (layer.style.opacity - 1.0).abs() <= 0.001
967            && layer.style.transform.is_none();
968        if let (true, Some(child)) = (is_plain_wrapper, only_child) {
969            layer = child;
970            path.push(0);
971        } else {
972            return Some(path);
973        }
974    }
975}
976
977#[derive(Debug)]
978struct TexturePlanCandidate<'a> {
979    node: &'a RenderNode,
980    path: Vec<usize>,
981}
982
983fn build_texture_plan_from_node(
984    node: &RenderNode,
985    node_path: &[usize],
986    force: bool,
987    runtime_dynamic_nodes: &HashSet<NodeId>,
988    scroll_nodes: &HashSet<NodeId>,
989    runtime_dynamic_subtrees: &HashMap<NodeId, bool>,
990) -> Option<CompositorTexturePlan> {
991    let candidate = find_nested_texture_plan_candidate(
992        node,
993        node_path,
994        force,
995        runtime_dynamic_nodes,
996        scroll_nodes,
997        runtime_dynamic_subtrees,
998    )?;
999    let bounds = render_node_bounds(candidate.node);
1000    if bounds.size.width <= 0.0 || bounds.size.height <= 0.0 {
1001        return None;
1002    }
1003
1004    match candidate.node {
1005        RenderNode::Paint(list) => {
1006            let scene = localized_scene_for_compositor_children(
1007                vec![RenderNode::Paint(list.clone())],
1008                bounds,
1009            );
1010            let scene_cache_key = scene_cache_key(&scene);
1011            let content_key = plan_content_key(Some(scene_cache_key), &[]);
1012            Some(CompositorTexturePlan {
1013                key: texture_plan_key_for_paint(list),
1014                bounds,
1015                scene: Some(scene),
1016                scene_cache_key: Some(scene_cache_key),
1017                content_key,
1018                local_dynamic: false,
1019                composite_dynamic: false,
1020                opacity: 1.0,
1021                transform: None,
1022                transform_clip: true,
1023                clip: None,
1024                children: Vec::new(),
1025                source_layer_path: None,
1026            })
1027        }
1028        RenderNode::Layer(layer) => {
1029            let wrapper_only_scroll_plan = !layer.style.transform_clip;
1030            let mut child_plans = Vec::new();
1031            let mut local_children = Vec::new();
1032            for (child_index, child) in layer.children.iter().enumerate() {
1033                let mut child_path = candidate.path.clone();
1034                child_path.push(child_index);
1035                if wrapper_only_scroll_plan {
1036                    child_plans.extend(build_descending_wrapper_plans(
1037                        child,
1038                        &child_path,
1039                        runtime_dynamic_nodes,
1040                        scroll_nodes,
1041                        runtime_dynamic_subtrees,
1042                    ));
1043                } else {
1044                    if let Some(child_plan) = build_texture_plan_from_node(
1045                        child,
1046                        &child_path,
1047                        false,
1048                        runtime_dynamic_nodes,
1049                        scroll_nodes,
1050                        runtime_dynamic_subtrees,
1051                    ) {
1052                        child_plans.push(child_plan);
1053                    } else {
1054                        local_children.push(child.clone());
1055                    }
1056                }
1057            }
1058
1059            let local_dynamic = local_children
1060                .iter()
1061                .any(|child| render_node_or_subtree_is_dynamic(child, runtime_dynamic_subtrees));
1062            let scene = if local_children.is_empty() {
1063                None
1064            } else {
1065                Some(localized_scene_for_compositor_children(
1066                    local_children,
1067                    bounds,
1068                ))
1069            };
1070            let scene_cache_key = if scene.is_none() {
1071                None
1072            } else {
1073                layer
1074                    .style
1075                    .content_cache_key
1076                    .or(layer.style.cache_key)
1077                    .or_else(|| scene.as_ref().map(scene_cache_key))
1078            };
1079            let content_key = plan_content_key(scene_cache_key, &child_plans);
1080            let composite_dynamic = layer
1081                .node_id
1082                .map(|id| runtime_dynamic_nodes.contains(&id))
1083                .unwrap_or(false);
1084            Some(CompositorTexturePlan {
1085                key: texture_plan_key_for_layer(layer),
1086                bounds,
1087                scene,
1088                scene_cache_key,
1089                content_key,
1090                local_dynamic,
1091                composite_dynamic,
1092                opacity: layer.style.opacity,
1093                transform: layer.style.transform,
1094                transform_clip: layer.style.transform_clip,
1095                clip: layer.style.clip.clone(),
1096                children: child_plans,
1097                source_layer_path: Some(candidate.path),
1098            })
1099        }
1100    }
1101}
1102
1103fn build_descending_wrapper_plans(
1104    node: &RenderNode,
1105    node_path: &[usize],
1106    runtime_dynamic_nodes: &HashSet<NodeId>,
1107    scroll_nodes: &HashSet<NodeId>,
1108    runtime_dynamic_subtrees: &HashMap<NodeId, bool>,
1109) -> Vec<CompositorTexturePlan> {
1110    match node {
1111        RenderNode::Paint(_) => build_texture_plan_from_node(
1112            node,
1113            node_path,
1114            true,
1115            runtime_dynamic_nodes,
1116            scroll_nodes,
1117            runtime_dynamic_subtrees,
1118        )
1119        .into_iter()
1120        .collect(),
1121        RenderNode::Layer(layer) => {
1122            let mut children = Vec::new();
1123            for (child_index, child) in layer.children.iter().enumerate() {
1124                let mut child_path = node_path.to_vec();
1125                child_path.push(child_index);
1126                children.extend(build_descending_wrapper_plans(
1127                    child,
1128                    &child_path,
1129                    runtime_dynamic_nodes,
1130                    scroll_nodes,
1131                    runtime_dynamic_subtrees,
1132                ));
1133            }
1134
1135            if children.is_empty() {
1136                return build_texture_plan_from_node(
1137                    node,
1138                    node_path,
1139                    true,
1140                    runtime_dynamic_nodes,
1141                    scroll_nodes,
1142                    runtime_dynamic_subtrees,
1143                )
1144                .into_iter()
1145                .collect();
1146            }
1147
1148            let composite_dynamic = layer
1149                .node_id
1150                .map(|id| runtime_dynamic_nodes.contains(&id))
1151                .unwrap_or(false);
1152            vec![CompositorTexturePlan {
1153                key: texture_plan_key_for_layer(layer),
1154                bounds: layer.bounds,
1155                scene: None,
1156                scene_cache_key: None,
1157                content_key: plan_content_key(None, &children),
1158                local_dynamic: false,
1159                composite_dynamic,
1160                opacity: layer.style.opacity,
1161                transform: layer.style.transform,
1162                transform_clip: layer.style.transform_clip,
1163                clip: layer.style.clip.clone(),
1164                children,
1165                source_layer_path: Some(node_path.to_vec()),
1166            }]
1167        }
1168    }
1169}
1170
1171fn find_nested_texture_plan_candidate<'a>(
1172    node: &'a RenderNode,
1173    node_path: &[usize],
1174    force: bool,
1175    runtime_dynamic_nodes: &HashSet<NodeId>,
1176    scroll_nodes: &HashSet<NodeId>,
1177    runtime_dynamic_subtrees: &HashMap<NodeId, bool>,
1178) -> Option<TexturePlanCandidate<'a>> {
1179    match node {
1180        RenderNode::Paint(_) => force.then_some(TexturePlanCandidate {
1181            node,
1182            path: node_path.to_vec(),
1183        }),
1184        RenderNode::Layer(layer) => {
1185            if !force {
1186                if let Some(child) = descend_through_plain_wrapper(layer) {
1187                    let mut child_path = node_path.to_vec();
1188                    child_path.push(0);
1189                    return find_nested_texture_plan_candidate(
1190                        child,
1191                        &child_path,
1192                        false,
1193                        runtime_dynamic_nodes,
1194                        scroll_nodes,
1195                        runtime_dynamic_subtrees,
1196                    );
1197                }
1198            }
1199
1200            let subtree_dynamic = render_node_or_subtree_is_dynamic(node, runtime_dynamic_subtrees);
1201            let own_dynamic = layer
1202                .node_id
1203                .map(|id| runtime_dynamic_nodes.contains(&id))
1204                .unwrap_or(false);
1205            let is_scroll_node = layer
1206                .node_id
1207                .map(|id| scroll_nodes.contains(&id))
1208                .unwrap_or(false);
1209            if force
1210                || layer_should_extract_as_plan(layer, subtree_dynamic, own_dynamic, is_scroll_node)
1211            {
1212                Some(TexturePlanCandidate {
1213                    node,
1214                    path: node_path.to_vec(),
1215                })
1216            } else {
1217                for (child_index, child) in layer.children.iter().enumerate() {
1218                    let mut child_path = node_path.to_vec();
1219                    child_path.push(child_index);
1220                    if let Some(candidate) = find_nested_texture_plan_candidate(
1221                        child,
1222                        &child_path,
1223                        false,
1224                        runtime_dynamic_nodes,
1225                        scroll_nodes,
1226                        runtime_dynamic_subtrees,
1227                    ) {
1228                        return Some(candidate);
1229                    }
1230                }
1231                None
1232            }
1233        }
1234    }
1235}
1236
1237fn descend_through_plain_wrapper<'a>(layer: &'a RenderLayer) -> Option<&'a RenderNode> {
1238    let only_child = match layer.children.as_slice() {
1239        [child] => Some(child),
1240        _ => None,
1241    }?;
1242    if layer.style.clip.is_none()
1243        && (layer.style.opacity - 1.0).abs() <= 0.001
1244        && layer.style.transform.is_none()
1245    {
1246        match only_child {
1247            RenderNode::Layer(_) => Some(only_child),
1248            RenderNode::Paint(_) => None,
1249        }
1250    } else {
1251        None
1252    }
1253}
1254
1255fn layer_should_extract_as_plan(
1256    layer: &RenderLayer,
1257    subtree_dynamic: bool,
1258    own_dynamic: bool,
1259    is_scroll_node: bool,
1260) -> bool {
1261    const MIN_PLAN_AREA: f32 = 64.0 * 64.0;
1262    if layer.children.is_empty() {
1263        return false;
1264    }
1265    if is_scroll_node {
1266        return false;
1267    }
1268    if own_dynamic {
1269        return true;
1270    }
1271    if !subtree_dynamic {
1272        return false;
1273    }
1274    let has_style = layer.style.clip.is_some()
1275        || (layer.style.opacity - 1.0).abs() > 0.001
1276        || layer.style.transform.is_some();
1277    let has_local_paint = layer
1278        .children
1279        .iter()
1280        .any(|child| matches!(child, RenderNode::Paint(_)));
1281    let has_multiple_children = layer.children.len() > 1;
1282    (has_style || has_local_paint || has_multiple_children)
1283        && layer.bounds.size.width * layer.bounds.size.height >= MIN_PLAN_AREA
1284}
1285
1286fn localized_scene_for_compositor_children(
1287    children: Vec<RenderNode>,
1288    bounds: LayoutRect,
1289) -> RenderScene {
1290    let local_bounds = LayoutRect::new(0.0, 0.0, bounds.size.width, bounds.size.height);
1291    let mut root = RenderLayer::new(local_bounds);
1292    root.style.transform = Some(translation_matrix(-bounds.origin.x, -bounds.origin.y));
1293    root.children.extend(children);
1294
1295    let mut scene = RenderScene::new(local_bounds);
1296    scene.roots.push(RenderNode::Layer(root));
1297    scene
1298}
1299
1300fn render_node_or_subtree_is_dynamic(
1301    node: &RenderNode,
1302    runtime_dynamic_subtrees: &HashMap<NodeId, bool>,
1303) -> bool {
1304    match node {
1305        RenderNode::Paint(_) => false,
1306        RenderNode::Layer(layer) => {
1307            layer
1308                .node_id
1309                .and_then(|id| runtime_dynamic_subtrees.get(&id).copied())
1310                .unwrap_or(false)
1311                || layer
1312                    .children
1313                    .iter()
1314                    .any(|child| render_node_or_subtree_is_dynamic(child, runtime_dynamic_subtrees))
1315        }
1316    }
1317}
1318
1319fn texture_plan_key_for_layer(layer: &RenderLayer) -> u64 {
1320    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1321    layer.node_id.hash(&mut hasher);
1322    layer.bounds.size.width.to_bits().hash(&mut hasher);
1323    layer.bounds.size.height.to_bits().hash(&mut hasher);
1324    hash_serde_value(&layer.style.clip, &mut hasher);
1325    hasher.finish()
1326}
1327
1328fn texture_plan_key_for_paint(list: &DisplayList) -> u64 {
1329    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1330    list.bounds.size.width.to_bits().hash(&mut hasher);
1331    list.bounds.size.height.to_bits().hash(&mut hasher);
1332    hash_serde_value(list, &mut hasher);
1333    hasher.finish()
1334}
1335
1336fn scene_cache_key(scene: &RenderScene) -> u64 {
1337    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1338    hash_serde_value(scene, &mut hasher);
1339    hasher.finish()
1340}
1341
1342fn plan_content_key(scene_cache_key: Option<u64>, children: &[CompositorTexturePlan]) -> u64 {
1343    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1344    scene_cache_key.hash(&mut hasher);
1345    for child in children {
1346        child.key.hash(&mut hasher);
1347        child.content_key.hash(&mut hasher);
1348        child.bounds.origin.x.to_bits().hash(&mut hasher);
1349        child.bounds.origin.y.to_bits().hash(&mut hasher);
1350        child.bounds.size.width.to_bits().hash(&mut hasher);
1351        child.bounds.size.height.to_bits().hash(&mut hasher);
1352        child.opacity.to_bits().hash(&mut hasher);
1353        hash_serde_value(&child.transform, &mut hasher);
1354        hash_serde_value(&child.clip, &mut hasher);
1355    }
1356    hasher.finish()
1357}
1358
1359fn patch_texture_compositor_plans(plans: &mut [CompositorTexturePlan], scene: &RenderScene) {
1360    for plan in plans {
1361        patch_texture_compositor_plan(plan, scene);
1362    }
1363}
1364
1365fn patch_texture_compositor_plan(plan: &mut CompositorTexturePlan, scene: &RenderScene) {
1366    for child in &mut plan.children {
1367        patch_texture_compositor_plan(child, scene);
1368    }
1369
1370    if let Some(path) = plan.source_layer_path.as_deref() {
1371        if let Some(layer) = layer_ref_at_path(scene, path) {
1372            plan.bounds = layer.bounds;
1373            plan.opacity = layer.style.opacity;
1374            plan.transform = layer.style.transform;
1375            plan.transform_clip = layer.style.transform_clip;
1376            plan.clip = layer.style.clip.clone();
1377        }
1378    }
1379
1380    plan.content_key = plan_content_key(plan.scene_cache_key, &plan.children);
1381}
1382
1383fn hash_serde_value<T: Serialize, H: Hasher>(value: &T, hasher: &mut H) {
1384    if let Ok(bytes) = bincode::serialize(value) {
1385        bytes.hash(hasher);
1386    }
1387}
1388
1389fn presentation_transform_matrix(
1390    render_viewport_size: LayoutSize,
1391    layout_viewport_size: LayoutSize,
1392    resize_preview: bool,
1393) -> Option<[f32; 16]> {
1394    if !resize_preview
1395        || render_viewport_size.width <= 0.0
1396        || render_viewport_size.height <= 0.0
1397        || layout_viewport_size.width <= 0.0
1398        || layout_viewport_size.height <= 0.0
1399    {
1400        return None;
1401    }
1402
1403    // Do not non-uniformly scale the retained UI during live resize.
1404    // Text-heavy surfaces look visibly distorted; we keep the last committed
1405    // layout anchored in place and rely on throttled relayouts instead.
1406    None
1407}
1408
1409fn compose_dynamic_layer_transform(
1410    binding: &TransformBinding,
1411    scroll_map: &ScrollStateMap,
1412    animation_map: &AnimationStateMap,
1413) -> Option<[f32; 16]> {
1414    let mut matrix: Option<[f32; 16]> = None;
1415
1416    if let Some(scroll) = &binding.scroll {
1417        let offset = scroll_map.get_offset(scroll.node_id);
1418        let scroll_matrix = match scroll.direction {
1419            FlexDirection::Row => translation_matrix(-offset, 0.0),
1420            FlexDirection::Column => translation_matrix(0.0, -offset),
1421        };
1422        matrix = append_transform(matrix, scroll_matrix);
1423    }
1424
1425    if let Some(layout_transform) = binding.layout_transform {
1426        matrix = append_transform(matrix, layout_transform);
1427    }
1428
1429    let translate_x = binding
1430        .translate_x
1431        .as_ref()
1432        .map(|scalar| resolve_scalar_value(scalar, animation_map, AnimationPropertyId::TranslateX))
1433        .unwrap_or(0.0);
1434    let translate_y = binding
1435        .translate_y
1436        .as_ref()
1437        .map(|scalar| resolve_scalar_value(scalar, animation_map, AnimationPropertyId::TranslateY))
1438        .unwrap_or(0.0);
1439    let scale = binding
1440        .scale
1441        .as_ref()
1442        .map(|scalar| resolve_scalar_value(scalar, animation_map, AnimationPropertyId::Scale))
1443        .unwrap_or(1.0);
1444    let rotation = binding
1445        .rotation
1446        .as_ref()
1447        .map(|scalar| resolve_scalar_value(scalar, animation_map, AnimationPropertyId::Rotation))
1448        .unwrap_or(0.0);
1449
1450    let has_composite_transform = translate_x.abs() > 0.001
1451        || translate_y.abs() > 0.001
1452        || (scale - 1.0).abs() > 0.001
1453        || rotation.abs() > 0.001;
1454    if has_composite_transform {
1455        matrix = append_transform(
1456            matrix,
1457            composite_transform_matrix(binding.rect, translate_x, translate_y, scale, rotation),
1458        );
1459    }
1460
1461    matrix.filter(|value| !is_identity_matrix(value))
1462}
1463
1464fn append_transform(current: Option<[f32; 16]>, next: [f32; 16]) -> Option<[f32; 16]> {
1465    Some(match current {
1466        Some(existing) => multiply_matrix(existing, next),
1467        None => next,
1468    })
1469}
1470
1471fn generate_render_layer_recursive(
1472    node_id: NodeId,
1473    ir: &CoreIR,
1474    snapshot: &LayoutSnapshot,
1475    scroll_map: &ScrollStateMap,
1476    animation_map: &AnimationStateMap,
1477    paint_cache: &mut HashMap<NodeId, (u64, DisplayList)>,
1478    boundary_cache: &mut HashMap<NodeId, BoundaryCacheEntry>,
1479    runtime_dynamic_subtrees: &HashMap<NodeId, bool>,
1480    miss_count: &mut usize,
1481    hit_count: &mut usize,
1482    scene_cache_allowed: bool,
1483    visited: &mut HashSet<NodeId>,
1484    bindings: &mut RetainedDynamicOps,
1485    layer_path: Vec<usize>,
1486) -> Option<RenderLayer> {
1487    if !visited.insert(node_id) {
1488        return None;
1489    }
1490
1491    let (Some(node), Some(geom)) = (ir.nodes.get(&node_id), snapshot.nodes.get(&node_id)) else {
1492        return None;
1493    };
1494
1495    let rect = geom.rect;
1496    let can_use_boundary_cache = !runtime_dynamic_subtrees
1497        .get(&node_id)
1498        .copied()
1499        .unwrap_or(false);
1500
1501    let scene_cache_key = boundary_hash(node, rect);
1502    let can_cache_scene = scene_cache_allowed && can_use_boundary_cache && node.parent.is_some();
1503    if can_cache_scene {
1504        if let Some(entry) = boundary_cache.get(&node_id) {
1505            if entry.hash == scene_cache_key {
1506                *hit_count += 1;
1507                return Some(entry.layer.clone());
1508            }
1509        }
1510    } else if can_use_boundary_cache {
1511        if let Some(entry) = boundary_cache.get(&node_id) {
1512            if entry.hash == scene_cache_key {
1513                *hit_count += 1;
1514                return Some(entry.layer.clone());
1515            }
1516        }
1517    }
1518
1519    let composite_opacity = resolve_composite_scalar(
1520        node.composite.opacity.as_ref(),
1521        animation_map,
1522        AnimationPropertyId::Opacity,
1523    );
1524    let composite_tx = resolve_composite_scalar(
1525        node.composite.translate_x.as_ref(),
1526        animation_map,
1527        AnimationPropertyId::TranslateX,
1528    );
1529    let composite_ty = resolve_composite_scalar(
1530        node.composite.translate_y.as_ref(),
1531        animation_map,
1532        AnimationPropertyId::TranslateY,
1533    );
1534    let composite_scale = resolve_composite_scalar(
1535        node.composite.scale.as_ref(),
1536        animation_map,
1537        AnimationPropertyId::Scale,
1538    )
1539    .unwrap_or(1.0);
1540    let composite_rotation = resolve_composite_scalar(
1541        node.composite.rotation.as_ref(),
1542        animation_map,
1543        AnimationPropertyId::Rotation,
1544    )
1545    .unwrap_or(0.0);
1546
1547    let _has_composite_transform = composite_tx.unwrap_or(0.0).abs() > 0.001
1548        || composite_ty.unwrap_or(0.0).abs() > 0.001
1549        || (composite_scale - 1.0).abs() > 0.001
1550        || composite_rotation.abs() > 0.001;
1551    let has_opacity_layer = composite_opacity
1552        .map(|value| (value - 1.0).abs() > 0.001)
1553        .unwrap_or(false);
1554    let needs_dynamic_opacity = node
1555        .composite
1556        .opacity
1557        .as_ref()
1558        .and_then(|value| value.animation_target)
1559        .is_some();
1560    let needs_dynamic_transform = node
1561        .composite
1562        .translate_x
1563        .as_ref()
1564        .and_then(|value| value.animation_target)
1565        .is_some()
1566        || node
1567            .composite
1568            .translate_y
1569            .as_ref()
1570            .and_then(|value| value.animation_target)
1571            .is_some()
1572        || node
1573            .composite
1574            .scale
1575            .as_ref()
1576            .and_then(|value| value.animation_target)
1577            .is_some()
1578        || node
1579            .composite
1580            .rotation
1581            .as_ref()
1582            .and_then(|value| value.animation_target)
1583            .is_some();
1584    let emit_opacity_layer = has_opacity_layer || needs_dynamic_opacity;
1585    let has_runtime_clip = node.composite.clip_to_bounds;
1586    let scroll = match &node.op {
1587        Op::Layout(LayoutOp::Scroll { direction, .. }) => Some(ScrollTransform {
1588            node_id,
1589            direction: *direction,
1590        }),
1591        _ => None,
1592    };
1593    let layout_transform = match &node.op {
1594        Op::Layout(LayoutOp::Transform { transform }) => Some(*transform),
1595        _ => None,
1596    };
1597    let has_own_transform = needs_dynamic_transform || layout_transform.is_some();
1598    let has_dynamic_transform = has_own_transform || scroll.is_some();
1599    let has_dynamic_style = emit_opacity_layer || has_dynamic_transform || has_runtime_clip;
1600    let has_dynamic_children = node.children.iter().any(|child| {
1601        runtime_dynamic_subtrees
1602            .get(child)
1603            .copied()
1604            .unwrap_or(false)
1605    });
1606    let mut layer = RenderLayer::new(rect);
1607    layer.node_id = Some(node_id);
1608    if can_cache_scene {
1609        layer.style.cache_key = Some(scene_cache_key);
1610    } else if has_dynamic_style && !has_dynamic_children {
1611        layer.style.content_cache_key = Some(scene_cache_key ^ 0x9E37_79B9_7F4A_7C15);
1612    }
1613
1614    layer.style.clip = match &node.op {
1615        Op::Layout(LayoutOp::Scroll { .. }) | Op::Layout(LayoutOp::Clip { .. }) => {
1616            Some(LayerClip::Rect(rect))
1617        }
1618        _ if has_runtime_clip => Some(LayerClip::Rect(rect)),
1619        _ => None,
1620    };
1621    if emit_opacity_layer {
1622        layer.style.opacity = composite_opacity.unwrap_or(1.0);
1623    }
1624
1625    if let Some(transform) = compose_dynamic_layer_transform(
1626        &TransformBinding {
1627            layer_path: layer_path.clone(),
1628            rect,
1629            layout_transform,
1630            scroll: None,
1631            translate_x: node.composite.translate_x.clone(),
1632            translate_y: node.composite.translate_y.clone(),
1633            scale: node.composite.scale.clone(),
1634            rotation: node.composite.rotation.clone(),
1635        },
1636        scroll_map,
1637        animation_map,
1638    ) {
1639        layer.style.transform = Some(transform);
1640    }
1641
1642    let local_hash = local_paint_hash(node);
1643    let local_paint = if let Some((cached_hash, cached_ops)) = paint_cache.get(&node_id) {
1644        if *cached_hash == local_hash {
1645            *hit_count += 1;
1646            Some(cached_ops.clone())
1647        } else {
1648            *miss_count += 1;
1649            let ops = build_local_paint_list(ir, node_id, node, rect);
1650            if let Some(ops) = ops.clone() {
1651                paint_cache.insert(node_id, (local_hash, ops));
1652            } else {
1653                paint_cache.remove(&node_id);
1654            }
1655            ops
1656        }
1657    } else {
1658        *miss_count += 1;
1659        let ops = build_local_paint_list(ir, node_id, node, rect);
1660        if let Some(ops) = ops.clone() {
1661            paint_cache.insert(node_id, (local_hash, ops));
1662        }
1663        ops
1664    };
1665
1666    if let Some(local_paint) = local_paint {
1667        layer.children.push(RenderNode::Paint(local_paint));
1668    }
1669
1670    if needs_dynamic_opacity {
1671        if let Some(scalar) = node.composite.opacity.as_ref() {
1672            bindings.opacity.push(OpacityBinding {
1673                layer_path: layer_path.clone(),
1674                scalar: scalar.clone(),
1675            });
1676        }
1677    }
1678    if has_own_transform {
1679        bindings.transform.push(TransformBinding {
1680            layer_path: layer_path.clone(),
1681            rect,
1682            layout_transform,
1683            scroll: None,
1684            translate_x: node.composite.translate_x.clone(),
1685            translate_y: node.composite.translate_y.clone(),
1686            scale: node.composite.scale.clone(),
1687            rotation: node.composite.rotation.clone(),
1688        });
1689    }
1690
1691    if let Some(scroll) = scroll {
1692        let content_index = layer.children.len();
1693        let mut content_path = layer_path.clone();
1694        content_path.push(content_index);
1695        let mut content_layer = RenderLayer::new(rect);
1696        content_layer.style.transform = compose_dynamic_layer_transform(
1697            &TransformBinding {
1698                layer_path: content_path.clone(),
1699                rect,
1700                layout_transform: None,
1701                scroll: Some(scroll.clone()),
1702                translate_x: None,
1703                translate_y: None,
1704                scale: None,
1705                rotation: None,
1706            },
1707            scroll_map,
1708            animation_map,
1709        );
1710        content_layer.style.transform_clip = false;
1711        bindings.transform.push(TransformBinding {
1712            layer_path: content_path.clone(),
1713            rect,
1714            layout_transform: None,
1715            scroll: Some(scroll),
1716            translate_x: None,
1717            translate_y: None,
1718            scale: None,
1719            rotation: None,
1720        });
1721
1722        for child in &node.children {
1723            let child_index = content_layer.children.len();
1724            let mut child_path = content_path.clone();
1725            child_path.push(child_index);
1726            if let Some(child_layer) = generate_render_layer_recursive(
1727                *child,
1728                ir,
1729                snapshot,
1730                scroll_map,
1731                animation_map,
1732                paint_cache,
1733                boundary_cache,
1734                runtime_dynamic_subtrees,
1735                miss_count,
1736                hit_count,
1737                scene_cache_allowed,
1738                visited,
1739                bindings,
1740                child_path,
1741            ) {
1742                content_layer.children.push(RenderNode::Layer(child_layer));
1743            }
1744        }
1745
1746        if !content_layer.children.is_empty() {
1747            layer.children.push(RenderNode::Layer(content_layer));
1748        }
1749    } else {
1750        for child in &node.children {
1751            let child_index = layer.children.len();
1752            let mut child_path = layer_path.clone();
1753            child_path.push(child_index);
1754            if let Some(child_layer) = generate_render_layer_recursive(
1755                *child,
1756                ir,
1757                snapshot,
1758                scroll_map,
1759                animation_map,
1760                paint_cache,
1761                boundary_cache,
1762                runtime_dynamic_subtrees,
1763                miss_count,
1764                hit_count,
1765                scene_cache_allowed,
1766                visited,
1767                bindings,
1768                child_path,
1769            ) {
1770                layer.children.push(RenderNode::Layer(child_layer));
1771            }
1772        }
1773    }
1774
1775    if let Some(scrollbar) = build_scrollbar_paint(ir, node_id, snapshot, scroll_map) {
1776        let mut scrollbar_path = layer_path.clone();
1777        scrollbar_path.push(layer.children.len());
1778        layer.children.push(RenderNode::Paint(scrollbar));
1779        bindings.scrollbar.push(ScrollbarBinding {
1780            node_path: scrollbar_path,
1781            node_id,
1782        });
1783    }
1784
1785    if can_use_boundary_cache {
1786        boundary_cache.insert(
1787            node_id,
1788            BoundaryCacheEntry {
1789                hash: scene_cache_key,
1790                layer: layer.clone(),
1791            },
1792        );
1793    }
1794
1795    Some(layer)
1796}
1797
1798fn push_video_surface(
1799    video_surfaces: &mut Vec<VideoSurfaceFrame>,
1800    widget_id: WidgetNodeId,
1801    rect: LayoutRect,
1802    video_map: &VideoStateMap,
1803) {
1804    if let Some(state) = video_map.states.get(&widget_id) {
1805        let surface_id = state.surface_id.unwrap_or(0);
1806        video_surfaces.push(VideoSurfaceFrame {
1807            widget_id,
1808            surface_id,
1809            rect,
1810        });
1811    }
1812}
1813
1814fn push_web_surface(
1815    web_surfaces: &mut Vec<WebSurfaceFrame>,
1816    widget_id: WidgetNodeId,
1817    rect: LayoutRect,
1818    web_map: &WebStateMap,
1819) {
1820    if let Some(state) = web_map.states.get(&widget_id) {
1821        if !state.url.trim().is_empty() {
1822            web_surfaces.push(WebSurfaceFrame {
1823                widget_id,
1824                url: state.url.clone(),
1825                user_agent: state.user_agent.clone(),
1826                rect,
1827            });
1828        }
1829    }
1830}
1831
1832fn collect_video_surfaces(
1833    node_id: NodeId,
1834    ir: &CoreIR,
1835    snapshot: &LayoutSnapshot,
1836    video_map: &VideoStateMap,
1837    web_map: &WebStateMap,
1838    scroll_map: &ScrollStateMap,
1839    accumulated_offset: LayoutPoint,
1840    video_surfaces: &mut Vec<VideoSurfaceFrame>,
1841    web_surfaces: &mut Vec<WebSurfaceFrame>,
1842    scene_3d_surfaces: &mut Vec<(WidgetNodeId, LayoutRect, Vec<u8>)>,
1843) {
1844    let mut visited = HashSet::new();
1845    collect_video_surfaces_with_visited(
1846        node_id,
1847        ir,
1848        snapshot,
1849        video_map,
1850        web_map,
1851        scroll_map,
1852        accumulated_offset,
1853        video_surfaces,
1854        web_surfaces,
1855        scene_3d_surfaces,
1856        &mut visited,
1857    );
1858}
1859
1860fn collect_video_surfaces_with_visited(
1861    node_id: NodeId,
1862    ir: &CoreIR,
1863    snapshot: &LayoutSnapshot,
1864    video_map: &VideoStateMap,
1865    web_map: &WebStateMap,
1866    scroll_map: &ScrollStateMap,
1867    accumulated_offset: LayoutPoint,
1868    video_surfaces: &mut Vec<VideoSurfaceFrame>,
1869    web_surfaces: &mut Vec<WebSurfaceFrame>,
1870    scene_3d_surfaces: &mut Vec<(WidgetNodeId, LayoutRect, Vec<u8>)>,
1871    visited: &mut HashSet<NodeId>,
1872) {
1873    if !visited.insert(node_id) {
1874        return;
1875    }
1876    if let (Some(node), Some(geom)) = (ir.nodes.get(&node_id), snapshot.nodes.get(&node_id)) {
1877        let mut child_offset = accumulated_offset;
1878        if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
1879            let offset = scroll_map.get_offset(node_id);
1880            child_offset = match direction {
1881                fission_ir::FlexDirection::Row => {
1882                    LayoutPoint::new(accumulated_offset.x - offset, accumulated_offset.y)
1883                }
1884                fission_ir::FlexDirection::Column => {
1885                    LayoutPoint::new(accumulated_offset.x, accumulated_offset.y - offset)
1886                }
1887            };
1888        }
1889
1890        if let Op::Layout(LayoutOp::Embed {
1891            kind: EmbedKind::Video,
1892            widget_id,
1893            ..
1894        }) = &node.op
1895        {
1896            let translated_rect = translate_rect(geom.rect, accumulated_offset);
1897            push_video_surface(video_surfaces, *widget_id, translated_rect, video_map);
1898        } else if let Op::Layout(LayoutOp::Embed {
1899            kind: EmbedKind::Web,
1900            widget_id,
1901            ..
1902        }) = &node.op
1903        {
1904            let translated_rect = translate_rect(geom.rect, accumulated_offset);
1905            push_web_surface(web_surfaces, *widget_id, translated_rect, web_map);
1906        } else if let Op::Layout(LayoutOp::Embed {
1907            kind: EmbedKind::Custom(payload),
1908            widget_id,
1909            ..
1910        }) = &node.op
1911        {
1912            let translated_rect = translate_rect(geom.rect, accumulated_offset);
1913            scene_3d_surfaces.push((*widget_id, translated_rect, payload.clone()));
1914        }
1915
1916        for child in &node.children {
1917            collect_video_surfaces_with_visited(
1918                *child,
1919                ir,
1920                snapshot,
1921                video_map,
1922                web_map,
1923                scroll_map,
1924                child_offset,
1925                video_surfaces,
1926                web_surfaces,
1927                scene_3d_surfaces,
1928                visited,
1929            );
1930        }
1931    }
1932}
1933
1934fn local_paint_hash(node: &fission_ir::CoreNode) -> u64 {
1935    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1936    node.op.hash(&mut hasher);
1937    hasher.finish()
1938}
1939
1940fn boundary_hash(node: &fission_ir::CoreNode, rect: LayoutRect) -> u64 {
1941    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1942    node.hash.hash(&mut hasher);
1943    rect.origin.x.to_bits().hash(&mut hasher);
1944    rect.origin.y.to_bits().hash(&mut hasher);
1945    rect.size.width.to_bits().hash(&mut hasher);
1946    rect.size.height.to_bits().hash(&mut hasher);
1947    hasher.finish()
1948}
1949
1950fn build_local_paint_list(
1951    ir: &CoreIR,
1952    node_id: NodeId,
1953    node: &fission_ir::CoreNode,
1954    rect: LayoutRect,
1955) -> Option<DisplayList> {
1956    let mut list = DisplayList::new(rect);
1957    match &node.op {
1958        Op::Paint(fission_ir::PaintOp::DrawRect {
1959            fill,
1960            stroke,
1961            corner_radius,
1962            shadow,
1963        }) => {
1964            list.push(DisplayOp::DrawRect {
1965                rect,
1966                fill: fill.as_ref().map(map_fill),
1967                stroke: stroke.as_ref().map(map_stroke),
1968                corner_radius: *corner_radius,
1969                shadow: shadow.as_ref().map(|s| BoxShadow {
1970                    color: RenderColor {
1971                        r: s.color.r,
1972                        g: s.color.g,
1973                        b: s.color.b,
1974                        a: s.color.a,
1975                    },
1976                    blur_radius: s.blur_radius,
1977                    offset: s.offset,
1978                }),
1979                bounds: rect,
1980                node_id: Some(node_id),
1981            });
1982        }
1983        Op::Paint(fission_ir::PaintOp::DrawText {
1984            text,
1985            size,
1986            color,
1987            underline,
1988            wrap,
1989            caret_index,
1990            caret_color,
1991            caret_width,
1992            caret_height,
1993            caret_radius,
1994            paragraph_style,
1995        }) => {
1996            list.push(DisplayOp::DrawText {
1997                text: text.clone(),
1998                position: rect.origin,
1999                size: *size,
2000                color: RenderColor {
2001                    r: color.r,
2002                    g: color.g,
2003                    b: color.b,
2004                    a: color.a,
2005                },
2006                bounds: rect,
2007                node_id: Some(node_id),
2008                underline: *underline,
2009                wrap: *wrap,
2010                caret_index: *caret_index,
2011                caret_color: caret_color.map(|color| RenderColor {
2012                    r: color.r,
2013                    g: color.g,
2014                    b: color.b,
2015                    a: color.a,
2016                }),
2017                caret_width: *caret_width,
2018                caret_height: *caret_height,
2019                caret_radius: *caret_radius,
2020                paragraph_style: *paragraph_style,
2021            });
2022        }
2023        Op::Paint(fission_ir::PaintOp::DrawRichText {
2024            runs,
2025            wrap,
2026            caret_index,
2027            caret_color,
2028            caret_width,
2029            caret_height,
2030            caret_radius,
2031            paragraph_style,
2032        }) => {
2033            let annotations = ir
2034                .custom_render_objects
2035                .get(&node_id)
2036                .and_then(|sidecar| {
2037                    sidecar.downcast_ref::<Vec<fission_ir::op::RichTextAnnotation>>()
2038                })
2039                .cloned()
2040                .unwrap_or_default();
2041            let render_runs = runs
2042                .iter()
2043                .map(|r| fission_render::TextRun {
2044                    text: r.text.clone(),
2045                    style: fission_render::TextStyle {
2046                        font_size: r.style.font_size,
2047                        color: RenderColor {
2048                            r: r.style.color.r,
2049                            g: r.style.color.g,
2050                            b: r.style.color.b,
2051                            a: r.style.color.a,
2052                        },
2053                        underline: r.style.underline,
2054                        font_family: r.style.font_family.clone(),
2055                        locale: r.style.locale.clone(),
2056                        font_weight: r.style.font_weight,
2057                        font_style: r.style.font_style,
2058                        line_height: r.style.line_height,
2059                        letter_spacing: r.style.letter_spacing,
2060                        background_color: r.style.background_color.map(|c| RenderColor {
2061                            r: c.r,
2062                            g: c.g,
2063                            b: c.b,
2064                            a: c.a,
2065                        }),
2066                    },
2067                })
2068                .collect();
2069
2070            list.push(DisplayOp::DrawRichText {
2071                runs: render_runs,
2072                position: rect.origin,
2073                bounds: rect,
2074                node_id: Some(node_id),
2075                wrap: *wrap,
2076                caret_index: *caret_index,
2077                caret_color: caret_color.map(|color| RenderColor {
2078                    r: color.r,
2079                    g: color.g,
2080                    b: color.b,
2081                    a: color.a,
2082                }),
2083                caret_width: *caret_width,
2084                caret_height: *caret_height,
2085                caret_radius: *caret_radius,
2086                paragraph_style: *paragraph_style,
2087                annotations,
2088            });
2089        }
2090        Op::Paint(fission_ir::PaintOp::DrawImage {
2091            request,
2092            fit,
2093            alignment,
2094        }) => {
2095            list.push(DisplayOp::DrawImage {
2096                rect,
2097                request: request.clone(),
2098                fit: match fit {
2099                    fission_ir::op::ImageFit::Contain => fission_render::ImageFit::Contain,
2100                    fission_ir::op::ImageFit::Cover => fission_render::ImageFit::Cover,
2101                    fission_ir::op::ImageFit::Fill => fission_render::ImageFit::Fill,
2102                    fission_ir::op::ImageFit::None => fission_render::ImageFit::None,
2103                },
2104                alignment: *alignment,
2105                bounds: rect,
2106                node_id: Some(node_id),
2107            });
2108        }
2109        Op::Paint(fission_ir::PaintOp::DrawPath { path, fill, stroke }) => {
2110            list.push(DisplayOp::DrawPath {
2111                path: path.clone(),
2112                fill: fill.as_ref().map(map_fill),
2113                stroke: stroke.as_ref().map(map_stroke),
2114                bounds: rect,
2115                node_id: Some(node_id),
2116            });
2117        }
2118        Op::Paint(fission_ir::PaintOp::DrawSvg {
2119            content,
2120            fill,
2121            stroke,
2122        }) => {
2123            list.push(DisplayOp::DrawSvg {
2124                content: content.clone(),
2125                fill: fill.as_ref().map(map_fill),
2126                stroke: stroke.as_ref().map(map_stroke),
2127                bounds: rect,
2128                node_id: Some(node_id),
2129            });
2130        }
2131        Op::Layout(LayoutOp::Embed {
2132            kind, widget_id, ..
2133        }) => {
2134            list.push(DisplayOp::DrawSurface {
2135                rect,
2136                surface_id: embed_surface_id(kind, *widget_id),
2137                position: 0,
2138                bounds: rect,
2139                node_id: Some(node_id),
2140            });
2141        }
2142        _ => {}
2143    }
2144    if list.ops.is_empty() {
2145        None
2146    } else {
2147        Some(list)
2148    }
2149}
2150
2151fn build_scrollbar_paint(
2152    ir: &CoreIR,
2153    node_id: NodeId,
2154    snapshot: &LayoutSnapshot,
2155    scroll_map: &ScrollStateMap,
2156) -> Option<DisplayList> {
2157    let geometry = scrollbar_geometry_for_node(ir, snapshot, scroll_map, node_id)?;
2158    let rail_fill = Some(Fill::Solid(RenderColor {
2159        r: 160,
2160        g: 168,
2161        b: 180,
2162        a: 80,
2163    }));
2164    let thumb_fill = Some(Fill::Solid(RenderColor {
2165        r: 82,
2166        g: 91,
2167        b: 108,
2168        a: 190,
2169    }));
2170    let mut list = DisplayList::new(geometry.rail_rect);
2171    let corner_radius = fission_core::scrollbar::SCROLLBAR_THICKNESS / 2.0;
2172
2173    list.push(DisplayOp::DrawRect {
2174        rect: geometry.rail_rect,
2175        fill: rail_fill,
2176        stroke: None,
2177        corner_radius,
2178        shadow: None,
2179        bounds: geometry.rail_rect,
2180        node_id: Some(node_id),
2181    });
2182    list.push(DisplayOp::DrawRect {
2183        rect: geometry.thumb_rect,
2184        fill: thumb_fill,
2185        stroke: None,
2186        corner_radius,
2187        shadow: None,
2188        bounds: geometry.thumb_rect,
2189        node_id: Some(node_id),
2190    });
2191
2192    Some(list)
2193}
2194
2195fn resolve_composite_scalar(
2196    scalar: Option<&fission_ir::CompositeScalar>,
2197    animation_map: &AnimationStateMap,
2198    property: AnimationPropertyId,
2199) -> Option<f32> {
2200    let scalar = scalar?;
2201    Some(resolve_scalar_value(scalar, animation_map, property))
2202}
2203
2204fn resolve_scalar_value(
2205    scalar: &fission_ir::CompositeScalar,
2206    animation_map: &AnimationStateMap,
2207    property: AnimationPropertyId,
2208) -> f32 {
2209    scalar
2210        .animation_target
2211        .and_then(|target| animation_map.values.get(&(target, property)).copied())
2212        .unwrap_or(scalar.base)
2213}
2214
2215fn composite_transform_matrix(
2216    rect: LayoutRect,
2217    translate_x: f32,
2218    translate_y: f32,
2219    scale: f32,
2220    rotation: f32,
2221) -> [f32; 16] {
2222    let center_x = rect.origin.x + rect.size.width * 0.5;
2223    let center_y = rect.origin.y + rect.size.height * 0.5;
2224
2225    let to_center = translation_matrix(center_x, center_y);
2226    let from_center = translation_matrix(-center_x, -center_y);
2227    let scale_matrix = scale_matrix(scale);
2228    let rotation_matrix = rotation_z_matrix(rotation);
2229    let animated_translate = translation_matrix(translate_x, translate_y);
2230
2231    multiply_matrix(
2232        animated_translate,
2233        multiply_matrix(
2234            to_center,
2235            multiply_matrix(rotation_matrix, multiply_matrix(scale_matrix, from_center)),
2236        ),
2237    )
2238}
2239
2240fn translation_matrix(tx: f32, ty: f32) -> [f32; 16] {
2241    [
2242        1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, tx, ty, 0.0, 1.0,
2243    ]
2244}
2245
2246fn scale_matrix(scale: f32) -> [f32; 16] {
2247    [
2248        scale, 0.0, 0.0, 0.0, 0.0, scale, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
2249    ]
2250}
2251
2252fn rotation_z_matrix(radians: f32) -> [f32; 16] {
2253    let sin = radians.sin();
2254    let cos = radians.cos();
2255    [
2256        cos, sin, 0.0, 0.0, -sin, cos, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
2257    ]
2258}
2259
2260fn multiply_matrix(a: [f32; 16], b: [f32; 16]) -> [f32; 16] {
2261    let mut out = [0.0; 16];
2262    for row in 0..4 {
2263        for col in 0..4 {
2264            let mut sum = 0.0;
2265            for k in 0..4 {
2266                sum += a[row * 4 + k] * b[k * 4 + col];
2267            }
2268            out[row * 4 + col] = sum;
2269        }
2270    }
2271    out
2272}
2273
2274fn is_identity_matrix(matrix: &[f32; 16]) -> bool {
2275    const IDENTITY: [f32; 16] = [
2276        1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
2277    ];
2278    matrix
2279        .iter()
2280        .zip(IDENTITY.iter())
2281        .all(|(lhs, rhs)| (*lhs - *rhs).abs() <= 0.000_1)
2282}
2283
2284#[cfg(test)]
2285fn scroll_offsets_changed(prev: &HashMap<NodeId, u32>, scroll_map: &ScrollStateMap) -> bool {
2286    if prev.len() != scroll_map.offsets.len() {
2287        return true;
2288    }
2289
2290    scroll_map
2291        .offsets
2292        .iter()
2293        .any(|(id, offset)| prev.get(id).copied() != Some(offset.to_bits()))
2294}
2295
2296impl SnapshotProvider for Pipeline {
2297    fn snapshot(&self, kind: SnapshotKind) -> Option<SnapshotBlob> {
2298        match kind {
2299            SnapshotKind::Layout => self.last_snapshot.as_ref().and_then(|snap| {
2300                serde_json::to_string_pretty(snap)
2301                    .ok()
2302                    .map(|json| SnapshotBlob { kind, json })
2303            }),
2304        }
2305    }
2306}
2307
2308fn map_fill(f: &fission_ir::op::Fill) -> Fill {
2309    match f {
2310        fission_ir::op::Fill::Solid(c) => Fill::Solid(RenderColor {
2311            r: c.r,
2312            g: c.g,
2313            b: c.b,
2314            a: c.a,
2315        }),
2316        fission_ir::op::Fill::LinearGradient { start, end, stops } => Fill::LinearGradient {
2317            start: *start,
2318            end: *end,
2319            stops: stops
2320                .iter()
2321                .map(|(o, c)| {
2322                    (
2323                        *o,
2324                        RenderColor {
2325                            r: c.r,
2326                            g: c.g,
2327                            b: c.b,
2328                            a: c.a,
2329                        },
2330                    )
2331                })
2332                .collect(),
2333        },
2334        fission_ir::op::Fill::RadialGradient {
2335            center,
2336            radius,
2337            stops,
2338        } => Fill::RadialGradient {
2339            center: *center,
2340            radius: *radius,
2341            stops: stops
2342                .iter()
2343                .map(|(o, c)| {
2344                    (
2345                        *o,
2346                        RenderColor {
2347                            r: c.r,
2348                            g: c.g,
2349                            b: c.b,
2350                            a: c.a,
2351                        },
2352                    )
2353                })
2354                .collect(),
2355        },
2356    }
2357}
2358
2359fn map_stroke(s: &fission_ir::op::Stroke) -> Stroke {
2360    Stroke {
2361        fill: map_fill(&s.fill),
2362        width: s.width,
2363        dash_array: s.dash_array.clone(),
2364        line_cap: match s.line_cap {
2365            fission_ir::op::LineCap::Butt => fission_render::LineCap::Butt,
2366            fission_ir::op::LineCap::Round => fission_render::LineCap::Round,
2367            fission_ir::op::LineCap::Square => fission_render::LineCap::Square,
2368        },
2369        line_join: match s.line_join {
2370            fission_ir::op::LineJoin::Miter => fission_render::LineJoin::Miter,
2371            fission_ir::op::LineJoin::Round => fission_render::LineJoin::Round,
2372            fission_ir::op::LineJoin::Bevel => fission_render::LineJoin::Bevel,
2373        },
2374    }
2375}
2376
2377fn translate_rect(rect: LayoutRect, offset: LayoutPoint) -> LayoutRect {
2378    LayoutRect {
2379        origin: LayoutPoint::new(rect.origin.x + offset.x, rect.origin.y + offset.y),
2380        size: rect.size,
2381    }
2382}
2383
2384#[cfg(test)]
2385mod tests {
2386    use super::{build_local_paint_list, scroll_offsets_changed, InvalidationSet, Pipeline};
2387    use fission_core::env::Env;
2388    use fission_core::registry::AnimationPropertyId;
2389    use fission_core::ScrollStateMap;
2390    use fission_ir::op::{
2391        Color, Fill, ImageAlignment, ImageFit, ImageRequest, ImageSource, RichTextAnnotation,
2392        TextRun, TextStyle,
2393    };
2394    use fission_ir::semantics::ActionTrigger;
2395    use fission_ir::{
2396        ActionEntry, CompositeScalar, CompositeStyle, CoreIR, EmbedKind, LayoutOp, NodeId, Op,
2397        PaintOp, WidgetNodeId,
2398    };
2399    use fission_layout::{LayoutEngine, LayoutRect, LayoutSize};
2400    use fission_render::{DisplayOp, RenderScene, Renderer};
2401    use std::collections::HashMap;
2402    use std::sync::Arc;
2403
2404    struct NullRenderer;
2405
2406    impl Renderer for NullRenderer {
2407        fn render_scene(&mut self, _scene: &RenderScene) -> anyhow::Result<()> {
2408            Ok(())
2409        }
2410    }
2411
2412    fn two_child_layout_ir(second_width: f32) -> CoreIR {
2413        let root = NodeId::derived(50, &[0]);
2414        let first = NodeId::derived(50, &[1]);
2415        let second = NodeId::derived(50, &[2]);
2416        let mut ir = CoreIR::new();
2417        ir.add_node(
2418            first,
2419            Op::Layout(LayoutOp::Box {
2420                width: Some(40.0),
2421                height: Some(20.0),
2422                min_width: None,
2423                max_width: None,
2424                min_height: None,
2425                max_height: None,
2426                padding: [0.0; 4],
2427                flex_grow: 0.0,
2428                flex_shrink: 1.0,
2429                aspect_ratio: None,
2430            }),
2431            vec![],
2432        );
2433        ir.add_node(
2434            second,
2435            Op::Layout(LayoutOp::Box {
2436                width: Some(second_width),
2437                height: Some(20.0),
2438                min_width: None,
2439                max_width: None,
2440                min_height: None,
2441                max_height: None,
2442                padding: [0.0; 4],
2443                flex_grow: 0.0,
2444                flex_shrink: 1.0,
2445                aspect_ratio: None,
2446            }),
2447            vec![],
2448        );
2449        ir.add_node(
2450            root,
2451            Op::Layout(LayoutOp::Flex {
2452                direction: fission_ir::FlexDirection::Column,
2453                wrap: fission_ir::op::FlexWrap::NoWrap,
2454                flex_grow: 0.0,
2455                flex_shrink: 1.0,
2456                padding: [0.0; 4],
2457                gap: Some(4.0),
2458                align_items: fission_ir::op::AlignItems::Start,
2459                justify_content: fission_ir::op::JustifyContent::Start,
2460            }),
2461            vec![first, second],
2462        );
2463        ir.set_root(root);
2464        ir
2465    }
2466
2467    #[test]
2468    fn unchanged_scroll_offsets_do_not_invalidate_cache() {
2469        let id = NodeId::derived(1, &[0]);
2470        let mut prev = HashMap::new();
2471        prev.insert(id, 12.5f32.to_bits());
2472        let mut scroll = ScrollStateMap::default();
2473        scroll.set_offset(id, 12.5);
2474        assert!(!scroll_offsets_changed(&prev, &scroll));
2475    }
2476
2477    #[test]
2478    fn changed_scroll_offsets_invalidate_cache() {
2479        let id = NodeId::derived(2, &[0]);
2480        let mut prev = HashMap::new();
2481        prev.insert(id, 0.0f32.to_bits());
2482        let mut scroll = ScrollStateMap::default();
2483        scroll.set_offset(id, 4.0);
2484        assert!(scroll_offsets_changed(&prev, &scroll));
2485    }
2486
2487    #[test]
2488    fn incremental_layout_keeps_rebuild_telemetry_honest() {
2489        let mut pipeline = Pipeline::new();
2490        let mut layout_engine = LayoutEngine::new();
2491        let scroll = ScrollStateMap::default();
2492
2493        pipeline.replace_ir(two_child_layout_ir(60.0), &Env::default());
2494        let first_pass = pipeline
2495            .ensure_layout(
2496                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2497                &mut layout_engine,
2498                &scroll,
2499            )
2500            .expect("initial layout");
2501        assert_eq!(first_pass, pipeline.layout_input_nodes.len());
2502        assert_eq!(pipeline.layout_full_rebuild_count, 1);
2503
2504        pipeline.replace_ir(two_child_layout_ir(90.0), &Env::default());
2505        let second_pass = pipeline
2506            .ensure_layout(
2507                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2508                &mut layout_engine,
2509                &scroll,
2510            )
2511            .expect("incremental layout");
2512
2513        assert_eq!(second_pass, 1);
2514        assert_eq!(pipeline.layout_full_rebuild_count, 1);
2515        assert!(pipeline.pending_layout_dirty_nodes.is_empty());
2516    }
2517
2518    #[test]
2519    fn rich_text_annotations_flow_into_display_ops() {
2520        let node_id = NodeId::derived(9, &[0]);
2521        let mut ir = CoreIR::new();
2522        ir.add_node(
2523            node_id,
2524            Op::Paint(PaintOp::DrawRichText {
2525                runs: vec![TextRun {
2526                    text: "docs".into(),
2527                    style: TextStyle {
2528                        font_size: 14.0,
2529                        color: Color::BLACK,
2530                        underline: false,
2531                        font_family: None,
2532                        locale: None,
2533                        font_weight: 400,
2534                        font_style: fission_ir::op::FontStyle::Normal,
2535                        line_height: None,
2536                        letter_spacing: 0.0,
2537                        background_color: None,
2538                    },
2539                }],
2540                wrap: true,
2541                caret_index: None,
2542                caret_color: None,
2543                caret_width: None,
2544                caret_height: None,
2545                caret_radius: None,
2546                paragraph_style: None,
2547            }),
2548            vec![],
2549        );
2550        ir.custom_render_objects.insert(
2551            node_id,
2552            Arc::new(vec![RichTextAnnotation {
2553                range: 0..4,
2554                semantics_label: Some("Documentation".into()),
2555                semantics_identifier: Some("docs-link".into()),
2556                spell_out: Some(true),
2557                mouse_cursor: Some(fission_ir::op::MouseCursor::Pointer),
2558                actions: vec![ActionEntry {
2559                    trigger: ActionTrigger::Default,
2560                    action_id: 7,
2561                    payload_data: Some(vec![1, 2, 3]),
2562                }],
2563            }]),
2564        );
2565
2566        let node = ir.nodes.get(&node_id).expect("paint node");
2567        let list =
2568            build_local_paint_list(&ir, node_id, node, LayoutRect::new(0.0, 0.0, 160.0, 40.0))
2569                .expect("display list");
2570        match list.ops.first() {
2571            Some(DisplayOp::DrawRichText { annotations, .. }) => {
2572                assert_eq!(annotations.len(), 1);
2573                assert_eq!(annotations[0].range, 0..4);
2574                assert_eq!(
2575                    annotations[0].semantics_identifier.as_deref(),
2576                    Some("docs-link")
2577                );
2578            }
2579            other => panic!("expected rich text op, got {other:?}"),
2580        }
2581    }
2582
2583    #[test]
2584    fn draw_image_paint_ops_flow_into_display_ops() {
2585        let node_id = NodeId::derived(12, &[0]);
2586        let request = ImageRequest {
2587            source: ImageSource::Network {
2588                url: "https://example.com/product.webp".into(),
2589                headers: Vec::new(),
2590                cache_policy: Default::default(),
2591            },
2592            cache_width: Some(220),
2593            cache_height: Some(160),
2594            semantic_label: Some("Product thumbnail".into()),
2595            ..Default::default()
2596        };
2597        let mut ir = CoreIR::new();
2598        ir.add_node(
2599            node_id,
2600            Op::Paint(PaintOp::DrawImage {
2601                request: request.clone(),
2602                fit: ImageFit::Cover,
2603                alignment: ImageAlignment::Center,
2604            }),
2605            vec![],
2606        );
2607
2608        let node = ir.nodes.get(&node_id).expect("image node");
2609        let rect = LayoutRect::new(24.0, 32.0, 220.0, 160.0);
2610        let list = build_local_paint_list(&ir, node_id, node, rect).expect("display list");
2611
2612        match list.ops.first() {
2613            Some(DisplayOp::DrawImage {
2614                rect: image_rect,
2615                request: image_request,
2616                fit,
2617                alignment,
2618                bounds,
2619                node_id: Some(image_node_id),
2620            }) => {
2621                assert_eq!(*image_rect, rect);
2622                assert_eq!(image_request, &request);
2623                assert_eq!(*fit, fission_render::ImageFit::Cover);
2624                assert_eq!(*alignment, ImageAlignment::Center);
2625                assert_eq!(*bounds, rect);
2626                assert_eq!(*image_node_id, node_id);
2627            }
2628            other => panic!("expected image display op, got {other:?}"),
2629        }
2630    }
2631
2632    #[test]
2633    fn retained_pipeline_scene_keeps_draw_image_ops() {
2634        let image_id = NodeId::derived(13, &[0]);
2635        let root_id = NodeId::derived(13, &[1]);
2636        let request = ImageRequest {
2637            source: ImageSource::Network {
2638                url: "https://example.com/catalog/thumbnail.webp".into(),
2639                headers: Vec::new(),
2640                cache_policy: Default::default(),
2641            },
2642            semantic_label: Some("Catalog thumbnail".into()),
2643            ..Default::default()
2644        };
2645        let mut ir = CoreIR::new();
2646        ir.add_node(
2647            image_id,
2648            Op::Paint(PaintOp::DrawImage {
2649                request: request.clone(),
2650                fit: ImageFit::Cover,
2651                alignment: ImageAlignment::Center,
2652            }),
2653            vec![],
2654        );
2655        ir.add_node(
2656            root_id,
2657            Op::Layout(LayoutOp::Box {
2658                width: Some(220.0),
2659                height: Some(160.0),
2660                min_width: None,
2661                max_width: None,
2662                min_height: None,
2663                max_height: None,
2664                padding: [0.0, 0.0, 0.0, 0.0],
2665                flex_grow: 0.0,
2666                flex_shrink: 0.0,
2667                aspect_ratio: None,
2668            }),
2669            vec![image_id],
2670        );
2671        ir.set_root(root_id);
2672
2673        let mut pipeline = Pipeline::new();
2674        let mut layout_engine = LayoutEngine::new();
2675        let scroll = ScrollStateMap::default();
2676        pipeline.replace_ir(ir, &Env::default());
2677        pipeline
2678            .ensure_layout(
2679                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2680                &mut layout_engine,
2681                &scroll,
2682            )
2683            .unwrap();
2684        pipeline
2685            .prepare_current(
2686                LayoutSize {
2687                    width: 320.0,
2688                    height: 240.0,
2689                },
2690                LayoutSize {
2691                    width: 320.0,
2692                    height: 240.0,
2693                },
2694                false,
2695                &scroll,
2696                &Default::default(),
2697                &Default::default(),
2698                &Default::default(),
2699            )
2700            .unwrap();
2701
2702        let display_list = pipeline.retained_scene().expect("retained scene").flatten();
2703        let image_op = display_list.ops.iter().find_map(|op| match op {
2704            DisplayOp::DrawImage {
2705                rect,
2706                request: image_request,
2707                fit,
2708                alignment,
2709                ..
2710            } => Some((rect, image_request, fit, alignment)),
2711            _ => None,
2712        });
2713
2714        let Some((rect, image_request, fit, alignment)) = image_op else {
2715            panic!("retained scene dropped DrawImage op");
2716        };
2717        assert_eq!(image_request, &request);
2718        assert_eq!(*fit, fission_render::ImageFit::Cover);
2719        assert_eq!(*alignment, ImageAlignment::Center);
2720        assert_eq!(rect.size.width, 220.0);
2721        assert_eq!(rect.size.height, 160.0);
2722    }
2723
2724    #[test]
2725    fn embed_layout_ops_flow_into_surface_display_ops() {
2726        let node_id = NodeId::derived(14, &[0]);
2727        let widget_id = WidgetNodeId::explicit("embed.surface");
2728        let mut ir = CoreIR::new();
2729        ir.add_node(
2730            node_id,
2731            Op::Layout(LayoutOp::Embed {
2732                kind: EmbedKind::Web,
2733                widget_id,
2734                width: Some(320.0),
2735                height: Some(180.0),
2736            }),
2737            vec![],
2738        );
2739
2740        let node = ir.nodes.get(&node_id).expect("embed node");
2741        let rect = LayoutRect::new(12.0, 24.0, 320.0, 180.0);
2742        let list = build_local_paint_list(&ir, node_id, node, rect).expect("display list");
2743
2744        match list.ops.first() {
2745            Some(DisplayOp::DrawSurface {
2746                rect: surface_rect,
2747                bounds,
2748                node_id: Some(surface_node_id),
2749                ..
2750            }) => {
2751                assert_eq!(*surface_rect, rect);
2752                assert_eq!(*bounds, rect);
2753                assert_eq!(*surface_node_id, node_id);
2754            }
2755            other => panic!("expected surface display op, got {other:?}"),
2756        }
2757    }
2758
2759    #[test]
2760    fn compositor_bound_opacity_animation_is_composite_only() {
2761        let mut ir = CoreIR::new();
2762        let child = NodeId::derived(10, &[1]);
2763        let root = NodeId::derived(10, &[0]);
2764        ir.add_node(child, Op::Layout(LayoutOp::AbsoluteFill), vec![]);
2765        ir.add_node_with_composite(
2766            root,
2767            Op::Structural(fission_ir::StructuralOp::Group { stable_hash: 1 }),
2768            CompositeStyle {
2769                opacity: Some(CompositeScalar::new(0.0).animated(WidgetNodeId::explicit("fade"))),
2770                ..Default::default()
2771            },
2772            vec![child],
2773        );
2774        ir.set_root(root);
2775
2776        let mut pipeline = Pipeline::new();
2777        pipeline.replace_ir(ir, &Env::default());
2778        let invalidation = pipeline.classify_animation_updates(&[(
2779            WidgetNodeId::explicit("fade"),
2780            AnimationPropertyId::Opacity,
2781        )]);
2782        assert_eq!(
2783            invalidation,
2784            InvalidationSet {
2785                build: false,
2786                layout: false,
2787                paint: false,
2788                composite: true,
2789            }
2790        );
2791    }
2792
2793    #[test]
2794    fn unbound_custom_animation_requires_build() {
2795        let pipeline = Pipeline::new();
2796        let invalidation = pipeline.classify_animation_updates(&[(
2797            WidgetNodeId::explicit("custom"),
2798            AnimationPropertyId::custom("phase"),
2799        )]);
2800        assert!(invalidation.build);
2801        assert!(invalidation.layout);
2802    }
2803
2804    #[test]
2805    fn compositor_bound_translate_animation_is_composite_only() {
2806        let mut ir = CoreIR::new();
2807        let child = NodeId::derived(11, &[1]);
2808        let root = NodeId::derived(11, &[0]);
2809        ir.add_node(
2810            child,
2811            Op::Paint(PaintOp::DrawRect {
2812                fill: Some(Fill::Solid(Color {
2813                    r: 0,
2814                    g: 0,
2815                    b: 0,
2816                    a: 255,
2817                })),
2818                stroke: None,
2819                corner_radius: 0.0,
2820                shadow: None,
2821            }),
2822            vec![],
2823        );
2824        ir.add_node_with_composite(
2825            root,
2826            Op::Layout(LayoutOp::Box {
2827                width: Some(120.0),
2828                height: Some(64.0),
2829                min_width: None,
2830                max_width: None,
2831                min_height: None,
2832                max_height: None,
2833                padding: [0.0, 0.0, 0.0, 0.0],
2834                flex_grow: 0.0,
2835                flex_shrink: 0.0,
2836                aspect_ratio: None,
2837            }),
2838            CompositeStyle {
2839                translate_x: Some(
2840                    CompositeScalar::new(12.0).animated(WidgetNodeId::explicit("slide")),
2841                ),
2842                ..Default::default()
2843            },
2844            vec![child],
2845        );
2846        ir.set_root(root);
2847
2848        let mut pipeline = Pipeline::new();
2849        pipeline.replace_ir(ir, &Env::default());
2850        let invalidation = pipeline.classify_animation_updates(&[(
2851            WidgetNodeId::explicit("slide"),
2852            AnimationPropertyId::TranslateX,
2853        )]);
2854        assert_eq!(
2855            invalidation,
2856            InvalidationSet {
2857                build: false,
2858                layout: false,
2859                paint: false,
2860                composite: true,
2861            }
2862        );
2863    }
2864
2865    #[test]
2866    fn dynamic_layer_with_static_contents_gets_content_cache_key() {
2867        let mut ir = CoreIR::new();
2868        let child = NodeId::derived(12, &[1]);
2869        let root = NodeId::derived(12, &[0]);
2870        ir.add_node(
2871            child,
2872            Op::Paint(PaintOp::DrawRect {
2873                fill: Some(Fill::Solid(Color {
2874                    r: 20,
2875                    g: 40,
2876                    b: 60,
2877                    a: 255,
2878                })),
2879                stroke: None,
2880                corner_radius: 8.0,
2881                shadow: None,
2882            }),
2883            vec![],
2884        );
2885        ir.add_node_with_composite(
2886            root,
2887            Op::Layout(LayoutOp::Box {
2888                width: Some(160.0),
2889                height: Some(72.0),
2890                min_width: None,
2891                max_width: None,
2892                min_height: None,
2893                max_height: None,
2894                padding: [0.0, 0.0, 0.0, 0.0],
2895                flex_grow: 0.0,
2896                flex_shrink: 0.0,
2897                aspect_ratio: None,
2898            }),
2899            CompositeStyle {
2900                opacity: Some(
2901                    CompositeScalar::new(0.4).animated(WidgetNodeId::explicit("fade-cache")),
2902                ),
2903                ..Default::default()
2904            },
2905            vec![child],
2906        );
2907        ir.set_root(root);
2908
2909        let mut pipeline = Pipeline::new();
2910        let mut layout_engine = LayoutEngine::new();
2911        let mut renderer = NullRenderer;
2912        let scroll = ScrollStateMap::default();
2913        pipeline.replace_ir(ir, &Env::default());
2914        pipeline
2915            .ensure_layout(
2916                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2917                &mut layout_engine,
2918                &scroll,
2919            )
2920            .unwrap();
2921        pipeline
2922            .render_current(
2923                LayoutSize {
2924                    width: 320.0,
2925                    height: 240.0,
2926                },
2927                LayoutSize {
2928                    width: 320.0,
2929                    height: 240.0,
2930                },
2931                false,
2932                &mut renderer,
2933                &scroll,
2934                &Default::default(),
2935                &Default::default(),
2936                &Default::default(),
2937            )
2938            .unwrap();
2939
2940        let scene = pipeline
2941            .retained_scene
2942            .as_ref()
2943            .expect("retained scene missing");
2944        let presentation_root = match scene.roots.first() {
2945            Some(fission_render::RenderNode::Layer(layer)) => layer,
2946            _ => panic!("missing presentation layer"),
2947        };
2948        let animated_layer = match presentation_root.children.first() {
2949            Some(fission_render::RenderNode::Layer(layer)) => layer,
2950            _ => panic!("missing animated layer"),
2951        };
2952
2953        assert!(animated_layer.style.cache_key.is_none());
2954        assert!(animated_layer.style.content_cache_key.is_some());
2955    }
2956
2957    #[test]
2958    fn nested_dynamic_descendant_becomes_child_texture_plan() {
2959        let mut ir = CoreIR::new();
2960        let left_paint = NodeId::derived(13, &[0]);
2961        let animated_paint = NodeId::derived(13, &[1]);
2962        let animated_wrapper = NodeId::derived(13, &[2]);
2963        let outer_static = NodeId::derived(13, &[3]);
2964        let outer_group = NodeId::derived(13, &[4]);
2965        let root = NodeId::derived(13, &[5]);
2966
2967        ir.add_node(
2968            left_paint,
2969            Op::Paint(PaintOp::DrawRect {
2970                fill: Some(Fill::Solid(Color {
2971                    r: 10,
2972                    g: 10,
2973                    b: 10,
2974                    a: 255,
2975                })),
2976                stroke: None,
2977                corner_radius: 0.0,
2978                shadow: None,
2979            }),
2980            vec![],
2981        );
2982        ir.add_node(
2983            animated_paint,
2984            Op::Paint(PaintOp::DrawRect {
2985                fill: Some(Fill::Solid(Color {
2986                    r: 200,
2987                    g: 40,
2988                    b: 40,
2989                    a: 255,
2990                })),
2991                stroke: None,
2992                corner_radius: 0.0,
2993                shadow: None,
2994            }),
2995            vec![],
2996        );
2997        ir.add_node_with_composite(
2998            animated_wrapper,
2999            Op::Layout(LayoutOp::Box {
3000                width: Some(96.0),
3001                height: Some(96.0),
3002                min_width: None,
3003                max_width: None,
3004                min_height: None,
3005                max_height: None,
3006                padding: [0.0, 0.0, 0.0, 0.0],
3007                flex_grow: 0.0,
3008                flex_shrink: 0.0,
3009                aspect_ratio: None,
3010            }),
3011            CompositeStyle {
3012                opacity: Some(
3013                    CompositeScalar::new(0.4).animated(WidgetNodeId::explicit("nested-fade")),
3014                ),
3015                ..Default::default()
3016            },
3017            vec![animated_paint],
3018        );
3019        ir.add_node(
3020            outer_static,
3021            Op::Paint(PaintOp::DrawRect {
3022                fill: Some(Fill::Solid(Color {
3023                    r: 20,
3024                    g: 100,
3025                    b: 180,
3026                    a: 255,
3027                })),
3028                stroke: None,
3029                corner_radius: 8.0,
3030                shadow: None,
3031            }),
3032            vec![],
3033        );
3034        ir.add_node(
3035            outer_group,
3036            Op::Layout(LayoutOp::Box {
3037                width: Some(160.0),
3038                height: Some(120.0),
3039                min_width: None,
3040                max_width: None,
3041                min_height: None,
3042                max_height: None,
3043                padding: [0.0, 0.0, 0.0, 0.0],
3044                flex_grow: 0.0,
3045                flex_shrink: 0.0,
3046                aspect_ratio: None,
3047            }),
3048            vec![outer_static, animated_wrapper],
3049        );
3050        ir.add_node(
3051            root,
3052            Op::Layout(LayoutOp::Box {
3053                width: Some(320.0),
3054                height: Some(240.0),
3055                min_width: None,
3056                max_width: None,
3057                min_height: None,
3058                max_height: None,
3059                padding: [0.0, 0.0, 0.0, 0.0],
3060                flex_grow: 0.0,
3061                flex_shrink: 0.0,
3062                aspect_ratio: None,
3063            }),
3064            vec![left_paint, outer_group],
3065        );
3066        ir.set_root(root);
3067
3068        let mut pipeline = Pipeline::new();
3069        let mut layout_engine = LayoutEngine::new();
3070        let scroll = ScrollStateMap::default();
3071        pipeline.replace_ir(ir, &Env::default());
3072        pipeline
3073            .ensure_layout(
3074                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3075                &mut layout_engine,
3076                &scroll,
3077            )
3078            .unwrap();
3079        pipeline
3080            .prepare_current(
3081                LayoutSize {
3082                    width: 320.0,
3083                    height: 240.0,
3084                },
3085                LayoutSize {
3086                    width: 320.0,
3087                    height: 240.0,
3088                },
3089                false,
3090                &scroll,
3091                &Default::default(),
3092                &Default::default(),
3093                &Default::default(),
3094            )
3095            .unwrap();
3096
3097        let plans = pipeline.texture_compositor_plans();
3098        assert!(!plans.is_empty());
3099        assert!(
3100            plans.iter().any(|plan| !plan.children.is_empty()),
3101            "expected at least one retained texture plan to extract nested dynamic descendants"
3102        );
3103    }
3104
3105    #[test]
3106    fn resize_preview_keeps_texture_compositor_root_transform() {
3107        let mut ir = CoreIR::new();
3108        let left = NodeId::derived(14, &[0]);
3109        let right = NodeId::derived(14, &[1]);
3110        let root = NodeId::derived(14, &[2]);
3111
3112        ir.add_node(
3113            left,
3114            Op::Paint(PaintOp::DrawRect {
3115                fill: Some(Fill::Solid(Color {
3116                    r: 80,
3117                    g: 80,
3118                    b: 80,
3119                    a: 255,
3120                })),
3121                stroke: None,
3122                corner_radius: 0.0,
3123                shadow: None,
3124            }),
3125            vec![],
3126        );
3127        ir.add_node(
3128            right,
3129            Op::Paint(PaintOp::DrawRect {
3130                fill: Some(Fill::Solid(Color {
3131                    r: 180,
3132                    g: 180,
3133                    b: 180,
3134                    a: 255,
3135                })),
3136                stroke: None,
3137                corner_radius: 0.0,
3138                shadow: None,
3139            }),
3140            vec![],
3141        );
3142        ir.add_node(
3143            root,
3144            Op::Layout(LayoutOp::Box {
3145                width: Some(300.0),
3146                height: Some(200.0),
3147                min_width: None,
3148                max_width: None,
3149                min_height: None,
3150                max_height: None,
3151                padding: [0.0, 0.0, 0.0, 0.0],
3152                flex_grow: 0.0,
3153                flex_shrink: 0.0,
3154                aspect_ratio: None,
3155            }),
3156            vec![left, right],
3157        );
3158        ir.set_root(root);
3159
3160        let mut pipeline = Pipeline::new();
3161        let mut layout_engine = LayoutEngine::new();
3162        let scroll = ScrollStateMap::default();
3163        pipeline.replace_ir(ir, &Env::default());
3164        pipeline
3165            .ensure_layout(
3166                LayoutRect::new(0.0, 0.0, 300.0, 200.0),
3167                &mut layout_engine,
3168                &scroll,
3169            )
3170            .unwrap();
3171        pipeline
3172            .prepare_current(
3173                LayoutSize {
3174                    width: 540.0,
3175                    height: 360.0,
3176                },
3177                LayoutSize {
3178                    width: 300.0,
3179                    height: 200.0,
3180                },
3181                true,
3182                &scroll,
3183                &Default::default(),
3184                &Default::default(),
3185                &Default::default(),
3186            )
3187            .unwrap();
3188
3189        assert!(pipeline.texture_compositor_root_transform().is_none());
3190        assert!(!pipeline.texture_compositor_plans().is_empty());
3191    }
3192
3193    #[test]
3194    fn scroll_only_layers_patch_retained_transforms_after_offset_changes() {
3195        let mut ir = CoreIR::new();
3196        let content = NodeId::derived(15, &[0]);
3197        let scroll = NodeId::derived(15, &[1]);
3198        let root = NodeId::derived(15, &[2]);
3199
3200        ir.add_node(
3201            content,
3202            Op::Paint(PaintOp::DrawRect {
3203                fill: Some(Fill::Solid(Color {
3204                    r: 120,
3205                    g: 120,
3206                    b: 220,
3207                    a: 255,
3208                })),
3209                stroke: None,
3210                corner_radius: 0.0,
3211                shadow: None,
3212            }),
3213            vec![],
3214        );
3215        ir.add_node(
3216            scroll,
3217            Op::Layout(LayoutOp::Scroll {
3218                direction: fission_ir::FlexDirection::Column,
3219                show_scrollbar: true,
3220                width: Some(320.0),
3221                height: Some(240.0),
3222                min_width: None,
3223                max_width: None,
3224                min_height: None,
3225                max_height: None,
3226                padding: [0.0, 0.0, 0.0, 0.0],
3227                flex_grow: 0.0,
3228                flex_shrink: 0.0,
3229            }),
3230            vec![content],
3231        );
3232        ir.add_node(
3233            root,
3234            Op::Layout(LayoutOp::Box {
3235                width: Some(320.0),
3236                height: Some(240.0),
3237                min_width: None,
3238                max_width: None,
3239                min_height: None,
3240                max_height: None,
3241                padding: [0.0, 0.0, 0.0, 0.0],
3242                flex_grow: 0.0,
3243                flex_shrink: 0.0,
3244                aspect_ratio: None,
3245            }),
3246            vec![scroll],
3247        );
3248        ir.set_root(root);
3249
3250        let mut pipeline = Pipeline::new();
3251        let mut layout_engine = LayoutEngine::new();
3252        let scroll0 = ScrollStateMap::default();
3253        pipeline.replace_ir(ir, &Env::default());
3254        pipeline
3255            .ensure_layout(
3256                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3257                &mut layout_engine,
3258                &scroll0,
3259            )
3260            .unwrap();
3261        pipeline
3262            .prepare_current(
3263                LayoutSize {
3264                    width: 320.0,
3265                    height: 240.0,
3266                },
3267                LayoutSize {
3268                    width: 320.0,
3269                    height: 240.0,
3270                },
3271                false,
3272                &scroll0,
3273                &Default::default(),
3274                &Default::default(),
3275                &Default::default(),
3276            )
3277            .unwrap();
3278
3279        let mut scroll1 = ScrollStateMap::default();
3280        scroll1.set_offset(scroll, 180.0);
3281        pipeline
3282            .prepare_current(
3283                LayoutSize {
3284                    width: 320.0,
3285                    height: 240.0,
3286                },
3287                LayoutSize {
3288                    width: 320.0,
3289                    height: 240.0,
3290                },
3291                false,
3292                &scroll1,
3293                &Default::default(),
3294                &Default::default(),
3295                &Default::default(),
3296            )
3297            .unwrap();
3298
3299        fn find_layer_by_node(
3300            node: &fission_render::RenderNode,
3301            node_id: NodeId,
3302        ) -> Option<&fission_render::RenderLayer> {
3303            match node {
3304                fission_render::RenderNode::Paint(_) => None,
3305                fission_render::RenderNode::Layer(layer) => {
3306                    if layer.node_id == Some(node_id) {
3307                        return Some(layer);
3308                    }
3309                    for child in &layer.children {
3310                        if let Some(found) = find_layer_by_node(child, node_id) {
3311                            return Some(found);
3312                        }
3313                    }
3314                    None
3315                }
3316            }
3317        }
3318
3319        let scroll_layer = pipeline
3320            .retained_scene()
3321            .and_then(|scene| {
3322                scene
3323                    .roots
3324                    .iter()
3325                    .find_map(|node| find_layer_by_node(node, scroll))
3326            })
3327            .expect("expected a retained scroll layer");
3328        assert!(
3329            scroll_layer.style.transform.is_none(),
3330            "scrollbar chrome must not inherit the content scroll transform"
3331        );
3332        let transform = scroll_layer
3333            .children
3334            .iter()
3335            .find_map(|child| match child {
3336                fission_render::RenderNode::Layer(layer) => layer.style.transform,
3337                fission_render::RenderNode::Paint(_) => None,
3338            })
3339            .expect("scroll content layer should carry a compositor transform");
3340        assert!(
3341            (transform[13] + 180.0).abs() <= 0.01,
3342            "expected retained content transform to patch to -180, got {}",
3343            transform[13]
3344        );
3345    }
3346
3347    #[test]
3348    fn scrollbar_thumb_patches_after_scroll_offset_changes() {
3349        let mut ir = CoreIR::new();
3350        let fill = NodeId::derived(18, &[0]);
3351        let content = NodeId::derived(18, &[1]);
3352        let scroll = NodeId::derived(18, &[2]);
3353        let root = NodeId::derived(18, &[3]);
3354
3355        ir.add_node(
3356            fill,
3357            Op::Paint(PaintOp::DrawRect {
3358                fill: Some(Fill::Solid(Color {
3359                    r: 120,
3360                    g: 120,
3361                    b: 220,
3362                    a: 255,
3363                })),
3364                stroke: None,
3365                corner_radius: 0.0,
3366                shadow: None,
3367            }),
3368            vec![],
3369        );
3370        ir.add_node(
3371            content,
3372            Op::Layout(LayoutOp::Box {
3373                width: Some(320.0),
3374                height: Some(640.0),
3375                min_width: None,
3376                max_width: None,
3377                min_height: None,
3378                max_height: None,
3379                padding: [0.0, 0.0, 0.0, 0.0],
3380                flex_grow: 0.0,
3381                flex_shrink: 0.0,
3382                aspect_ratio: None,
3383            }),
3384            vec![fill],
3385        );
3386        ir.add_node(
3387            scroll,
3388            Op::Layout(LayoutOp::Scroll {
3389                direction: fission_ir::FlexDirection::Column,
3390                show_scrollbar: true,
3391                width: Some(320.0),
3392                height: Some(240.0),
3393                min_width: None,
3394                max_width: None,
3395                min_height: None,
3396                max_height: None,
3397                padding: [0.0, 0.0, 0.0, 0.0],
3398                flex_grow: 0.0,
3399                flex_shrink: 0.0,
3400            }),
3401            vec![content],
3402        );
3403        ir.add_node(
3404            root,
3405            Op::Layout(LayoutOp::Box {
3406                width: Some(320.0),
3407                height: Some(240.0),
3408                min_width: None,
3409                max_width: None,
3410                min_height: None,
3411                max_height: None,
3412                padding: [0.0, 0.0, 0.0, 0.0],
3413                flex_grow: 0.0,
3414                flex_shrink: 0.0,
3415                aspect_ratio: None,
3416            }),
3417            vec![scroll],
3418        );
3419        ir.set_root(root);
3420
3421        let mut pipeline = Pipeline::new();
3422        let mut layout_engine = LayoutEngine::new();
3423        let scroll0 = ScrollStateMap::default();
3424        pipeline.replace_ir(ir, &Env::default());
3425        pipeline
3426            .ensure_layout(
3427                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3428                &mut layout_engine,
3429                &scroll0,
3430            )
3431            .unwrap();
3432        pipeline
3433            .prepare_current(
3434                LayoutSize::new(320.0, 240.0),
3435                LayoutSize::new(320.0, 240.0),
3436                false,
3437                &scroll0,
3438                &Default::default(),
3439                &Default::default(),
3440                &Default::default(),
3441            )
3442            .unwrap();
3443        let initial_thumb_y = scrollbar_thumb_y(pipeline.retained_scene().unwrap(), scroll)
3444            .expect("initial scrollbar thumb");
3445
3446        let mut scroll1 = ScrollStateMap::default();
3447        scroll1.set_offset(scroll, 200.0);
3448        pipeline
3449            .prepare_current(
3450                LayoutSize::new(320.0, 240.0),
3451                LayoutSize::new(320.0, 240.0),
3452                false,
3453                &scroll1,
3454                &Default::default(),
3455                &Default::default(),
3456                &Default::default(),
3457            )
3458            .unwrap();
3459        let moved_thumb_y = scrollbar_thumb_y(pipeline.retained_scene().unwrap(), scroll)
3460            .expect("moved scrollbar thumb");
3461
3462        assert!(
3463            moved_thumb_y > initial_thumb_y,
3464            "body scroll must patch the retained scrollbar thumb, before={initial_thumb_y}, after={moved_thumb_y}"
3465        );
3466
3467        fn scrollbar_thumb_y(scene: &fission_render::RenderScene, scroll: NodeId) -> Option<f32> {
3468            fn find(node: &fission_render::RenderNode, scroll: NodeId) -> Option<f32> {
3469                match node {
3470                    fission_render::RenderNode::Paint(list) => list.ops.iter().find_map(|op| {
3471                        if let fission_render::DisplayOp::DrawRect { rect, node_id, .. } = op {
3472                            if *node_id == Some(scroll)
3473                                && (rect.width() - 6.0).abs() <= 0.01
3474                                && rect.height() < 200.0
3475                            {
3476                                return Some(rect.origin.y);
3477                            }
3478                        }
3479                        None
3480                    }),
3481                    fission_render::RenderNode::Layer(layer) => {
3482                        layer.children.iter().find_map(|child| find(child, scroll))
3483                    }
3484                }
3485            }
3486            scene.roots.iter().find_map(|root| find(root, scroll))
3487        }
3488    }
3489
3490    #[test]
3491    fn overflowing_scroll_nodes_emit_visible_scroll_rails() {
3492        let mut ir = CoreIR::new();
3493        let fill = NodeId::derived(16, &[0]);
3494        let content = NodeId::derived(16, &[1]);
3495        let scroll = NodeId::derived(16, &[2]);
3496        let root = NodeId::derived(16, &[3]);
3497
3498        ir.add_node(
3499            fill,
3500            Op::Paint(PaintOp::DrawRect {
3501                fill: Some(Fill::Solid(Color {
3502                    r: 80,
3503                    g: 120,
3504                    b: 220,
3505                    a: 255,
3506                })),
3507                stroke: None,
3508                corner_radius: 0.0,
3509                shadow: None,
3510            }),
3511            vec![],
3512        );
3513        ir.add_node(
3514            content,
3515            Op::Layout(LayoutOp::Box {
3516                width: Some(320.0),
3517                height: Some(640.0),
3518                min_width: None,
3519                max_width: None,
3520                min_height: None,
3521                max_height: None,
3522                padding: [0.0, 0.0, 0.0, 0.0],
3523                flex_grow: 0.0,
3524                flex_shrink: 0.0,
3525                aspect_ratio: None,
3526            }),
3527            vec![fill],
3528        );
3529        ir.add_node(
3530            scroll,
3531            Op::Layout(LayoutOp::Scroll {
3532                direction: fission_ir::FlexDirection::Column,
3533                show_scrollbar: true,
3534                width: Some(320.0),
3535                height: Some(240.0),
3536                min_width: None,
3537                max_width: None,
3538                min_height: None,
3539                max_height: None,
3540                padding: [0.0, 0.0, 0.0, 0.0],
3541                flex_grow: 0.0,
3542                flex_shrink: 0.0,
3543            }),
3544            vec![content],
3545        );
3546        ir.add_node(
3547            root,
3548            Op::Layout(LayoutOp::Box {
3549                width: Some(320.0),
3550                height: Some(240.0),
3551                min_width: None,
3552                max_width: None,
3553                min_height: None,
3554                max_height: None,
3555                padding: [0.0, 0.0, 0.0, 0.0],
3556                flex_grow: 0.0,
3557                flex_shrink: 0.0,
3558                aspect_ratio: None,
3559            }),
3560            vec![scroll],
3561        );
3562        ir.set_root(root);
3563
3564        let mut pipeline = Pipeline::new();
3565        let mut layout_engine = LayoutEngine::new();
3566        let scroll_map = ScrollStateMap::default();
3567        pipeline.replace_ir(ir, &Env::default());
3568        pipeline
3569            .ensure_layout(
3570                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3571                &mut layout_engine,
3572                &scroll_map,
3573            )
3574            .unwrap();
3575        pipeline
3576            .prepare_current(
3577                LayoutSize {
3578                    width: 320.0,
3579                    height: 240.0,
3580                },
3581                LayoutSize {
3582                    width: 320.0,
3583                    height: 240.0,
3584                },
3585                false,
3586                &scroll_map,
3587                &Default::default(),
3588                &Default::default(),
3589                &Default::default(),
3590            )
3591            .unwrap();
3592
3593        fn count_scroll_rails(node: &fission_render::RenderNode, scroll: NodeId) -> usize {
3594            match node {
3595                fission_render::RenderNode::Paint(list) => list
3596                    .ops
3597                    .iter()
3598                    .filter(|op| match op {
3599                        fission_render::DisplayOp::DrawRect { rect, node_id, .. } => {
3600                            *node_id == Some(scroll)
3601                                && (rect.width() - 6.0).abs() <= 0.01
3602                                && rect.height() >= 200.0
3603                        }
3604                        _ => false,
3605                    })
3606                    .count(),
3607                fission_render::RenderNode::Layer(layer) => layer
3608                    .children
3609                    .iter()
3610                    .map(|child| count_scroll_rails(child, scroll))
3611                    .sum(),
3612            }
3613        }
3614
3615        let rail_count: usize = pipeline
3616            .retained_scene()
3617            .expect("retained scene")
3618            .roots
3619            .iter()
3620            .map(|node| count_scroll_rails(node, scroll))
3621            .sum();
3622        assert!(
3623            rail_count > 0,
3624            "expected an overflow rail for the scroll node"
3625        );
3626    }
3627}