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::DrawPath { path, fill, stroke }) => {
2091            list.push(DisplayOp::DrawPath {
2092                path: path.clone(),
2093                fill: fill.as_ref().map(map_fill),
2094                stroke: stroke.as_ref().map(map_stroke),
2095                bounds: rect,
2096                node_id: Some(node_id),
2097            });
2098        }
2099        Op::Paint(fission_ir::PaintOp::DrawSvg {
2100            content,
2101            fill,
2102            stroke,
2103        }) => {
2104            list.push(DisplayOp::DrawSvg {
2105                content: content.clone(),
2106                fill: fill.as_ref().map(map_fill),
2107                stroke: stroke.as_ref().map(map_stroke),
2108                bounds: rect,
2109                node_id: Some(node_id),
2110            });
2111        }
2112        Op::Layout(LayoutOp::Embed {
2113            kind, widget_id, ..
2114        }) => {
2115            list.push(DisplayOp::DrawSurface {
2116                rect,
2117                surface_id: embed_surface_id(kind, *widget_id),
2118                position: 0,
2119                bounds: rect,
2120                node_id: Some(node_id),
2121            });
2122        }
2123        _ => {}
2124    }
2125    if list.ops.is_empty() {
2126        None
2127    } else {
2128        Some(list)
2129    }
2130}
2131
2132fn build_scrollbar_paint(
2133    ir: &CoreIR,
2134    node_id: NodeId,
2135    snapshot: &LayoutSnapshot,
2136    scroll_map: &ScrollStateMap,
2137) -> Option<DisplayList> {
2138    let geometry = scrollbar_geometry_for_node(ir, snapshot, scroll_map, node_id)?;
2139    let rail_fill = Some(Fill::Solid(RenderColor {
2140        r: 160,
2141        g: 168,
2142        b: 180,
2143        a: 80,
2144    }));
2145    let thumb_fill = Some(Fill::Solid(RenderColor {
2146        r: 82,
2147        g: 91,
2148        b: 108,
2149        a: 190,
2150    }));
2151    let mut list = DisplayList::new(geometry.rail_rect);
2152    let corner_radius = fission_core::scrollbar::SCROLLBAR_THICKNESS / 2.0;
2153
2154    list.push(DisplayOp::DrawRect {
2155        rect: geometry.rail_rect,
2156        fill: rail_fill,
2157        stroke: None,
2158        corner_radius,
2159        shadow: None,
2160        bounds: geometry.rail_rect,
2161        node_id: Some(node_id),
2162    });
2163    list.push(DisplayOp::DrawRect {
2164        rect: geometry.thumb_rect,
2165        fill: thumb_fill,
2166        stroke: None,
2167        corner_radius,
2168        shadow: None,
2169        bounds: geometry.thumb_rect,
2170        node_id: Some(node_id),
2171    });
2172
2173    Some(list)
2174}
2175
2176fn resolve_composite_scalar(
2177    scalar: Option<&fission_ir::CompositeScalar>,
2178    animation_map: &AnimationStateMap,
2179    property: AnimationPropertyId,
2180) -> Option<f32> {
2181    let scalar = scalar?;
2182    Some(resolve_scalar_value(scalar, animation_map, property))
2183}
2184
2185fn resolve_scalar_value(
2186    scalar: &fission_ir::CompositeScalar,
2187    animation_map: &AnimationStateMap,
2188    property: AnimationPropertyId,
2189) -> f32 {
2190    scalar
2191        .animation_target
2192        .and_then(|target| animation_map.values.get(&(target, property)).copied())
2193        .unwrap_or(scalar.base)
2194}
2195
2196fn composite_transform_matrix(
2197    rect: LayoutRect,
2198    translate_x: f32,
2199    translate_y: f32,
2200    scale: f32,
2201    rotation: f32,
2202) -> [f32; 16] {
2203    let center_x = rect.origin.x + rect.size.width * 0.5;
2204    let center_y = rect.origin.y + rect.size.height * 0.5;
2205
2206    let to_center = translation_matrix(center_x, center_y);
2207    let from_center = translation_matrix(-center_x, -center_y);
2208    let scale_matrix = scale_matrix(scale);
2209    let rotation_matrix = rotation_z_matrix(rotation);
2210    let animated_translate = translation_matrix(translate_x, translate_y);
2211
2212    multiply_matrix(
2213        animated_translate,
2214        multiply_matrix(
2215            to_center,
2216            multiply_matrix(rotation_matrix, multiply_matrix(scale_matrix, from_center)),
2217        ),
2218    )
2219}
2220
2221fn translation_matrix(tx: f32, ty: f32) -> [f32; 16] {
2222    [
2223        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,
2224    ]
2225}
2226
2227fn scale_matrix(scale: f32) -> [f32; 16] {
2228    [
2229        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,
2230    ]
2231}
2232
2233fn rotation_z_matrix(radians: f32) -> [f32; 16] {
2234    let sin = radians.sin();
2235    let cos = radians.cos();
2236    [
2237        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,
2238    ]
2239}
2240
2241fn multiply_matrix(a: [f32; 16], b: [f32; 16]) -> [f32; 16] {
2242    let mut out = [0.0; 16];
2243    for row in 0..4 {
2244        for col in 0..4 {
2245            let mut sum = 0.0;
2246            for k in 0..4 {
2247                sum += a[row * 4 + k] * b[k * 4 + col];
2248            }
2249            out[row * 4 + col] = sum;
2250        }
2251    }
2252    out
2253}
2254
2255fn is_identity_matrix(matrix: &[f32; 16]) -> bool {
2256    const IDENTITY: [f32; 16] = [
2257        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,
2258    ];
2259    matrix
2260        .iter()
2261        .zip(IDENTITY.iter())
2262        .all(|(lhs, rhs)| (*lhs - *rhs).abs() <= 0.000_1)
2263}
2264
2265#[cfg(test)]
2266fn scroll_offsets_changed(prev: &HashMap<NodeId, u32>, scroll_map: &ScrollStateMap) -> bool {
2267    if prev.len() != scroll_map.offsets.len() {
2268        return true;
2269    }
2270
2271    scroll_map
2272        .offsets
2273        .iter()
2274        .any(|(id, offset)| prev.get(id).copied() != Some(offset.to_bits()))
2275}
2276
2277impl SnapshotProvider for Pipeline {
2278    fn snapshot(&self, kind: SnapshotKind) -> Option<SnapshotBlob> {
2279        match kind {
2280            SnapshotKind::Layout => self.last_snapshot.as_ref().and_then(|snap| {
2281                serde_json::to_string_pretty(snap)
2282                    .ok()
2283                    .map(|json| SnapshotBlob { kind, json })
2284            }),
2285        }
2286    }
2287}
2288
2289fn map_fill(f: &fission_ir::op::Fill) -> Fill {
2290    match f {
2291        fission_ir::op::Fill::Solid(c) => Fill::Solid(RenderColor {
2292            r: c.r,
2293            g: c.g,
2294            b: c.b,
2295            a: c.a,
2296        }),
2297        fission_ir::op::Fill::LinearGradient { start, end, stops } => Fill::LinearGradient {
2298            start: *start,
2299            end: *end,
2300            stops: stops
2301                .iter()
2302                .map(|(o, c)| {
2303                    (
2304                        *o,
2305                        RenderColor {
2306                            r: c.r,
2307                            g: c.g,
2308                            b: c.b,
2309                            a: c.a,
2310                        },
2311                    )
2312                })
2313                .collect(),
2314        },
2315        fission_ir::op::Fill::RadialGradient {
2316            center,
2317            radius,
2318            stops,
2319        } => Fill::RadialGradient {
2320            center: *center,
2321            radius: *radius,
2322            stops: stops
2323                .iter()
2324                .map(|(o, c)| {
2325                    (
2326                        *o,
2327                        RenderColor {
2328                            r: c.r,
2329                            g: c.g,
2330                            b: c.b,
2331                            a: c.a,
2332                        },
2333                    )
2334                })
2335                .collect(),
2336        },
2337    }
2338}
2339
2340fn map_stroke(s: &fission_ir::op::Stroke) -> Stroke {
2341    Stroke {
2342        fill: map_fill(&s.fill),
2343        width: s.width,
2344        dash_array: s.dash_array.clone(),
2345        line_cap: match s.line_cap {
2346            fission_ir::op::LineCap::Butt => fission_render::LineCap::Butt,
2347            fission_ir::op::LineCap::Round => fission_render::LineCap::Round,
2348            fission_ir::op::LineCap::Square => fission_render::LineCap::Square,
2349        },
2350        line_join: match s.line_join {
2351            fission_ir::op::LineJoin::Miter => fission_render::LineJoin::Miter,
2352            fission_ir::op::LineJoin::Round => fission_render::LineJoin::Round,
2353            fission_ir::op::LineJoin::Bevel => fission_render::LineJoin::Bevel,
2354        },
2355    }
2356}
2357
2358fn translate_rect(rect: LayoutRect, offset: LayoutPoint) -> LayoutRect {
2359    LayoutRect {
2360        origin: LayoutPoint::new(rect.origin.x + offset.x, rect.origin.y + offset.y),
2361        size: rect.size,
2362    }
2363}
2364
2365#[cfg(test)]
2366mod tests {
2367    use super::{build_local_paint_list, scroll_offsets_changed, InvalidationSet, Pipeline};
2368    use fission_core::env::Env;
2369    use fission_core::registry::AnimationPropertyId;
2370    use fission_core::ScrollStateMap;
2371    use fission_ir::op::{Color, Fill, RichTextAnnotation, TextRun, TextStyle};
2372    use fission_ir::semantics::ActionTrigger;
2373    use fission_ir::{
2374        ActionEntry, CompositeScalar, CompositeStyle, CoreIR, EmbedKind, LayoutOp, NodeId, Op,
2375        PaintOp, WidgetNodeId,
2376    };
2377    use fission_layout::{LayoutEngine, LayoutRect, LayoutSize};
2378    use fission_render::{DisplayOp, RenderScene, Renderer};
2379    use std::collections::HashMap;
2380    use std::sync::Arc;
2381
2382    struct NullRenderer;
2383
2384    impl Renderer for NullRenderer {
2385        fn render_scene(&mut self, _scene: &RenderScene) -> anyhow::Result<()> {
2386            Ok(())
2387        }
2388    }
2389
2390    fn two_child_layout_ir(second_width: f32) -> CoreIR {
2391        let root = NodeId::derived(50, &[0]);
2392        let first = NodeId::derived(50, &[1]);
2393        let second = NodeId::derived(50, &[2]);
2394        let mut ir = CoreIR::new();
2395        ir.add_node(
2396            first,
2397            Op::Layout(LayoutOp::Box {
2398                width: Some(40.0),
2399                height: Some(20.0),
2400                min_width: None,
2401                max_width: None,
2402                min_height: None,
2403                max_height: None,
2404                padding: [0.0; 4],
2405                flex_grow: 0.0,
2406                flex_shrink: 1.0,
2407                aspect_ratio: None,
2408            }),
2409            vec![],
2410        );
2411        ir.add_node(
2412            second,
2413            Op::Layout(LayoutOp::Box {
2414                width: Some(second_width),
2415                height: Some(20.0),
2416                min_width: None,
2417                max_width: None,
2418                min_height: None,
2419                max_height: None,
2420                padding: [0.0; 4],
2421                flex_grow: 0.0,
2422                flex_shrink: 1.0,
2423                aspect_ratio: None,
2424            }),
2425            vec![],
2426        );
2427        ir.add_node(
2428            root,
2429            Op::Layout(LayoutOp::Flex {
2430                direction: fission_ir::FlexDirection::Column,
2431                wrap: fission_ir::op::FlexWrap::NoWrap,
2432                flex_grow: 0.0,
2433                flex_shrink: 1.0,
2434                padding: [0.0; 4],
2435                gap: Some(4.0),
2436                align_items: fission_ir::op::AlignItems::Start,
2437                justify_content: fission_ir::op::JustifyContent::Start,
2438            }),
2439            vec![first, second],
2440        );
2441        ir.set_root(root);
2442        ir
2443    }
2444
2445    #[test]
2446    fn unchanged_scroll_offsets_do_not_invalidate_cache() {
2447        let id = NodeId::derived(1, &[0]);
2448        let mut prev = HashMap::new();
2449        prev.insert(id, 12.5f32.to_bits());
2450        let mut scroll = ScrollStateMap::default();
2451        scroll.set_offset(id, 12.5);
2452        assert!(!scroll_offsets_changed(&prev, &scroll));
2453    }
2454
2455    #[test]
2456    fn changed_scroll_offsets_invalidate_cache() {
2457        let id = NodeId::derived(2, &[0]);
2458        let mut prev = HashMap::new();
2459        prev.insert(id, 0.0f32.to_bits());
2460        let mut scroll = ScrollStateMap::default();
2461        scroll.set_offset(id, 4.0);
2462        assert!(scroll_offsets_changed(&prev, &scroll));
2463    }
2464
2465    #[test]
2466    fn incremental_layout_keeps_rebuild_telemetry_honest() {
2467        let mut pipeline = Pipeline::new();
2468        let mut layout_engine = LayoutEngine::new();
2469        let scroll = ScrollStateMap::default();
2470
2471        pipeline.replace_ir(two_child_layout_ir(60.0), &Env::default());
2472        let first_pass = pipeline
2473            .ensure_layout(
2474                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2475                &mut layout_engine,
2476                &scroll,
2477            )
2478            .expect("initial layout");
2479        assert_eq!(first_pass, pipeline.layout_input_nodes.len());
2480        assert_eq!(pipeline.layout_full_rebuild_count, 1);
2481
2482        pipeline.replace_ir(two_child_layout_ir(90.0), &Env::default());
2483        let second_pass = pipeline
2484            .ensure_layout(
2485                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2486                &mut layout_engine,
2487                &scroll,
2488            )
2489            .expect("incremental layout");
2490
2491        assert_eq!(second_pass, 1);
2492        assert_eq!(pipeline.layout_full_rebuild_count, 1);
2493        assert!(pipeline.pending_layout_dirty_nodes.is_empty());
2494    }
2495
2496    #[test]
2497    fn rich_text_annotations_flow_into_display_ops() {
2498        let node_id = NodeId::derived(9, &[0]);
2499        let mut ir = CoreIR::new();
2500        ir.add_node(
2501            node_id,
2502            Op::Paint(PaintOp::DrawRichText {
2503                runs: vec![TextRun {
2504                    text: "docs".into(),
2505                    style: TextStyle {
2506                        font_size: 14.0,
2507                        color: Color::BLACK,
2508                        underline: false,
2509                        font_family: None,
2510                        locale: None,
2511                        font_weight: 400,
2512                        font_style: fission_ir::op::FontStyle::Normal,
2513                        line_height: None,
2514                        letter_spacing: 0.0,
2515                        background_color: None,
2516                    },
2517                }],
2518                wrap: true,
2519                caret_index: None,
2520                caret_color: None,
2521                caret_width: None,
2522                caret_height: None,
2523                caret_radius: None,
2524                paragraph_style: None,
2525            }),
2526            vec![],
2527        );
2528        ir.custom_render_objects.insert(
2529            node_id,
2530            Arc::new(vec![RichTextAnnotation {
2531                range: 0..4,
2532                semantics_label: Some("Documentation".into()),
2533                semantics_identifier: Some("docs-link".into()),
2534                spell_out: Some(true),
2535                mouse_cursor: Some(fission_ir::op::MouseCursor::Pointer),
2536                actions: vec![ActionEntry {
2537                    trigger: ActionTrigger::Default,
2538                    action_id: 7,
2539                    payload_data: Some(vec![1, 2, 3]),
2540                }],
2541            }]),
2542        );
2543
2544        let node = ir.nodes.get(&node_id).expect("paint node");
2545        let list =
2546            build_local_paint_list(&ir, node_id, node, LayoutRect::new(0.0, 0.0, 160.0, 40.0))
2547                .expect("display list");
2548        match list.ops.first() {
2549            Some(DisplayOp::DrawRichText { annotations, .. }) => {
2550                assert_eq!(annotations.len(), 1);
2551                assert_eq!(annotations[0].range, 0..4);
2552                assert_eq!(
2553                    annotations[0].semantics_identifier.as_deref(),
2554                    Some("docs-link")
2555                );
2556            }
2557            other => panic!("expected rich text op, got {other:?}"),
2558        }
2559    }
2560
2561    #[test]
2562    fn embed_layout_ops_flow_into_surface_display_ops() {
2563        let node_id = NodeId::derived(14, &[0]);
2564        let widget_id = WidgetNodeId::explicit("embed.surface");
2565        let mut ir = CoreIR::new();
2566        ir.add_node(
2567            node_id,
2568            Op::Layout(LayoutOp::Embed {
2569                kind: EmbedKind::Web,
2570                widget_id,
2571                width: Some(320.0),
2572                height: Some(180.0),
2573            }),
2574            vec![],
2575        );
2576
2577        let node = ir.nodes.get(&node_id).expect("embed node");
2578        let rect = LayoutRect::new(12.0, 24.0, 320.0, 180.0);
2579        let list = build_local_paint_list(&ir, node_id, node, rect).expect("display list");
2580
2581        match list.ops.first() {
2582            Some(DisplayOp::DrawSurface {
2583                rect: surface_rect,
2584                bounds,
2585                node_id: Some(surface_node_id),
2586                ..
2587            }) => {
2588                assert_eq!(*surface_rect, rect);
2589                assert_eq!(*bounds, rect);
2590                assert_eq!(*surface_node_id, node_id);
2591            }
2592            other => panic!("expected surface display op, got {other:?}"),
2593        }
2594    }
2595
2596    #[test]
2597    fn compositor_bound_opacity_animation_is_composite_only() {
2598        let mut ir = CoreIR::new();
2599        let child = NodeId::derived(10, &[1]);
2600        let root = NodeId::derived(10, &[0]);
2601        ir.add_node(child, Op::Layout(LayoutOp::AbsoluteFill), vec![]);
2602        ir.add_node_with_composite(
2603            root,
2604            Op::Structural(fission_ir::StructuralOp::Group { stable_hash: 1 }),
2605            CompositeStyle {
2606                opacity: Some(CompositeScalar::new(0.0).animated(WidgetNodeId::explicit("fade"))),
2607                ..Default::default()
2608            },
2609            vec![child],
2610        );
2611        ir.set_root(root);
2612
2613        let mut pipeline = Pipeline::new();
2614        pipeline.replace_ir(ir, &Env::default());
2615        let invalidation = pipeline.classify_animation_updates(&[(
2616            WidgetNodeId::explicit("fade"),
2617            AnimationPropertyId::Opacity,
2618        )]);
2619        assert_eq!(
2620            invalidation,
2621            InvalidationSet {
2622                build: false,
2623                layout: false,
2624                paint: false,
2625                composite: true,
2626            }
2627        );
2628    }
2629
2630    #[test]
2631    fn unbound_custom_animation_requires_build() {
2632        let pipeline = Pipeline::new();
2633        let invalidation = pipeline.classify_animation_updates(&[(
2634            WidgetNodeId::explicit("custom"),
2635            AnimationPropertyId::custom("phase"),
2636        )]);
2637        assert!(invalidation.build);
2638        assert!(invalidation.layout);
2639    }
2640
2641    #[test]
2642    fn compositor_bound_translate_animation_is_composite_only() {
2643        let mut ir = CoreIR::new();
2644        let child = NodeId::derived(11, &[1]);
2645        let root = NodeId::derived(11, &[0]);
2646        ir.add_node(
2647            child,
2648            Op::Paint(PaintOp::DrawRect {
2649                fill: Some(Fill::Solid(Color {
2650                    r: 0,
2651                    g: 0,
2652                    b: 0,
2653                    a: 255,
2654                })),
2655                stroke: None,
2656                corner_radius: 0.0,
2657                shadow: None,
2658            }),
2659            vec![],
2660        );
2661        ir.add_node_with_composite(
2662            root,
2663            Op::Layout(LayoutOp::Box {
2664                width: Some(120.0),
2665                height: Some(64.0),
2666                min_width: None,
2667                max_width: None,
2668                min_height: None,
2669                max_height: None,
2670                padding: [0.0, 0.0, 0.0, 0.0],
2671                flex_grow: 0.0,
2672                flex_shrink: 0.0,
2673                aspect_ratio: None,
2674            }),
2675            CompositeStyle {
2676                translate_x: Some(
2677                    CompositeScalar::new(12.0).animated(WidgetNodeId::explicit("slide")),
2678                ),
2679                ..Default::default()
2680            },
2681            vec![child],
2682        );
2683        ir.set_root(root);
2684
2685        let mut pipeline = Pipeline::new();
2686        pipeline.replace_ir(ir, &Env::default());
2687        let invalidation = pipeline.classify_animation_updates(&[(
2688            WidgetNodeId::explicit("slide"),
2689            AnimationPropertyId::TranslateX,
2690        )]);
2691        assert_eq!(
2692            invalidation,
2693            InvalidationSet {
2694                build: false,
2695                layout: false,
2696                paint: false,
2697                composite: true,
2698            }
2699        );
2700    }
2701
2702    #[test]
2703    fn dynamic_layer_with_static_contents_gets_content_cache_key() {
2704        let mut ir = CoreIR::new();
2705        let child = NodeId::derived(12, &[1]);
2706        let root = NodeId::derived(12, &[0]);
2707        ir.add_node(
2708            child,
2709            Op::Paint(PaintOp::DrawRect {
2710                fill: Some(Fill::Solid(Color {
2711                    r: 20,
2712                    g: 40,
2713                    b: 60,
2714                    a: 255,
2715                })),
2716                stroke: None,
2717                corner_radius: 8.0,
2718                shadow: None,
2719            }),
2720            vec![],
2721        );
2722        ir.add_node_with_composite(
2723            root,
2724            Op::Layout(LayoutOp::Box {
2725                width: Some(160.0),
2726                height: Some(72.0),
2727                min_width: None,
2728                max_width: None,
2729                min_height: None,
2730                max_height: None,
2731                padding: [0.0, 0.0, 0.0, 0.0],
2732                flex_grow: 0.0,
2733                flex_shrink: 0.0,
2734                aspect_ratio: None,
2735            }),
2736            CompositeStyle {
2737                opacity: Some(
2738                    CompositeScalar::new(0.4).animated(WidgetNodeId::explicit("fade-cache")),
2739                ),
2740                ..Default::default()
2741            },
2742            vec![child],
2743        );
2744        ir.set_root(root);
2745
2746        let mut pipeline = Pipeline::new();
2747        let mut layout_engine = LayoutEngine::new();
2748        let mut renderer = NullRenderer;
2749        let scroll = ScrollStateMap::default();
2750        pipeline.replace_ir(ir, &Env::default());
2751        pipeline
2752            .ensure_layout(
2753                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2754                &mut layout_engine,
2755                &scroll,
2756            )
2757            .unwrap();
2758        pipeline
2759            .render_current(
2760                LayoutSize {
2761                    width: 320.0,
2762                    height: 240.0,
2763                },
2764                LayoutSize {
2765                    width: 320.0,
2766                    height: 240.0,
2767                },
2768                false,
2769                &mut renderer,
2770                &scroll,
2771                &Default::default(),
2772                &Default::default(),
2773                &Default::default(),
2774            )
2775            .unwrap();
2776
2777        let scene = pipeline
2778            .retained_scene
2779            .as_ref()
2780            .expect("retained scene missing");
2781        let presentation_root = match scene.roots.first() {
2782            Some(fission_render::RenderNode::Layer(layer)) => layer,
2783            _ => panic!("missing presentation layer"),
2784        };
2785        let animated_layer = match presentation_root.children.first() {
2786            Some(fission_render::RenderNode::Layer(layer)) => layer,
2787            _ => panic!("missing animated layer"),
2788        };
2789
2790        assert!(animated_layer.style.cache_key.is_none());
2791        assert!(animated_layer.style.content_cache_key.is_some());
2792    }
2793
2794    #[test]
2795    fn nested_dynamic_descendant_becomes_child_texture_plan() {
2796        let mut ir = CoreIR::new();
2797        let left_paint = NodeId::derived(13, &[0]);
2798        let animated_paint = NodeId::derived(13, &[1]);
2799        let animated_wrapper = NodeId::derived(13, &[2]);
2800        let outer_static = NodeId::derived(13, &[3]);
2801        let outer_group = NodeId::derived(13, &[4]);
2802        let root = NodeId::derived(13, &[5]);
2803
2804        ir.add_node(
2805            left_paint,
2806            Op::Paint(PaintOp::DrawRect {
2807                fill: Some(Fill::Solid(Color {
2808                    r: 10,
2809                    g: 10,
2810                    b: 10,
2811                    a: 255,
2812                })),
2813                stroke: None,
2814                corner_radius: 0.0,
2815                shadow: None,
2816            }),
2817            vec![],
2818        );
2819        ir.add_node(
2820            animated_paint,
2821            Op::Paint(PaintOp::DrawRect {
2822                fill: Some(Fill::Solid(Color {
2823                    r: 200,
2824                    g: 40,
2825                    b: 40,
2826                    a: 255,
2827                })),
2828                stroke: None,
2829                corner_radius: 0.0,
2830                shadow: None,
2831            }),
2832            vec![],
2833        );
2834        ir.add_node_with_composite(
2835            animated_wrapper,
2836            Op::Layout(LayoutOp::Box {
2837                width: Some(96.0),
2838                height: Some(96.0),
2839                min_width: None,
2840                max_width: None,
2841                min_height: None,
2842                max_height: None,
2843                padding: [0.0, 0.0, 0.0, 0.0],
2844                flex_grow: 0.0,
2845                flex_shrink: 0.0,
2846                aspect_ratio: None,
2847            }),
2848            CompositeStyle {
2849                opacity: Some(
2850                    CompositeScalar::new(0.4).animated(WidgetNodeId::explicit("nested-fade")),
2851                ),
2852                ..Default::default()
2853            },
2854            vec![animated_paint],
2855        );
2856        ir.add_node(
2857            outer_static,
2858            Op::Paint(PaintOp::DrawRect {
2859                fill: Some(Fill::Solid(Color {
2860                    r: 20,
2861                    g: 100,
2862                    b: 180,
2863                    a: 255,
2864                })),
2865                stroke: None,
2866                corner_radius: 8.0,
2867                shadow: None,
2868            }),
2869            vec![],
2870        );
2871        ir.add_node(
2872            outer_group,
2873            Op::Layout(LayoutOp::Box {
2874                width: Some(160.0),
2875                height: Some(120.0),
2876                min_width: None,
2877                max_width: None,
2878                min_height: None,
2879                max_height: None,
2880                padding: [0.0, 0.0, 0.0, 0.0],
2881                flex_grow: 0.0,
2882                flex_shrink: 0.0,
2883                aspect_ratio: None,
2884            }),
2885            vec![outer_static, animated_wrapper],
2886        );
2887        ir.add_node(
2888            root,
2889            Op::Layout(LayoutOp::Box {
2890                width: Some(320.0),
2891                height: Some(240.0),
2892                min_width: None,
2893                max_width: None,
2894                min_height: None,
2895                max_height: None,
2896                padding: [0.0, 0.0, 0.0, 0.0],
2897                flex_grow: 0.0,
2898                flex_shrink: 0.0,
2899                aspect_ratio: None,
2900            }),
2901            vec![left_paint, outer_group],
2902        );
2903        ir.set_root(root);
2904
2905        let mut pipeline = Pipeline::new();
2906        let mut layout_engine = LayoutEngine::new();
2907        let scroll = ScrollStateMap::default();
2908        pipeline.replace_ir(ir, &Env::default());
2909        pipeline
2910            .ensure_layout(
2911                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2912                &mut layout_engine,
2913                &scroll,
2914            )
2915            .unwrap();
2916        pipeline
2917            .prepare_current(
2918                LayoutSize {
2919                    width: 320.0,
2920                    height: 240.0,
2921                },
2922                LayoutSize {
2923                    width: 320.0,
2924                    height: 240.0,
2925                },
2926                false,
2927                &scroll,
2928                &Default::default(),
2929                &Default::default(),
2930                &Default::default(),
2931            )
2932            .unwrap();
2933
2934        let plans = pipeline.texture_compositor_plans();
2935        assert!(!plans.is_empty());
2936        assert!(
2937            plans.iter().any(|plan| !plan.children.is_empty()),
2938            "expected at least one retained texture plan to extract nested dynamic descendants"
2939        );
2940    }
2941
2942    #[test]
2943    fn resize_preview_keeps_texture_compositor_root_transform() {
2944        let mut ir = CoreIR::new();
2945        let left = NodeId::derived(14, &[0]);
2946        let right = NodeId::derived(14, &[1]);
2947        let root = NodeId::derived(14, &[2]);
2948
2949        ir.add_node(
2950            left,
2951            Op::Paint(PaintOp::DrawRect {
2952                fill: Some(Fill::Solid(Color {
2953                    r: 80,
2954                    g: 80,
2955                    b: 80,
2956                    a: 255,
2957                })),
2958                stroke: None,
2959                corner_radius: 0.0,
2960                shadow: None,
2961            }),
2962            vec![],
2963        );
2964        ir.add_node(
2965            right,
2966            Op::Paint(PaintOp::DrawRect {
2967                fill: Some(Fill::Solid(Color {
2968                    r: 180,
2969                    g: 180,
2970                    b: 180,
2971                    a: 255,
2972                })),
2973                stroke: None,
2974                corner_radius: 0.0,
2975                shadow: None,
2976            }),
2977            vec![],
2978        );
2979        ir.add_node(
2980            root,
2981            Op::Layout(LayoutOp::Box {
2982                width: Some(300.0),
2983                height: Some(200.0),
2984                min_width: None,
2985                max_width: None,
2986                min_height: None,
2987                max_height: None,
2988                padding: [0.0, 0.0, 0.0, 0.0],
2989                flex_grow: 0.0,
2990                flex_shrink: 0.0,
2991                aspect_ratio: None,
2992            }),
2993            vec![left, right],
2994        );
2995        ir.set_root(root);
2996
2997        let mut pipeline = Pipeline::new();
2998        let mut layout_engine = LayoutEngine::new();
2999        let scroll = ScrollStateMap::default();
3000        pipeline.replace_ir(ir, &Env::default());
3001        pipeline
3002            .ensure_layout(
3003                LayoutRect::new(0.0, 0.0, 300.0, 200.0),
3004                &mut layout_engine,
3005                &scroll,
3006            )
3007            .unwrap();
3008        pipeline
3009            .prepare_current(
3010                LayoutSize {
3011                    width: 540.0,
3012                    height: 360.0,
3013                },
3014                LayoutSize {
3015                    width: 300.0,
3016                    height: 200.0,
3017                },
3018                true,
3019                &scroll,
3020                &Default::default(),
3021                &Default::default(),
3022                &Default::default(),
3023            )
3024            .unwrap();
3025
3026        assert!(pipeline.texture_compositor_root_transform().is_none());
3027        assert!(!pipeline.texture_compositor_plans().is_empty());
3028    }
3029
3030    #[test]
3031    fn scroll_only_layers_patch_retained_transforms_after_offset_changes() {
3032        let mut ir = CoreIR::new();
3033        let content = NodeId::derived(15, &[0]);
3034        let scroll = NodeId::derived(15, &[1]);
3035        let root = NodeId::derived(15, &[2]);
3036
3037        ir.add_node(
3038            content,
3039            Op::Paint(PaintOp::DrawRect {
3040                fill: Some(Fill::Solid(Color {
3041                    r: 120,
3042                    g: 120,
3043                    b: 220,
3044                    a: 255,
3045                })),
3046                stroke: None,
3047                corner_radius: 0.0,
3048                shadow: None,
3049            }),
3050            vec![],
3051        );
3052        ir.add_node(
3053            scroll,
3054            Op::Layout(LayoutOp::Scroll {
3055                direction: fission_ir::FlexDirection::Column,
3056                show_scrollbar: true,
3057                width: Some(320.0),
3058                height: Some(240.0),
3059                min_width: None,
3060                max_width: None,
3061                min_height: None,
3062                max_height: None,
3063                padding: [0.0, 0.0, 0.0, 0.0],
3064                flex_grow: 0.0,
3065                flex_shrink: 0.0,
3066            }),
3067            vec![content],
3068        );
3069        ir.add_node(
3070            root,
3071            Op::Layout(LayoutOp::Box {
3072                width: Some(320.0),
3073                height: Some(240.0),
3074                min_width: None,
3075                max_width: None,
3076                min_height: None,
3077                max_height: None,
3078                padding: [0.0, 0.0, 0.0, 0.0],
3079                flex_grow: 0.0,
3080                flex_shrink: 0.0,
3081                aspect_ratio: None,
3082            }),
3083            vec![scroll],
3084        );
3085        ir.set_root(root);
3086
3087        let mut pipeline = Pipeline::new();
3088        let mut layout_engine = LayoutEngine::new();
3089        let scroll0 = ScrollStateMap::default();
3090        pipeline.replace_ir(ir, &Env::default());
3091        pipeline
3092            .ensure_layout(
3093                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3094                &mut layout_engine,
3095                &scroll0,
3096            )
3097            .unwrap();
3098        pipeline
3099            .prepare_current(
3100                LayoutSize {
3101                    width: 320.0,
3102                    height: 240.0,
3103                },
3104                LayoutSize {
3105                    width: 320.0,
3106                    height: 240.0,
3107                },
3108                false,
3109                &scroll0,
3110                &Default::default(),
3111                &Default::default(),
3112                &Default::default(),
3113            )
3114            .unwrap();
3115
3116        let mut scroll1 = ScrollStateMap::default();
3117        scroll1.set_offset(scroll, 180.0);
3118        pipeline
3119            .prepare_current(
3120                LayoutSize {
3121                    width: 320.0,
3122                    height: 240.0,
3123                },
3124                LayoutSize {
3125                    width: 320.0,
3126                    height: 240.0,
3127                },
3128                false,
3129                &scroll1,
3130                &Default::default(),
3131                &Default::default(),
3132                &Default::default(),
3133            )
3134            .unwrap();
3135
3136        fn find_layer_by_node(
3137            node: &fission_render::RenderNode,
3138            node_id: NodeId,
3139        ) -> Option<&fission_render::RenderLayer> {
3140            match node {
3141                fission_render::RenderNode::Paint(_) => None,
3142                fission_render::RenderNode::Layer(layer) => {
3143                    if layer.node_id == Some(node_id) {
3144                        return Some(layer);
3145                    }
3146                    for child in &layer.children {
3147                        if let Some(found) = find_layer_by_node(child, node_id) {
3148                            return Some(found);
3149                        }
3150                    }
3151                    None
3152                }
3153            }
3154        }
3155
3156        let scroll_layer = pipeline
3157            .retained_scene()
3158            .and_then(|scene| {
3159                scene
3160                    .roots
3161                    .iter()
3162                    .find_map(|node| find_layer_by_node(node, scroll))
3163            })
3164            .expect("expected a retained scroll layer");
3165        assert!(
3166            scroll_layer.style.transform.is_none(),
3167            "scrollbar chrome must not inherit the content scroll transform"
3168        );
3169        let transform = scroll_layer
3170            .children
3171            .iter()
3172            .find_map(|child| match child {
3173                fission_render::RenderNode::Layer(layer) => layer.style.transform,
3174                fission_render::RenderNode::Paint(_) => None,
3175            })
3176            .expect("scroll content layer should carry a compositor transform");
3177        assert!(
3178            (transform[13] + 180.0).abs() <= 0.01,
3179            "expected retained content transform to patch to -180, got {}",
3180            transform[13]
3181        );
3182    }
3183
3184    #[test]
3185    fn scrollbar_thumb_patches_after_scroll_offset_changes() {
3186        let mut ir = CoreIR::new();
3187        let fill = NodeId::derived(18, &[0]);
3188        let content = NodeId::derived(18, &[1]);
3189        let scroll = NodeId::derived(18, &[2]);
3190        let root = NodeId::derived(18, &[3]);
3191
3192        ir.add_node(
3193            fill,
3194            Op::Paint(PaintOp::DrawRect {
3195                fill: Some(Fill::Solid(Color {
3196                    r: 120,
3197                    g: 120,
3198                    b: 220,
3199                    a: 255,
3200                })),
3201                stroke: None,
3202                corner_radius: 0.0,
3203                shadow: None,
3204            }),
3205            vec![],
3206        );
3207        ir.add_node(
3208            content,
3209            Op::Layout(LayoutOp::Box {
3210                width: Some(320.0),
3211                height: Some(640.0),
3212                min_width: None,
3213                max_width: None,
3214                min_height: None,
3215                max_height: None,
3216                padding: [0.0, 0.0, 0.0, 0.0],
3217                flex_grow: 0.0,
3218                flex_shrink: 0.0,
3219                aspect_ratio: None,
3220            }),
3221            vec![fill],
3222        );
3223        ir.add_node(
3224            scroll,
3225            Op::Layout(LayoutOp::Scroll {
3226                direction: fission_ir::FlexDirection::Column,
3227                show_scrollbar: true,
3228                width: Some(320.0),
3229                height: Some(240.0),
3230                min_width: None,
3231                max_width: None,
3232                min_height: None,
3233                max_height: None,
3234                padding: [0.0, 0.0, 0.0, 0.0],
3235                flex_grow: 0.0,
3236                flex_shrink: 0.0,
3237            }),
3238            vec![content],
3239        );
3240        ir.add_node(
3241            root,
3242            Op::Layout(LayoutOp::Box {
3243                width: Some(320.0),
3244                height: Some(240.0),
3245                min_width: None,
3246                max_width: None,
3247                min_height: None,
3248                max_height: None,
3249                padding: [0.0, 0.0, 0.0, 0.0],
3250                flex_grow: 0.0,
3251                flex_shrink: 0.0,
3252                aspect_ratio: None,
3253            }),
3254            vec![scroll],
3255        );
3256        ir.set_root(root);
3257
3258        let mut pipeline = Pipeline::new();
3259        let mut layout_engine = LayoutEngine::new();
3260        let scroll0 = ScrollStateMap::default();
3261        pipeline.replace_ir(ir, &Env::default());
3262        pipeline
3263            .ensure_layout(
3264                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3265                &mut layout_engine,
3266                &scroll0,
3267            )
3268            .unwrap();
3269        pipeline
3270            .prepare_current(
3271                LayoutSize::new(320.0, 240.0),
3272                LayoutSize::new(320.0, 240.0),
3273                false,
3274                &scroll0,
3275                &Default::default(),
3276                &Default::default(),
3277                &Default::default(),
3278            )
3279            .unwrap();
3280        let initial_thumb_y = scrollbar_thumb_y(pipeline.retained_scene().unwrap(), scroll)
3281            .expect("initial scrollbar thumb");
3282
3283        let mut scroll1 = ScrollStateMap::default();
3284        scroll1.set_offset(scroll, 200.0);
3285        pipeline
3286            .prepare_current(
3287                LayoutSize::new(320.0, 240.0),
3288                LayoutSize::new(320.0, 240.0),
3289                false,
3290                &scroll1,
3291                &Default::default(),
3292                &Default::default(),
3293                &Default::default(),
3294            )
3295            .unwrap();
3296        let moved_thumb_y = scrollbar_thumb_y(pipeline.retained_scene().unwrap(), scroll)
3297            .expect("moved scrollbar thumb");
3298
3299        assert!(
3300            moved_thumb_y > initial_thumb_y,
3301            "body scroll must patch the retained scrollbar thumb, before={initial_thumb_y}, after={moved_thumb_y}"
3302        );
3303
3304        fn scrollbar_thumb_y(scene: &fission_render::RenderScene, scroll: NodeId) -> Option<f32> {
3305            fn find(node: &fission_render::RenderNode, scroll: NodeId) -> Option<f32> {
3306                match node {
3307                    fission_render::RenderNode::Paint(list) => list.ops.iter().find_map(|op| {
3308                        if let fission_render::DisplayOp::DrawRect { rect, node_id, .. } = op {
3309                            if *node_id == Some(scroll)
3310                                && (rect.width() - 6.0).abs() <= 0.01
3311                                && rect.height() < 200.0
3312                            {
3313                                return Some(rect.origin.y);
3314                            }
3315                        }
3316                        None
3317                    }),
3318                    fission_render::RenderNode::Layer(layer) => {
3319                        layer.children.iter().find_map(|child| find(child, scroll))
3320                    }
3321                }
3322            }
3323            scene.roots.iter().find_map(|root| find(root, scroll))
3324        }
3325    }
3326
3327    #[test]
3328    fn overflowing_scroll_nodes_emit_visible_scroll_rails() {
3329        let mut ir = CoreIR::new();
3330        let fill = NodeId::derived(16, &[0]);
3331        let content = NodeId::derived(16, &[1]);
3332        let scroll = NodeId::derived(16, &[2]);
3333        let root = NodeId::derived(16, &[3]);
3334
3335        ir.add_node(
3336            fill,
3337            Op::Paint(PaintOp::DrawRect {
3338                fill: Some(Fill::Solid(Color {
3339                    r: 80,
3340                    g: 120,
3341                    b: 220,
3342                    a: 255,
3343                })),
3344                stroke: None,
3345                corner_radius: 0.0,
3346                shadow: None,
3347            }),
3348            vec![],
3349        );
3350        ir.add_node(
3351            content,
3352            Op::Layout(LayoutOp::Box {
3353                width: Some(320.0),
3354                height: Some(640.0),
3355                min_width: None,
3356                max_width: None,
3357                min_height: None,
3358                max_height: None,
3359                padding: [0.0, 0.0, 0.0, 0.0],
3360                flex_grow: 0.0,
3361                flex_shrink: 0.0,
3362                aspect_ratio: None,
3363            }),
3364            vec![fill],
3365        );
3366        ir.add_node(
3367            scroll,
3368            Op::Layout(LayoutOp::Scroll {
3369                direction: fission_ir::FlexDirection::Column,
3370                show_scrollbar: true,
3371                width: Some(320.0),
3372                height: Some(240.0),
3373                min_width: None,
3374                max_width: None,
3375                min_height: None,
3376                max_height: None,
3377                padding: [0.0, 0.0, 0.0, 0.0],
3378                flex_grow: 0.0,
3379                flex_shrink: 0.0,
3380            }),
3381            vec![content],
3382        );
3383        ir.add_node(
3384            root,
3385            Op::Layout(LayoutOp::Box {
3386                width: Some(320.0),
3387                height: Some(240.0),
3388                min_width: None,
3389                max_width: None,
3390                min_height: None,
3391                max_height: None,
3392                padding: [0.0, 0.0, 0.0, 0.0],
3393                flex_grow: 0.0,
3394                flex_shrink: 0.0,
3395                aspect_ratio: None,
3396            }),
3397            vec![scroll],
3398        );
3399        ir.set_root(root);
3400
3401        let mut pipeline = Pipeline::new();
3402        let mut layout_engine = LayoutEngine::new();
3403        let scroll_map = ScrollStateMap::default();
3404        pipeline.replace_ir(ir, &Env::default());
3405        pipeline
3406            .ensure_layout(
3407                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3408                &mut layout_engine,
3409                &scroll_map,
3410            )
3411            .unwrap();
3412        pipeline
3413            .prepare_current(
3414                LayoutSize {
3415                    width: 320.0,
3416                    height: 240.0,
3417                },
3418                LayoutSize {
3419                    width: 320.0,
3420                    height: 240.0,
3421                },
3422                false,
3423                &scroll_map,
3424                &Default::default(),
3425                &Default::default(),
3426                &Default::default(),
3427            )
3428            .unwrap();
3429
3430        fn count_scroll_rails(node: &fission_render::RenderNode, scroll: NodeId) -> usize {
3431            match node {
3432                fission_render::RenderNode::Paint(list) => list
3433                    .ops
3434                    .iter()
3435                    .filter(|op| match op {
3436                        fission_render::DisplayOp::DrawRect { rect, node_id, .. } => {
3437                            *node_id == Some(scroll)
3438                                && (rect.width() - 6.0).abs() <= 0.01
3439                                && rect.height() >= 200.0
3440                        }
3441                        _ => false,
3442                    })
3443                    .count(),
3444                fission_render::RenderNode::Layer(layer) => layer
3445                    .children
3446                    .iter()
3447                    .map(|child| count_scroll_rails(child, scroll))
3448                    .sum(),
3449            }
3450        }
3451
3452        let rail_count: usize = pipeline
3453            .retained_scene()
3454            .expect("retained scene")
3455            .roots
3456            .iter()
3457            .map(|node| count_scroll_rails(node, scroll))
3458            .sum();
3459        assert!(
3460            rail_count > 0,
3461            "expected an overflow rail for the scroll node"
3462        );
3463    }
3464}