Skip to main content

cranpose_render_wgpu/
render.rs

1//! GPU rendering implementation using WGPU
2
3use crate::effect_renderer::{
4    projective_dest_bounds_rect, CompositeBatchItem, CompositeSampleMode, EffectRenderer,
5    EffectScratchTargetProvider, ProjectiveSurfaceComposite, RoundedCompositeMask,
6    ShaderCompositeBatchItem,
7};
8use crate::frame_graph::{
9    FrameCommandRecorder, FrameTextureDescriptor, WgpuFrameGraph, WgpuFrameGraphExecutor,
10};
11use crate::frame_packet::{
12    CancelReason, FramePacket, PacketRoot, PresentOutcome, RenderReturns, RootSurfacePacket,
13};
14use crate::layer_events::{
15    collect_effect_ranges, collect_layer_events, LayerEvent, LayerEventKind,
16};
17use crate::layer_surface_cache::LayerSurfaceCache;
18use crate::lazy_resource::LazyGpuResource;
19#[cfg(test)]
20use crate::normalized_scene::{
21    build_scene_window, collect_layer_contents, collect_layer_contents_with_translation_context,
22    filtered_effect_layer_index, scene_bounds, SceneWindowSource,
23};
24#[cfg(test)]
25use crate::normalized_scene::{estimate_layer_surface_rect, motion_stable_capture_bounds};
26use crate::normalized_scene::{translate_quad, ChildLayerComposite, CollectedLayer};
27use crate::offscreen::OffscreenTarget;
28use crate::rect_to_quad;
29use crate::scene::{
30    BackdropLayer, CompositorScene, DrawOp, DrawOpKind, DrawShape, EffectLayer, ImageDraw,
31    RetainedDraw, ShadowDraw, SimilarityTransform, SnapAnchor, TextDraw,
32};
33use crate::shaders;
34#[cfg(test)]
35use crate::surface_executor::surface_target_size;
36use crate::surface_executor::{
37    apply_backdrop_layer_to_target as execute_apply_backdrop_layer_to_target,
38    axis_aligned_quad_rect, backdrop_underlay_is_covered_by_local_content,
39    canonicalize_device_coordinate, canonicalized_scaled_quad, canonicalized_scaled_rect,
40    composite_surface_to_view as execute_composite_surface_to_view, device_pixel_bounds_for_rect,
41    offscreen_byte_size, render_effect_layer_to_target as execute_render_effect_layer_to_target,
42    render_layer_surface as execute_render_layer_surface,
43    render_root_direct as execute_render_root_direct, root_direct_scene_events_are_supported,
44    scaled_quad, snap_delta_for_anchor, snap_motion_stable_dest_quad,
45    translation_stable_anchored_device_pixel_bounds, DevicePixelBounds, LayerSurfaceTexture,
46    SurfaceExecutionBackend,
47};
48#[cfg(test)]
49use crate::surface_executor::{clamp_effect_surface_scale, visible_layer_rect};
50#[cfg(test)]
51use crate::surface_plan::root_can_render_directly_cached;
52#[cfg(test)]
53use crate::surface_plan::{
54    composite_sample_mode_for_effect_layer, composite_sample_mode_for_requirements,
55    direct_translation, effect_layer_target_scale, layer_contains_descendant_backdrop,
56    layer_surface_requirements, layer_surface_requirements_cached, layer_surface_scale,
57    layer_surface_target_scale, layer_uses_external_backdrop_input, TranslatedContentAxes,
58};
59use crate::surface_plan::{LayerSurfaceRequest, TranslationRenderContext};
60#[cfg(test)]
61use crate::surface_requirements::SurfaceRequirement;
62use crate::surface_requirements::SurfaceRequirementSet;
63use crate::DebugCpuAllocationStats;
64use bytemuck::{Pod, Zeroable};
65#[cfg(any(not(target_arch = "wasm32"), test))]
66use cranpose_core::collections::map::HashMap;
67use cranpose_core::{hash::default as default_hash, NodeId};
68use cranpose_render_common::bounded_lru_cache::BoundedLruCache;
69use cranpose_render_common::geometry::blur_extent_margin;
70use cranpose_render_common::graph::quad_bounds;
71#[cfg(test)]
72use cranpose_render_common::graph::{
73    CachePolicy, LayerNode, PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform,
74    RenderNode,
75};
76use cranpose_render_common::raster_cache::LayerRasterCacheKey;
77#[cfg(test)]
78use cranpose_render_common::raster_cache::ScaleBucket;
79use cranpose_render_common::software_text_raster::{
80    collect_solid_text_atlas_run, measure_text_with_font,
81    rasterize_annotated_text_to_image_with_glyph_cache, rasterize_text_to_image_with_glyph_cache,
82    SoftwareGlyphAtlasGlyph, SoftwareGlyphAtlasKey, SoftwareGlyphAtlasPlacement,
83    SoftwareGlyphAtlasRunGlyph, SoftwareGlyphRasterCache, SoftwareTextFontSet,
84};
85#[cfg(test)]
86use cranpose_ui_graphics::GraphicsLayer;
87use cranpose_ui_graphics::{
88    BlendMode, Brush, Color, ColorFilter, FxHasher, ImageBitmap, ImageSampling, Point, Rect,
89    RenderEffect, RenderHash, RuntimeShader, StrokeCap, StrokeJoin, TileMode,
90};
91use std::borrow::Cow;
92use std::cell::Cell;
93use std::hash::{Hash, Hasher};
94use std::ops::Range;
95use std::rc::Rc;
96#[cfg(not(target_arch = "wasm32"))]
97use std::sync::atomic::{AtomicUsize, Ordering};
98use std::sync::{mpsc, Arc};
99use std::time::Duration;
100use web_time::Instant;
101
102use crate::gpu_stats;
103use crate::gpu_stats::gpu_stats_enabled;
104use crate::pipeline::push_layer_shadow;
105
106/// Must equal the `array<ShapeData, N>` literal in `shape.wgsl`: on wasm the
107/// shader source is used verbatim, so a larger batch cap here would index past
108/// the declared array. 102 x 160-byte ShapeData = 16320 bytes, the most that
109/// fits WebGL's 16 KiB uniform-binding floor.
110#[cfg(target_arch = "wasm32")]
111const MAX_SHAPES_PER_BATCH: usize = 102;
112#[cfg(not(target_arch = "wasm32"))]
113const MAX_SHAPES_PER_BATCH: usize = 768;
114#[cfg(target_arch = "wasm32")]
115const MAX_GRADIENT_STOPS: usize = 256;
116#[cfg(not(target_arch = "wasm32"))]
117const MAX_GRADIENT_STOPS: usize = 1024;
118
119/// Per-pass ceilings when the shape and gradient arrays live in storage
120/// buffers instead of uniforms. These are not hardware limits — storage
121/// bindings are hundreds of megabytes everywhere — they bound worst-case
122/// buffer growth: 65 536 shapes is a 7 MiB shape buffer and a 12 MiB vertex
123/// buffer, far past any real scene, while still forcing a batch split before
124/// a pathological one can ask for gigabytes.
125#[cfg(not(target_arch = "wasm32"))]
126const MAX_SHAPES_PER_STORAGE_BATCH: usize = 1 << 16;
127#[cfg(not(target_arch = "wasm32"))]
128const MAX_GRADIENT_STOPS_PER_STORAGE_BATCH: usize = 1 << 16;
129
130/// How many shapes/stops the storage-mode buffers start out sized for. In
131/// uniform mode the initial capacity must equal the cap (a uniform binding
132/// smaller than the shader's fixed-length array fails validation), but a
133/// runtime-sized storage array binds at any size, so start small and let
134/// `ensure_capacity` double toward the cap as scenes demand.
135#[cfg(not(target_arch = "wasm32"))]
136const INITIAL_STORAGE_BATCH_CAPACITY: usize = 1024;
137
138/// Shape/gradient batch capacities derived from the actual device limits.
139///
140/// Where storage buffers are available (any real Vulkan/Metal/D3D device, and
141/// GL only when it exposes SSBOs to fragment shaders) the arrays are bound as
142/// read-only storage and a whole scene fits one batch. Otherwise they fall
143/// back to uniform arrays: the compile-time `MAX_*` constants assume
144/// desktop-class 64 KiB uniform bindings, while Android downlevel and
145/// GLES-class devices may only offer the 16 KiB spec minimum; sizing the
146/// buffers (and the matching WGSL array lengths) past
147/// `max_uniform_buffer_binding_size` makes the very first "Shape Bind Group"
148/// fail validation and aborts the app.
149#[derive(Clone, Copy, Debug, Eq, PartialEq)]
150struct ShapeBatchLimits {
151    max_shapes_per_batch: usize,
152    max_gradient_stops: usize,
153    storage: bool,
154}
155
156impl ShapeBatchLimits {
157    fn for_device(device: &wgpu::Device) -> Self {
158        let limits = device.limits();
159        #[cfg(not(target_arch = "wasm32"))]
160        if limits.max_storage_buffers_per_shader_stage >= 2 {
161            return Self::for_storage_binding_size(limits.max_storage_buffer_binding_size);
162        }
163        Self::for_uniform_binding_size(limits.max_uniform_buffer_binding_size)
164    }
165
166    fn for_uniform_binding_size(max_uniform_buffer_binding_size: u64) -> Self {
167        let binding = max_uniform_buffer_binding_size as usize;
168        Self {
169            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
170                .clamp(1, MAX_SHAPES_PER_BATCH),
171            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
172                .clamp(1, MAX_GRADIENT_STOPS),
173            storage: false,
174        }
175    }
176
177    #[cfg(not(target_arch = "wasm32"))]
178    fn for_storage_binding_size(max_storage_buffer_binding_size: u64) -> Self {
179        let binding = max_storage_buffer_binding_size as usize;
180        Self {
181            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
182                .clamp(1, MAX_SHAPES_PER_STORAGE_BATCH),
183            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
184                .clamp(1, MAX_GRADIENT_STOPS_PER_STORAGE_BATCH),
185            storage: true,
186        }
187    }
188
189    fn initial_shape_capacity(&self) -> usize {
190        #[cfg(not(target_arch = "wasm32"))]
191        if self.storage {
192            return self
193                .max_shapes_per_batch
194                .min(INITIAL_STORAGE_BATCH_CAPACITY);
195        }
196        self.max_shapes_per_batch
197    }
198
199    fn initial_gradient_capacity(&self) -> usize {
200        #[cfg(not(target_arch = "wasm32"))]
201        if self.storage {
202            return self.max_gradient_stops.min(INITIAL_STORAGE_BATCH_CAPACITY);
203        }
204        self.max_gradient_stops
205    }
206
207    fn data_buffer_usage(&self) -> wgpu::BufferUsages {
208        if self.storage {
209            wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST
210        } else {
211            wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST
212        }
213    }
214
215    fn data_binding_type(&self) -> wgpu::BufferBindingType {
216        if self.storage {
217            wgpu::BufferBindingType::Storage { read_only: true }
218        } else {
219            wgpu::BufferBindingType::Uniform
220        }
221    }
222
223    #[cfg(test)]
224    fn desktop() -> Self {
225        Self::for_uniform_binding_size(wgpu::Limits::default().max_uniform_buffer_binding_size)
226    }
227}
228#[cfg(target_arch = "wasm32")]
229const HARD_MAX_BUFFER_MB: usize = 64; // Maximum 64MB per buffer (image vertex/index only)
230const MAX_SHADOW_SURFACE_CACHE_ITEMS: usize = 512;
231// Sized for HiDPI: a 4K fractional-scale screen full of shadowed panels needs
232// ~10-15 rasters of 4-12MB each; a 64MB budget made the large entries evict
233// each other every frame during scroll, re-blurring tens of megapixels.
234const MAX_SHADOW_SURFACE_CACHE_BYTES: u64 = 192 * 1024 * 1024;
235const MAX_TEXT_IMAGE_CACHE_ITEMS: usize = 1024;
236const MAX_TEXT_GLYPH_MASK_CACHE_ITEMS: usize = 8192;
237const MAX_TEXT_GLYPH_ATLAS_ITEMS: usize = 8192;
238const MAX_TEXT_GLYPH_RUN_CACHE_ITEMS: usize = 1024;
239#[cfg(not(target_arch = "wasm32"))]
240const MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS: usize = 1024;
241#[cfg(not(target_arch = "wasm32"))]
242const MIN_RETAINED_TEXT_GLYPH_QUADS: usize = 192;
243#[cfg(not(target_arch = "wasm32"))]
244const OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS: f64 = 0.75;
245#[cfg(not(target_arch = "wasm32"))]
246const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES: usize = 2;
247#[cfg(not(target_arch = "wasm32"))]
248const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS: usize = 160;
249#[cfg(not(target_arch = "wasm32"))]
250const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS: usize = 160;
251/// Side length the glyph atlas starts at, and the one it doubles towards.
252///
253/// The atlas is square and `R8Unorm`, so the maximum is a 16 MiB texture. That
254/// was also the starting size until it became the single largest resource the
255/// renderer allocated: a 454x454 watch face draws a couple of hundred distinct
256/// glyphs and needs well under a megabyte of them, but paid the full 16 MiB at
257/// renderer construction, before a single glyph had been rastered. Starting at
258/// `MIN` and doubling on overflow (see `TextGlyphAtlas::reset`) costs at most
259/// three extra resets for a workload that genuinely needs the large atlas —
260/// which then behaves exactly as the fixed 4096 atlas did — and costs a
261/// text-light screen 256 KiB instead of 16 MiB, permanently.
262const TEXT_GLYPH_ATLAS_MIN_SIZE: u32 = 512;
263const TEXT_GLYPH_ATLAS_MAX_SIZE: u32 = 4096;
264const TEXT_GLYPH_ATLAS_PADDING: u32 = 1;
265const MAX_TEXT_LINE_INDEX_CACHE_ITEMS: usize = 512;
266const MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER: usize = 2;
267const MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES: usize = 128;
268const CACHE_MISS_WARMUP_FRAMES: u8 = 1;
269pub(crate) const CLEAR_COLOR: wgpu::Color = wgpu::Color {
270    r: cranpose_render_common::FRAME_CLEAR_COLOR[0] as f64,
271    g: cranpose_render_common::FRAME_CLEAR_COLOR[1] as f64,
272    b: cranpose_render_common::FRAME_CLEAR_COLOR[2] as f64,
273    a: cranpose_render_common::FRAME_CLEAR_COLOR[3] as f64,
274};
275#[cfg(not(target_arch = "wasm32"))]
276const INITIAL_UPLOAD_BUFFER_BYTES: u64 = 4 * 1024;
277#[cfg(not(target_arch = "wasm32"))]
278const INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS: usize = 128;
279const MAX_TEXTURE_CACHE_ITEMS: usize = 256;
280/// Byte ceiling for `image_texture_cache` (see `CachedImageTexture::bytes`).
281/// Generous enough for a screenful of full-page images plus thumbnails;
282/// small enough that a camera preview stream can never pin gigabytes.
283const MAX_IMAGE_TEXTURE_CACHE_BYTES: usize = 256 * 1024 * 1024;
284const RETAINED_STAGED_UPLOAD_BYTES: usize = 256 * 1024;
285const RETAINED_STAGED_UPLOAD_COPIES: usize = 128;
286pub(crate) const RETAINED_LAYER_REQUIREMENTS_CAPACITY: usize = 512;
287const DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS: f64 = 4.0;
288#[cfg(not(target_arch = "wasm32"))]
289static SEGMENT_DIAG_LINES: AtomicUsize = AtomicUsize::new(0);
290// Reclaim oversized text scratch allocations only after a meaningful 4x collapse
291// from a previously large frame; smaller swings are left alone to avoid churn.
292
293fn wgpu_render_stage_telemetry_threshold_ms() -> Option<f64> {
294    static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
295    *THRESHOLD_MS.get_or_init(|| {
296        let explicit = std::env::var("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY_MS")
297            .ok()
298            .and_then(|value| value.parse::<f64>().ok())
299            .filter(|value| value.is_finite() && *value >= 0.0);
300        explicit.or_else(|| {
301            std::env::var_os("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY")
302                .is_some()
303                .then_some(DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS)
304        })
305    })
306}
307
308pub(crate) fn instant_ms(start: Instant, end: Instant) -> f64 {
309    end.duration_since(start).as_secs_f64() * 1000.0
310}
311
312pub(crate) fn should_log_wgpu_render_stage(start: Instant, end: Instant) -> Option<f64> {
313    let threshold_ms = wgpu_render_stage_telemetry_threshold_ms()?;
314    let total_ms = instant_ms(start, end);
315    (total_ms >= threshold_ms).then_some(total_ms)
316}
317
318fn admit_layer_surface_cache_miss_impl(
319    key: &LayerRasterCacheKey,
320    observed_scene_range_misses: &mut BoundedLruCache<LayerRasterCacheKey, ()>,
321) -> bool {
322    if !key.is_scene_range() {
323        return true;
324    }
325    if observed_scene_range_misses.contains(key) {
326        return true;
327    }
328    observed_scene_range_misses.put(*key, ());
329    false
330}
331
332#[cfg(test)]
333fn first_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
334    let mut observed_scene_range_misses =
335        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
336    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
337}
338
339#[cfg(test)]
340fn repeated_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
341    let mut observed_scene_range_misses =
342        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
343    let _ = admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses);
344    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
345}
346
347pub static PRESENTED_FRAMES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
348
349pub fn frames_presented() -> u64 {
350    PRESENTED_FRAMES.load(std::sync::atomic::Ordering::Relaxed)
351}
352
353fn frame_stats_need_warmup_frame(snapshot: &gpu_stats::FrameStatsSnapshot) -> bool {
354    snapshot.layer_cache_misses > 0
355        || snapshot.shadow_shape_cache_misses > 0
356        || snapshot.text_image_cache_misses > 0
357        || snapshot.text_glyph_atlas_misses > 0
358}
359
360fn update_frame_warmup_budget(pending_frames: &mut u8, snapshot: &gpu_stats::FrameStatsSnapshot) {
361    if *pending_frames > 0 {
362        *pending_frames = pending_frames.saturating_sub(1);
363    } else if frame_stats_need_warmup_frame(snapshot) {
364        *pending_frames = CACHE_MISS_WARMUP_FRAMES;
365    }
366}
367
368fn text_atlas_fallback_diag_enabled() -> bool {
369    cranpose_core::env_flag!("CRANPOSE_TEXT_ATLAS_FALLBACK_DIAG")
370}
371
372fn text_glyph_run_diag_enabled() -> bool {
373    cranpose_core::env_flag!("CRANPOSE_TEXT_GLYPH_RUN_DIAG")
374}
375
376fn root_direct_diag_enabled() -> bool {
377    cranpose_core::env_flag!("CRANPOSE_ROOT_DIRECT_DIAG")
378}
379
380fn scene_layer_events_precede_z(scene: &CompositorScene, z_index: usize) -> bool {
381    scene
382        .effect_layers
383        .iter()
384        .any(|layer| layer.z_start < z_index && 0 < layer.z_end)
385        || scene
386            .backdrop_layers
387            .iter()
388            .any(|layer| layer.z_index < z_index)
389}
390
391fn direct_root_child_can_be_replayed_into_later_underlay(child: &ChildLayerComposite) -> bool {
392    child.backdrop.is_none()
393        && !child.has_effect
394        && child.shadow_draws.is_empty()
395        && axis_aligned_quad_rect(child.dest_quad).is_some()
396}
397
398fn rects_overlap(a: Rect, b: Rect) -> bool {
399    let a_right = a.x + a.width;
400    let a_bottom = a.y + a.height;
401    let b_right = b.x + b.width;
402    let b_bottom = b.y + b.height;
403    a.x < b_right && b.x < a_right && a.y < b_bottom && b.y < a_bottom
404}
405
406pub(crate) fn direct_root_child_underlays_are_supported(collected: &CollectedLayer) -> bool {
407    for (child_index, child) in collected.child_layers.iter().enumerate() {
408        if child.backdrop.is_some() {
409            if root_direct_diag_enabled() {
410                log::warn!(
411                    "[root-direct-diag] reject self-backdrop child node={:?}",
412                    child.node_id
413                );
414            }
415            return false;
416        }
417        if child.needs_nested_underlay {
418            let Some(dest_rect) = axis_aligned_quad_rect(child.dest_quad) else {
419                if root_direct_diag_enabled() {
420                    log::warn!(
421                        "[root-direct-diag] reject projective underlay child node={:?}",
422                        child.node_id
423                    );
424                }
425                return false;
426            };
427            let translation_only = (dest_rect.width - child.logical_rect.width).abs() <= 0.001
428                && (dest_rect.height - child.logical_rect.height).abs() <= 0.001;
429            let unsupported_preceding_child_layer = collected.child_layers[..child_index]
430                .iter()
431                .any(|preceding| {
432                    if direct_root_child_can_be_replayed_into_later_underlay(preceding) {
433                        return false;
434                    }
435                    axis_aligned_quad_rect(preceding.dest_quad)
436                        .is_none_or(|preceding_rect| rects_overlap(preceding_rect, dest_rect))
437                });
438            let preceding_scene_events =
439                scene_layer_events_precede_z(&collected.scene, child.z_index);
440            if unsupported_preceding_child_layer || preceding_scene_events || !translation_only {
441                if root_direct_diag_enabled() {
442                    log::warn!(
443                        "[root-direct-diag] reject underlay child node={:?} unsupported_preceding_child_layer={} preceding_scene_events={} translation_only={} dest=({:.1},{:.1},{:.1},{:.1}) logical=({:.1},{:.1},{:.1},{:.1})",
444                        child.node_id,
445                        unsupported_preceding_child_layer,
446                        preceding_scene_events,
447                        translation_only,
448                        dest_rect.x,
449                        dest_rect.y,
450                        dest_rect.width,
451                        dest_rect.height,
452                        child.logical_rect.x,
453                        child.logical_rect.y,
454                        child.logical_rect.width,
455                        child.logical_rect.height
456                    );
457                }
458                return false;
459            }
460        }
461    }
462    true
463}
464
465#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
466struct ShadowSurfaceCacheKey {
467    content_hash: u64,
468    pixel_size: [u32; 2],
469    root_scale_bits: u32,
470    blur_radius_bits: u32,
471}
472
473struct CachedShadowSurface {
474    target: Rc<OffscreenTarget>,
475    byte_size: u64,
476}
477
478struct CachedShadowComposite {
479    source: Rc<OffscreenTarget>,
480    scissor: Option<(u32, u32, u32, u32)>,
481    rounded_mask: Option<RoundedCompositeMask>,
482    dest_viewport: Option<(f32, f32, f32, f32)>,
483}
484
485impl CachedShadowComposite {
486    fn batch_item(&self) -> CompositeBatchItem<'_> {
487        CompositeBatchItem {
488            source: &self.source,
489            alpha: 1.0,
490            scissor: self.scissor,
491            rounded_mask: self.rounded_mask,
492            blend_mode: BlendMode::SrcOver,
493            dest_viewport: self.dest_viewport,
494            source_viewport: None,
495            sample_mode: CompositeSampleMode::Nearest,
496        }
497    }
498}
499
500#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
501struct TextImageCacheKey(u64);
502
503struct CachedTextImage {
504    image: ImageBitmap,
505}
506
507#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
508struct TextGlyphRunCacheKey(u64);
509
510#[derive(Clone, Copy)]
511struct CachedTextGlyphQuad {
512    x: i32,
513    y: i32,
514    width: usize,
515    height: usize,
516    color: (f32, f32, f32, f32),
517    uv: ImageUvRect,
518}
519
520struct CachedTextGlyphRun {
521    glyphs: Rc<[SoftwareGlyphAtlasPlacement]>,
522    quads: Option<Rc<[CachedTextGlyphQuad]>>,
523    atlas_generation: u64,
524}
525
526const TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER: f32 = 2.0;
527
528#[cfg(not(target_arch = "wasm32"))]
529struct CachedGpuTextGlyphRun {
530    vertex_buffer: wgpu::Buffer,
531    index_buffer: wgpu::Buffer,
532    index_count: u32,
533    atlas_generation: u64,
534}
535
536#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
537struct TextLineIndexCacheKey(usize);
538
539struct CachedTextLineIndex {
540    text: std::sync::Weak<cranpose_ui::text::RenderString>,
541    len: usize,
542    starts: Rc<[usize]>,
543}
544
545struct TextLineIndexCache {
546    entries: BoundedLruCache<TextLineIndexCacheKey, CachedTextLineIndex>,
547}
548
549impl TextLineIndexCache {
550    fn new(capacity: usize) -> Self {
551        Self {
552            entries: BoundedLruCache::with_capacity_at_least_one(capacity),
553        }
554    }
555
556    fn line_starts(&mut self, text: &Arc<cranpose_ui::text::RenderString>) -> Rc<[usize]> {
557        let key = TextLineIndexCacheKey(Arc::as_ptr(text) as usize);
558        if let Some(cached) = self.entries.get(&key) {
559            if cached.len == text.text.len()
560                && cached
561                    .text
562                    .upgrade()
563                    .is_some_and(|cached_text| Arc::ptr_eq(&cached_text, text))
564            {
565                return cached.starts.clone();
566            }
567        }
568
569        let starts = Rc::<[usize]>::from(line_start_offsets(text.text.as_str()));
570        self.entries.put(
571            key,
572            CachedTextLineIndex {
573                text: Arc::downgrade(text),
574                len: text.text.len(),
575                starts: starts.clone(),
576            },
577        );
578        starts
579    }
580}
581
582#[derive(Clone, Copy, Debug, PartialEq)]
583struct ShapeShadowSurfacePlan {
584    source_device_bounds: DevicePixelBounds,
585    processing_scissor: Option<(u32, u32, u32, u32)>,
586    pixel_radius: f32,
587}
588
589#[derive(Default)]
590struct RendererWarningState {
591    unsupported_effect_reported: Cell<bool>,
592}
593
594impl RendererWarningState {
595    fn warn_unsupported_effect_once(&self) {
596        if !self.unsupported_effect_reported.replace(true) {
597            log::warn!(
598                "WGPU renderer received an unsupported RenderEffect variant; falling back to passthrough compositing"
599            );
600        }
601    }
602}
603
604fn is_blend_mode_supported(mode: BlendMode) -> bool {
605    matches!(mode, BlendMode::SrcOver | BlendMode::DstOut)
606}
607
608fn blend_state_for_mode(mode: BlendMode) -> wgpu::BlendState {
609    match mode {
610        BlendMode::DstOut => wgpu::BlendState {
611            color: wgpu::BlendComponent {
612                src_factor: wgpu::BlendFactor::Zero,
613                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
614                operation: wgpu::BlendOperation::Add,
615            },
616            alpha: wgpu::BlendComponent {
617                src_factor: wgpu::BlendFactor::Zero,
618                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
619                operation: wgpu::BlendOperation::Add,
620            },
621        },
622        _ => wgpu::BlendState::ALPHA_BLENDING,
623    }
624}
625
626fn supported_blend_mode(mode: BlendMode) -> BlendMode {
627    if is_blend_mode_supported(mode) {
628        return mode;
629    }
630
631    BlendMode::SrcOver
632}
633
634fn direct_shader_composite_viewport(
635    alpha: f32,
636    blend_mode: BlendMode,
637    dest_viewport: Option<(f32, f32, f32, f32)>,
638    sample_mode: CompositeSampleMode,
639    source_size: (u32, u32),
640) -> Option<(f32, f32, f32, f32)> {
641    if alpha != 1.0 || supported_blend_mode(blend_mode) != BlendMode::SrcOver {
642        return None;
643    }
644    let viewport = dest_viewport?;
645    if viewport.2 <= 0.0 || viewport.3 <= 0.0 {
646        return None;
647    }
648    match sample_mode {
649        CompositeSampleMode::Linear | CompositeSampleMode::Nearest => Some(viewport),
650        CompositeSampleMode::Box4
651            if shader_composite_preserves_source_pixel_grid(viewport, source_size) =>
652        {
653            Some(viewport)
654        }
655        CompositeSampleMode::Box4 => None,
656    }
657}
658
659fn shader_composite_preserves_source_pixel_grid(
660    viewport: (f32, f32, f32, f32),
661    source_size: (u32, u32),
662) -> bool {
663    const EPSILON: f32 = 0.01;
664    let (x, y, width, height) = viewport;
665    let (source_width, source_height) = source_size;
666    (x - x.round()).abs() <= EPSILON
667        && (y - y.round()).abs() <= EPSILON
668        && (width - source_width as f32).abs() <= EPSILON
669        && (height - source_height as f32).abs() <= EPSILON
670}
671
672type DirectShaderTailComposite<'a> = (&'a RenderEffect, &'a RuntimeShader, (f32, f32, f32, f32));
673
674fn direct_shader_tail_composite(
675    effect: &RenderEffect,
676    alpha: f32,
677    blend_mode: BlendMode,
678    dest_viewport: Option<(f32, f32, f32, f32)>,
679    sample_mode: CompositeSampleMode,
680    source_size: (u32, u32),
681) -> Option<DirectShaderTailComposite<'_>> {
682    let viewport = direct_shader_composite_viewport(
683        alpha,
684        blend_mode,
685        dest_viewport,
686        sample_mode,
687        source_size,
688    )?;
689    let RenderEffect::Chain { first, second } = effect else {
690        return None;
691    };
692    let RenderEffect::Shader { shader } = second.as_ref() else {
693        return None;
694    };
695    Some((first.as_ref(), shader, viewport))
696}
697
698fn hash_f32_for_cache<H: Hasher>(value: f32, state: &mut H) {
699    value.to_bits().hash(state);
700}
701
702fn hash_text_raster_geometry_for_cache<H: Hasher>(
703    rect: Rect,
704    static_text_motion: bool,
705    state: &mut H,
706) {
707    hash_f32_for_cache(rect.width, state);
708    hash_f32_for_cache(rect.height, state);
709    static_text_motion.hash(state);
710    if !static_text_motion {
711        hash_f32_for_cache(rect.x.fract(), state);
712        hash_f32_for_cache(rect.y.fract(), state);
713    }
714}
715
716fn text_raster_geometry_for_draw(
717    text_draw: &TextDraw,
718    root_scale: f32,
719) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
720    if text_draw.text.is_empty()
721        || text_draw.rect.width <= 0.0
722        || text_draw.rect.height <= 0.0
723        || !root_scale.is_finite()
724        || root_scale <= 0.0
725    {
726        return None;
727    }
728
729    let text_scale = text_draw.scale * root_scale;
730    if !text_scale.is_finite() || text_scale <= 0.0 {
731        return None;
732    }
733
734    let static_text_motion = text_draw
735        .text_style
736        .paragraph_style
737        .text_motion
738        .unwrap_or(cranpose_ui::text::TextMotion::Static)
739        == cranpose_ui::text::TextMotion::Static;
740    let snap_delta = text_draw
741        .snap_anchor
742        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
743        .unwrap_or_default();
744    let logical_rect = text_draw.rect.translate(snap_delta.x, snap_delta.y);
745    // Clips are resolved in scene space from their own layer ancestry. A draw
746    // item's raster snap must never move a fixed ancestor clip.
747    let clip = text_draw.clip;
748    let mut raster_rect = Rect {
749        x: logical_rect.x * root_scale,
750        y: logical_rect.y * root_scale,
751        width: logical_rect.width * root_scale,
752        height: logical_rect.height * root_scale,
753    };
754    if text_draw.snap_anchor.is_some() {
755        raster_rect.x = canonicalize_device_coordinate(raster_rect.x);
756        raster_rect.y = canonicalize_device_coordinate(raster_rect.y);
757    }
758    if static_text_motion {
759        raster_rect.x = raster_rect.x.round();
760        raster_rect.y = raster_rect.y.round();
761    }
762    raster_rect.width = raster_rect.width.ceil().max(1.0);
763    raster_rect.height = raster_rect.height.ceil().max(1.0);
764    Some((
765        logical_rect,
766        raster_rect,
767        clip,
768        text_scale,
769        static_text_motion,
770    ))
771}
772
773fn text_draw_is_visible_in_viewport(
774    logical_rect: Rect,
775    clip: Option<Rect>,
776    viewport: ViewportUniformParams,
777    root_scale: f32,
778) -> bool {
779    draw_rect_is_visible_in_viewport(logical_rect, clip, viewport, root_scale)
780}
781
782fn text_draw_should_prewarm_in_viewport(
783    logical_rect: Rect,
784    clip: Option<Rect>,
785    viewport: ViewportUniformParams,
786    root_scale: f32,
787) -> bool {
788    if !root_scale.is_finite() || root_scale <= 0.0 {
789        return false;
790    }
791    let viewport_rect = Rect {
792        x: viewport.offset[0] / root_scale,
793        y: viewport.offset[1] / root_scale,
794        width: viewport.width as f32 / root_scale,
795        height: viewport.height as f32 / root_scale,
796    };
797    let margin_x = viewport_rect.width * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
798    let margin_y = viewport_rect.height * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
799    let prewarm_viewport = expand_rect(viewport_rect, margin_x, margin_y);
800    let prewarm_rect = match clip {
801        Some(clip) => expand_rect(clip, margin_x, margin_y).intersect(prewarm_viewport),
802        None => Some(prewarm_viewport),
803    };
804    prewarm_rect.is_some_and(|rect| logical_rect.intersect(rect).is_some())
805}
806
807fn expand_rect(rect: Rect, margin_x: f32, margin_y: f32) -> Rect {
808    Rect {
809        x: rect.x - margin_x,
810        y: rect.y - margin_y,
811        width: rect.width + margin_x * 2.0,
812        height: rect.height + margin_y * 2.0,
813    }
814}
815
816fn draw_rect_is_visible_in_viewport(
817    rect: Rect,
818    clip: Option<Rect>,
819    viewport: ViewportUniformParams,
820    root_scale: f32,
821) -> bool {
822    if !root_scale.is_finite() || root_scale <= 0.0 {
823        return false;
824    }
825    let viewport_rect = Rect {
826        x: viewport.offset[0] / root_scale,
827        y: viewport.offset[1] / root_scale,
828        width: viewport.width as f32 / root_scale,
829        height: viewport.height as f32 / root_scale,
830    };
831    let visible_rect = match clip {
832        Some(clip) => clip.intersect(viewport_rect),
833        None => Some(viewport_rect),
834    };
835    visible_rect.is_some_and(|visible| rect.intersect(visible).is_some())
836}
837
838fn shape_draw_is_visible_in_viewport(
839    shape: &DrawShape,
840    viewport: ViewportUniformParams,
841    root_scale: f32,
842) -> bool {
843    let snap_delta = shape
844        .snap_anchor
845        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
846        .unwrap_or_default();
847    let rect = quad_bounds(translate_quad(shape.quad, snap_delta));
848    let clip = shape.clip;
849    draw_rect_is_visible_in_viewport(rect, clip, viewport, root_scale)
850}
851
852fn cached_text_glyph_quad(
853    glyph: &SoftwareGlyphAtlasPlacement,
854    entry: GlyphAtlasEntry,
855    atlas_size: u32,
856) -> CachedTextGlyphQuad {
857    CachedTextGlyphQuad {
858        x: glyph.x,
859        y: glyph.y,
860        width: glyph.width,
861        height: glyph.height,
862        color: (
863            glyph.color.0.clamp(0.0, 1.0),
864            glyph.color.1.clamp(0.0, 1.0),
865            glyph.color.2.clamp(0.0, 1.0),
866            glyph.color.3.clamp(0.0, 1.0),
867        ),
868        uv: glyph_atlas_uv_rect(entry, atlas_size),
869    }
870}
871
872fn append_cached_text_glyph_quad(
873    source_raster_rect: Rect,
874    quad: &CachedTextGlyphQuad,
875    image_vertices: &mut Vec<Vertex>,
876    image_indices: &mut Vec<u32>,
877) -> bool {
878    if quad.width == 0 || quad.height == 0 || quad.color.3 <= 0.0 {
879        return false;
880    }
881
882    let base_vertex = image_vertices.len() as u32;
883    image_indices.extend_from_slice(&[
884        base_vertex,
885        base_vertex + 1,
886        base_vertex + 2,
887        base_vertex + 2,
888        base_vertex + 1,
889        base_vertex + 3,
890    ]);
891
892    let x0 = source_raster_rect.x + quad.x as f32;
893    let y0 = source_raster_rect.y + quad.y as f32;
894    let x1 = x0 + quad.width as f32;
895    let y1 = y0 + quad.height as f32;
896    let color = [quad.color.0, quad.color.1, quad.color.2, quad.color.3];
897
898    image_vertices.extend_from_slice(&[
899        Vertex {
900            position: [x0, y0],
901            color,
902            uv: [quad.uv.min[0], quad.uv.min[1]],
903            uv_bounds: quad.uv.sample_bounds,
904        },
905        Vertex {
906            position: [x1, y0],
907            color,
908            uv: [quad.uv.max[0], quad.uv.min[1]],
909            uv_bounds: quad.uv.sample_bounds,
910        },
911        Vertex {
912            position: [x0, y1],
913            color,
914            uv: [quad.uv.min[0], quad.uv.max[1]],
915            uv_bounds: quad.uv.sample_bounds,
916        },
917        Vertex {
918            position: [x1, y1],
919            color,
920            uv: [quad.uv.max[0], quad.uv.max[1]],
921            uv_bounds: quad.uv.sample_bounds,
922        },
923    ]);
924    true
925}
926
927fn cached_text_glyph_quad_logical_rect(
928    source_raster_rect: Rect,
929    quad: &CachedTextGlyphQuad,
930    root_scale: f32,
931) -> Option<Rect> {
932    if !root_scale.is_finite() || root_scale <= 0.0 {
933        return None;
934    }
935    Some(Rect {
936        x: (source_raster_rect.x + quad.x as f32) / root_scale,
937        y: (source_raster_rect.y + quad.y as f32) / root_scale,
938        width: quad.width as f32 / root_scale,
939        height: quad.height as f32 / root_scale,
940    })
941}
942
943fn cached_text_glyph_quad_is_visible_in_viewport(
944    source_raster_rect: Rect,
945    quad: &CachedTextGlyphQuad,
946    clip: Option<Rect>,
947    viewport: ViewportUniformParams,
948    root_scale: f32,
949) -> bool {
950    cached_text_glyph_quad_logical_rect(source_raster_rect, quad, root_scale)
951        .is_some_and(|rect| draw_rect_is_visible_in_viewport(rect, clip, viewport, root_scale))
952}
953
954#[derive(Clone, Copy, Debug, Eq, PartialEq)]
955enum TextGlyphDrawAction {
956    DrawVisible,
957    PrewarmOffscreen,
958    Skip,
959}
960
961fn text_glyph_draw_action(
962    is_visible: bool,
963    is_prewarm_candidate: bool,
964    allow_offscreen_prewarm: bool,
965) -> TextGlyphDrawAction {
966    if is_visible {
967        TextGlyphDrawAction::DrawVisible
968    } else if allow_offscreen_prewarm && is_prewarm_candidate {
969        TextGlyphDrawAction::PrewarmOffscreen
970    } else {
971        TextGlyphDrawAction::Skip
972    }
973}
974
975#[cfg(not(target_arch = "wasm32"))]
976fn should_use_retained_text_glyph_run(quads_len: usize, clip: Option<Rect>) -> bool {
977    clip.is_none() && quads_len >= MIN_RETAINED_TEXT_GLYPH_QUADS
978}
979
980#[cfg(not(target_arch = "wasm32"))]
981fn offscreen_text_glyph_prewarm_work_is_bounded(
982    cached_glyphs: Option<usize>,
983    text_len: usize,
984) -> bool {
985    match cached_glyphs {
986        Some(glyphs) => glyphs <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS,
987        None => text_len <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
988    }
989}
990
991#[cfg(not(target_arch = "wasm32"))]
992fn offscreen_text_glyph_prewarm_budget_exhausted(
993    start: Instant,
994    admitted_candidates: usize,
995) -> bool {
996    admitted_candidates >= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES
997        || instant_ms(start, Instant::now()) >= OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS
998}
999
1000fn text_draws_for_ordered_range<'a>(
1001    ordered_items: &'a [(usize, SegmentDrawItem)],
1002    texts: &'a [TextDraw],
1003    start: usize,
1004    end: usize,
1005) -> Result<impl Iterator<Item = &'a TextDraw>, String> {
1006    let range_items = ordered_items
1007        .get(start..end)
1008        .ok_or_else(|| format!("text batch range {start}..{end} is outside ordered draw items"))?;
1009    for (_, item) in range_items {
1010        match item {
1011            SegmentDrawItem::Text(text_index) if *text_index < texts.len() => {}
1012            SegmentDrawItem::Text(text_index) => {
1013                return Err(format!(
1014                    "text batch references missing text draw index: {text_index}"
1015                ));
1016            }
1017            _ => return Err(format!("text batch contains non-text draw item: {item:?}")),
1018        }
1019    }
1020
1021    Ok(range_items.iter().filter_map(move |(_, item)| match item {
1022        SegmentDrawItem::Text(text_index) => texts.get(*text_index),
1023        _ => None,
1024    }))
1025}
1026
1027/// Shadow geometry is hashed in device pixels quantized to 1/16 px so rigid
1028/// translations reuse the cached blurred raster. The cached surface is
1029/// composited one-to-one with texel-exact sampling; translation may not change
1030/// either the blur or its sampling phase.
1031const SHADOW_CACHE_DEVICE_QUANT: f32 = 16.0;
1032
1033fn hash_shadow_device_offset<H: Hasher>(value: f32, origin: f32, root_scale: f32, state: &mut H) {
1034    let quantized = ((value - origin) * root_scale * SHADOW_CACHE_DEVICE_QUANT).round();
1035    (quantized as i64).hash(state);
1036}
1037
1038fn hash_shadow_device_rect<H: Hasher>(
1039    rect: Rect,
1040    origin_x: f32,
1041    origin_y: f32,
1042    root_scale: f32,
1043    state: &mut H,
1044) {
1045    hash_shadow_device_offset(rect.x, origin_x, root_scale, state);
1046    hash_shadow_device_offset(rect.y, origin_y, root_scale, state);
1047    hash_shadow_device_offset(rect.width, 0.0, root_scale, state);
1048    hash_shadow_device_offset(rect.height, 0.0, root_scale, state);
1049}
1050
1051fn hash_shape_shadow_item<H: Hasher>(
1052    shape: &DrawShape,
1053    blend_mode: BlendMode,
1054    origin_x: f32,
1055    origin_y: f32,
1056    root_scale: f32,
1057    state: &mut H,
1058) {
1059    hash_shadow_device_rect(shape.rect, origin_x, origin_y, root_scale, state);
1060    hash_shadow_device_rect(shape.local_rect, origin_x, origin_y, root_scale, state);
1061    for point in shape.quad {
1062        hash_shadow_device_offset(point[0], origin_x, root_scale, state);
1063        hash_shadow_device_offset(point[1], origin_y, root_scale, state);
1064    }
1065    match shape.snap_anchor {
1066        Some(anchor) => {
1067            1u8.hash(state);
1068            hash_shadow_device_offset(anchor.origin.x, origin_x, root_scale, state);
1069            hash_shadow_device_offset(anchor.origin.y, origin_y, root_scale, state);
1070            hash_f32_for_cache(anchor.device_pixel_step, state);
1071        }
1072        None => 0u8.hash(state),
1073    }
1074    shape.brush.render_hash().hash(state);
1075    match shape.shape {
1076        Some(corner_shape) => {
1077            1u8.hash(state);
1078            corner_shape.radii().render_hash().hash(state);
1079        }
1080        None => 0u8.hash(state),
1081    }
1082    match shape.clip {
1083        Some(clip) => {
1084            1u8.hash(state);
1085            hash_shadow_device_rect(clip, origin_x, origin_y, root_scale, state);
1086        }
1087        None => 0u8.hash(state),
1088    }
1089    blend_mode.hash(state);
1090    shape.blend_mode.hash(state);
1091}
1092
1093fn shape_shadow_content_hash(shapes: &[(DrawShape, BlendMode)], root_scale: f32) -> u64 {
1094    let mut hasher = FxHasher::default();
1095    // Anchor the hash to the shapes' own (unfloored) bounds so rigid translation
1096    // cancels out exactly. Anchoring to floored device-pixel bounds would leak
1097    // the device subpixel phase into the hash and defeat the cache at
1098    // fractional display scales.
1099    let origin = shape_shadow_bounds(shapes).unwrap_or(Rect {
1100        x: 0.0,
1101        y: 0.0,
1102        width: 0.0,
1103        height: 0.0,
1104    });
1105
1106    shapes.len().hash(&mut hasher);
1107    for (shape, blend_mode) in shapes {
1108        hash_shape_shadow_item(
1109            shape,
1110            *blend_mode,
1111            origin.x,
1112            origin.y,
1113            root_scale,
1114            &mut hasher,
1115        );
1116    }
1117    hasher.finish()
1118}
1119
1120fn shape_shadow_surface_cache_key(
1121    shapes: &[(DrawShape, BlendMode)],
1122    device_bounds: DevicePixelBounds,
1123    pixel_radius: f32,
1124    root_scale: f32,
1125) -> Option<ShadowSurfaceCacheKey> {
1126    (root_scale.is_finite() && root_scale > 0.0).then(|| ShadowSurfaceCacheKey {
1127        content_hash: shape_shadow_content_hash(shapes, root_scale),
1128        pixel_size: [device_bounds.width, device_bounds.height],
1129        root_scale_bits: root_scale.to_bits(),
1130        blur_radius_bits: pixel_radius.to_bits(),
1131    })
1132}
1133
1134fn shape_shadow_bounds(shapes: &[(DrawShape, BlendMode)]) -> Option<Rect> {
1135    shapes
1136        .iter()
1137        .map(|(shape, _)| shape.rect)
1138        .reduce(|a, b| Rect {
1139            x: a.x.min(b.x),
1140            y: a.y.min(b.y),
1141            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1142            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1143        })
1144}
1145
1146fn shared_shape_shadow_snap_anchor(shapes: &[(DrawShape, BlendMode)]) -> Option<SnapAnchor> {
1147    let anchor = shapes.first()?.0.snap_anchor?;
1148    shapes
1149        .iter()
1150        .all(|(shape, _)| shape.snap_anchor == Some(anchor))
1151        .then_some(anchor)
1152}
1153
1154fn shadow_draw_bounds(shadow: &ShadowDraw) -> Option<Rect> {
1155    shadow
1156        .shapes
1157        .iter()
1158        .map(|(shape, _)| shape.rect)
1159        .chain(shadow.texts.iter().map(|text| text.rect))
1160        .reduce(|a, b| Rect {
1161            x: a.x.min(b.x),
1162            y: a.y.min(b.y),
1163            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1164            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1165        })
1166}
1167
1168fn shadow_draw_may_render(
1169    shadow: &ShadowDraw,
1170    width: u32,
1171    height: u32,
1172    root_scale: f32,
1173    max_texture_dim: u32,
1174) -> bool {
1175    if shadow.texts.is_empty() && !shadow.shapes.is_empty() && shadow.blur_radius > 0.0 {
1176        return shape_shadow_surface_plan(
1177            &shadow.shapes,
1178            shadow.clip,
1179            shadow.blur_radius,
1180            width,
1181            height,
1182            root_scale,
1183            max_texture_dim,
1184        )
1185        .is_some();
1186    }
1187
1188    let Some(bounds) = shadow_draw_bounds(shadow) else {
1189        return false;
1190    };
1191    let blur_margin = blur_extent_margin(shadow.blur_radius);
1192    let mut visible_bounds = Rect {
1193        x: bounds.x - blur_margin,
1194        y: bounds.y - blur_margin,
1195        width: bounds.width + blur_margin * 2.0,
1196        height: bounds.height + blur_margin * 2.0,
1197    };
1198    if let Some(clip) = shadow.clip {
1199        let clip_expanded = Rect {
1200            x: clip.x - blur_margin,
1201            y: clip.y - blur_margin,
1202            width: clip.width + blur_margin * 2.0,
1203            height: clip.height + blur_margin * 2.0,
1204        };
1205        let Some(intersection) = visible_bounds.intersect(clip_expanded) else {
1206            return false;
1207        };
1208        visible_bounds = intersection;
1209    }
1210
1211    scissor_rect_for_rect(visible_bounds, root_scale, width, height).is_some()
1212}
1213
1214fn shape_shadow_surface_plan(
1215    shapes: &[(DrawShape, BlendMode)],
1216    clip: Option<Rect>,
1217    blur_radius: f32,
1218    width: u32,
1219    height: u32,
1220    root_scale: f32,
1221    max_texture_dim: u32,
1222) -> Option<ShapeShadowSurfacePlan> {
1223    let shape_bounds = shape_shadow_bounds(shapes)?;
1224    let blur_margin = blur_extent_margin(blur_radius);
1225    let source_blur_bounds = Rect {
1226        x: shape_bounds.x - blur_margin,
1227        y: shape_bounds.y - blur_margin,
1228        width: shape_bounds.width + blur_margin * 2.0,
1229        height: shape_bounds.height + blur_margin * 2.0,
1230    };
1231
1232    let mut visible_blur_bounds = source_blur_bounds;
1233    if let Some(clip) = clip {
1234        let clip_expanded = Rect {
1235            x: clip.x - blur_margin,
1236            y: clip.y - blur_margin,
1237            width: clip.width + blur_margin * 2.0,
1238            height: clip.height + blur_margin * 2.0,
1239        };
1240        visible_blur_bounds = visible_blur_bounds.intersect(clip_expanded)?;
1241    }
1242
1243    let processing_scissor = scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
1244    processing_scissor?;
1245    let visible_device_bounds =
1246        device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)?;
1247    let source_device_bounds = translation_stable_anchored_device_pixel_bounds(
1248        source_blur_bounds,
1249        shared_shape_shadow_snap_anchor(shapes),
1250        root_scale,
1251        max_texture_dim,
1252    )
1253    .unwrap_or(visible_device_bounds);
1254
1255    Some(ShapeShadowSurfacePlan {
1256        source_device_bounds,
1257        processing_scissor,
1258        pixel_radius: blur_radius * root_scale,
1259    })
1260}
1261
1262fn is_render_effect_supported(effect: &RenderEffect) -> bool {
1263    match effect {
1264        RenderEffect::Blur { .. } => true,
1265        RenderEffect::Offset { .. } => true,
1266        RenderEffect::Shader { .. } => true,
1267        RenderEffect::Chain { first, second } => {
1268            is_render_effect_supported(first) && is_render_effect_supported(second)
1269        }
1270    }
1271}
1272
1273fn resolve_gradient_point(origin: f32, extent: f32, value: f32) -> f32 {
1274    if value.is_finite() {
1275        origin + value
1276    } else if value.is_sign_positive() {
1277        origin + extent
1278    } else {
1279        origin
1280    }
1281}
1282
1283fn gradient_tile_mode_value(tile_mode: TileMode) -> u32 {
1284    match tile_mode {
1285        TileMode::Clamp => 0,
1286        TileMode::Repeated => 1,
1287        TileMode::Mirror => 2,
1288        TileMode::Decal => 3,
1289    }
1290}
1291
1292#[cfg(not(target_arch = "wasm32"))]
1293fn shape_shader_source(batch_limits: ShapeBatchLimits) -> Cow<'static, str> {
1294    // These literals must stay in sync with `shape.wgsl`; a mismatch makes
1295    // the substitution silently no-op and leaves the shader sized for the
1296    // downlevel floor.
1297    if batch_limits.storage {
1298        return Cow::Owned(
1299            shaders::SHADER
1300                .replace(
1301                    "var<uniform> shape_data: array<ShapeData, 102>;",
1302                    "var<storage, read> shape_data: array<ShapeData>;",
1303                )
1304                .replace(
1305                    "var<uniform> gradient_stops: array<GradientStop, 256>;",
1306                    // Also inject the retained-paint array here: one mutable
1307                    // color per shape, read when `similarity.paint_select`
1308                    // is set, so recolor patches upload 16-byte colors
1309                    // instead of whole ShapeData records. The base text
1310                    // never declares it — uniform-mode devices cannot bind
1311                    // storage and never host retained slots.
1312                    "var<storage, read> gradient_stops: array<GradientStop>;\n\n\
1313                     @group(1) @binding(3)\n\
1314                     var<storage, read> paint: array<vec4<f32>>;",
1315                )
1316                .replace(
1317                    "output.color = shape.color;",
1318                    "output.color = \
1319                     select(shape.color, paint[shape_idx], similarity.paint_select > 0.5);",
1320                ),
1321        );
1322    }
1323    Cow::Owned(
1324        shaders::SHADER
1325            .replace(
1326                "array<ShapeData, 102>",
1327                &format!("array<ShapeData, {}>", batch_limits.max_shapes_per_batch),
1328            )
1329            .replace(
1330                "array<GradientStop, 256>",
1331                &format!("array<GradientStop, {}>", batch_limits.max_gradient_stops),
1332            ),
1333    )
1334}
1335
1336#[cfg(target_arch = "wasm32")]
1337fn shape_shader_source(_batch_limits: ShapeBatchLimits) -> Cow<'static, str> {
1338    Cow::Borrowed(shaders::SHADER)
1339}
1340
1341fn create_shape_pipeline(
1342    device: &wgpu::Device,
1343    surface_format: wgpu::TextureFormat,
1344    uniform_layout: &wgpu::BindGroupLayout,
1345    shape_layout: &wgpu::BindGroupLayout,
1346    blend_mode: BlendMode,
1347    batch_limits: ShapeBatchLimits,
1348    fragment_entry: &'static str,
1349) -> wgpu::RenderPipeline {
1350    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1351        label: Some("Shape Shader"),
1352        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1353    });
1354
1355    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1356        label: Some("Render Pipeline Layout"),
1357        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1358        immediate_size: 0,
1359    });
1360
1361    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1362        label: Some("Render Pipeline"),
1363        layout: Some(&pipeline_layout),
1364        vertex: wgpu::VertexState {
1365            module: &shader,
1366            entry_point: Some("vs_main"),
1367            compilation_options: wgpu::PipelineCompilationOptions::default(),
1368            // No vertex buffer: `vs_main` pulls quad corners from ShapeData
1369            // by `vertex_index`.
1370            buffers: &[],
1371        },
1372        fragment: Some(wgpu::FragmentState {
1373            module: &shader,
1374            entry_point: Some(fragment_entry),
1375            compilation_options: wgpu::PipelineCompilationOptions::default(),
1376            targets: &[Some(wgpu::ColorTargetState {
1377                format: surface_format,
1378                blend: Some(blend_state_for_mode(blend_mode)),
1379                write_mask: wgpu::ColorWrites::ALL,
1380            })],
1381        }),
1382        primitive: wgpu::PrimitiveState {
1383            topology: wgpu::PrimitiveTopology::TriangleList,
1384            strip_index_format: None,
1385            front_face: wgpu::FrontFace::Ccw,
1386            cull_mode: None,
1387            unclipped_depth: false,
1388            polygon_mode: wgpu::PolygonMode::Fill,
1389            conservative: false,
1390        },
1391        depth_stencil: None,
1392        multisample: wgpu::MultisampleState::default(),
1393        multiview_mask: None,
1394        cache: None,
1395    })
1396}
1397
1398/// Storage-mode pipeline for retained slots that captured a conservative arc
1399/// mesh: `vs_mesh` consumes `{position, uv, shape_idx}` vertices instead of
1400/// expanding six corners per shape. Fragment stage, bind group layouts
1401/// (including the dynamic-offset similarity binding and the retained paint
1402/// binding) and the SrcOver blend are exactly the ones the quad-expansion retained
1403/// path uses — only the vertex fetch differs.
1404#[cfg(not(target_arch = "wasm32"))]
1405fn create_mesh_shape_pipeline(
1406    device: &wgpu::Device,
1407    surface_format: wgpu::TextureFormat,
1408    uniform_layout: &wgpu::BindGroupLayout,
1409    shape_layout: &wgpu::BindGroupLayout,
1410    batch_limits: ShapeBatchLimits,
1411) -> wgpu::RenderPipeline {
1412    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1413        label: Some("Shape Mesh Shader"),
1414        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1415    });
1416
1417    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1418        label: Some("Mesh Render Pipeline Layout"),
1419        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1420        immediate_size: 0,
1421    });
1422
1423    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1424        label: Some("Retained Mesh Pipeline"),
1425        layout: Some(&pipeline_layout),
1426        vertex: wgpu::VertexState {
1427            module: &shader,
1428            entry_point: Some("vs_mesh"),
1429            compilation_options: wgpu::PipelineCompilationOptions::default(),
1430            buffers: &[MeshVertex::desc()],
1431        },
1432        fragment: Some(wgpu::FragmentState {
1433            module: &shader,
1434            entry_point: Some("fs_main"),
1435            compilation_options: wgpu::PipelineCompilationOptions::default(),
1436            targets: &[Some(wgpu::ColorTargetState {
1437                format: surface_format,
1438                blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1439                write_mask: wgpu::ColorWrites::ALL,
1440            })],
1441        }),
1442        primitive: wgpu::PrimitiveState {
1443            topology: wgpu::PrimitiveTopology::TriangleList,
1444            strip_index_format: None,
1445            front_face: wgpu::FrontFace::Ccw,
1446            cull_mode: None,
1447            unclipped_depth: false,
1448            polygon_mode: wgpu::PolygonMode::Fill,
1449            conservative: false,
1450        },
1451        depth_stencil: None,
1452        multisample: wgpu::MultisampleState::default(),
1453        multiview_mask: None,
1454        cache: None,
1455    })
1456}
1457
1458/// Storage-mode pipeline for ordinary shape batches drawn as instanced
1459/// indexed quads (`vs_shape_instanced`): four vertex executions per shape
1460/// through the static `[0, 1, 2, 2, 1, 3]` index buffer instead of six
1461/// unindexed corner expansions. Everything but the vertex entry point is
1462/// exactly `create_shape_pipeline` — same fragment stage, same layouts,
1463/// same blend per mode — so a draw-time fallback to `vs_main` (the
1464/// `CRANPOSE_INSTANCED_QUADS=0` kill switch) changes nothing else.
1465#[cfg(not(target_arch = "wasm32"))]
1466fn create_instanced_shape_pipeline(
1467    device: &wgpu::Device,
1468    surface_format: wgpu::TextureFormat,
1469    uniform_layout: &wgpu::BindGroupLayout,
1470    shape_layout: &wgpu::BindGroupLayout,
1471    blend_mode: BlendMode,
1472    batch_limits: ShapeBatchLimits,
1473    fragment_entry: &'static str,
1474) -> wgpu::RenderPipeline {
1475    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1476        label: Some("Shape Instanced Shader"),
1477        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1478    });
1479
1480    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1481        label: Some("Instanced Render Pipeline Layout"),
1482        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1483        immediate_size: 0,
1484    });
1485
1486    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1487        label: Some("Instanced Render Pipeline"),
1488        layout: Some(&pipeline_layout),
1489        vertex: wgpu::VertexState {
1490            module: &shader,
1491            entry_point: Some("vs_shape_instanced"),
1492            compilation_options: wgpu::PipelineCompilationOptions::default(),
1493            // No vertex buffer: like `vs_main`, the corners come from
1494            // ShapeData; only the shape index source differs
1495            // (`instance_index` instead of `vertex_index / 6`).
1496            buffers: &[],
1497        },
1498        fragment: Some(wgpu::FragmentState {
1499            module: &shader,
1500            entry_point: Some(fragment_entry),
1501            compilation_options: wgpu::PipelineCompilationOptions::default(),
1502            targets: &[Some(wgpu::ColorTargetState {
1503                format: surface_format,
1504                blend: Some(blend_state_for_mode(blend_mode)),
1505                write_mask: wgpu::ColorWrites::ALL,
1506            })],
1507        }),
1508        primitive: wgpu::PrimitiveState {
1509            topology: wgpu::PrimitiveTopology::TriangleList,
1510            strip_index_format: None,
1511            front_face: wgpu::FrontFace::Ccw,
1512            cull_mode: None,
1513            unclipped_depth: false,
1514            polygon_mode: wgpu::PolygonMode::Fill,
1515            conservative: false,
1516        },
1517        depth_stencil: None,
1518        multisample: wgpu::MultisampleState::default(),
1519        multiview_mask: None,
1520        cache: None,
1521    })
1522}
1523
1524fn create_image_pipeline(
1525    device: &wgpu::Device,
1526    surface_format: wgpu::TextureFormat,
1527    uniform_layout: &wgpu::BindGroupLayout,
1528    image_layout: &wgpu::BindGroupLayout,
1529    blend_mode: BlendMode,
1530) -> wgpu::RenderPipeline {
1531    let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1532        label: Some("Image Shader"),
1533        source: wgpu::ShaderSource::Wgsl(shaders::IMAGE_SHADER.into()),
1534    });
1535
1536    let image_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1537        label: Some("Image Pipeline Layout"),
1538        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1539        immediate_size: 0,
1540    });
1541
1542    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1543        label: Some("Image Pipeline"),
1544        layout: Some(&image_pipeline_layout),
1545        vertex: wgpu::VertexState {
1546            module: &image_shader,
1547            entry_point: Some("image_vs_main"),
1548            compilation_options: wgpu::PipelineCompilationOptions::default(),
1549            buffers: &[Vertex::desc()],
1550        },
1551        fragment: Some(wgpu::FragmentState {
1552            module: &image_shader,
1553            entry_point: Some("image_fs_main"),
1554            compilation_options: wgpu::PipelineCompilationOptions::default(),
1555            targets: &[Some(wgpu::ColorTargetState {
1556                format: surface_format,
1557                blend: Some(blend_state_for_mode(blend_mode)),
1558                write_mask: wgpu::ColorWrites::ALL,
1559            })],
1560        }),
1561        primitive: wgpu::PrimitiveState {
1562            topology: wgpu::PrimitiveTopology::TriangleList,
1563            strip_index_format: None,
1564            front_face: wgpu::FrontFace::Ccw,
1565            cull_mode: None,
1566            unclipped_depth: false,
1567            polygon_mode: wgpu::PolygonMode::Fill,
1568            conservative: false,
1569        },
1570        depth_stencil: None,
1571        multisample: wgpu::MultisampleState::default(),
1572        multiview_mask: None,
1573        cache: None,
1574    })
1575}
1576
1577fn create_glyph_atlas_pipeline(
1578    device: &wgpu::Device,
1579    surface_format: wgpu::TextureFormat,
1580    uniform_layout: &wgpu::BindGroupLayout,
1581    image_layout: &wgpu::BindGroupLayout,
1582) -> wgpu::RenderPipeline {
1583    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1584        label: Some("Glyph Atlas Shader"),
1585        source: wgpu::ShaderSource::Wgsl(shaders::GLYPH_ATLAS_SHADER.into()),
1586    });
1587
1588    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1589        label: Some("Glyph Atlas Pipeline Layout"),
1590        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1591        immediate_size: 0,
1592    });
1593
1594    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1595        label: Some("Glyph Atlas Pipeline"),
1596        layout: Some(&pipeline_layout),
1597        vertex: wgpu::VertexState {
1598            module: &shader,
1599            entry_point: Some("glyph_atlas_vs_main"),
1600            compilation_options: wgpu::PipelineCompilationOptions::default(),
1601            buffers: &[Vertex::desc()],
1602        },
1603        fragment: Some(wgpu::FragmentState {
1604            module: &shader,
1605            entry_point: Some("glyph_atlas_fs_main"),
1606            compilation_options: wgpu::PipelineCompilationOptions::default(),
1607            targets: &[Some(wgpu::ColorTargetState {
1608                format: surface_format,
1609                blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1610                write_mask: wgpu::ColorWrites::ALL,
1611            })],
1612        }),
1613        primitive: wgpu::PrimitiveState {
1614            topology: wgpu::PrimitiveTopology::TriangleList,
1615            strip_index_format: None,
1616            front_face: wgpu::FrontFace::Ccw,
1617            cull_mode: None,
1618            unclipped_depth: false,
1619            polygon_mode: wgpu::PolygonMode::Fill,
1620            conservative: false,
1621        },
1622        depth_stencil: None,
1623        multisample: wgpu::MultisampleState::default(),
1624        multiview_mask: None,
1625        cache: None,
1626    })
1627}
1628
1629#[repr(C)]
1630#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1631struct Vertex {
1632    position: [f32; 2],
1633    color: [f32; 4],
1634    uv: [f32; 2],
1635    uv_bounds: [f32; 4],
1636}
1637
1638impl Vertex {
1639    const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
1640        0 => Float32x2,
1641        1 => Float32x4,
1642        2 => Float32x2,
1643        3 => Float32x4
1644    ];
1645
1646    fn desc() -> wgpu::VertexBufferLayout<'static> {
1647        wgpu::VertexBufferLayout {
1648            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
1649            step_mode: wgpu::VertexStepMode::Vertex,
1650            attributes: &Self::ATTRIBS,
1651        }
1652    }
1653}
1654
1655#[repr(C)]
1656#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1657struct Uniforms {
1658    viewport: [f32; 2],
1659    viewport_offset: [f32; 2],
1660}
1661
1662/// Mirror of `struct ShapeData` in `shape.wgsl`. Field order and sizes must
1663/// match exactly: 10 x 16 bytes = 160 bytes, every member 16-byte aligned as
1664/// the uniform address space requires. The quad corners and vertex color ride
1665/// in here because the shape pipeline has no vertex buffer: the vertex shader
1666/// pulls all six corners of a shape straight from this struct.
1667#[repr(C)]
1668#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1669struct ShapeData {
1670    rect: [f32; 4], // x, y, width, height
1671    /// Rects: top_left, top_right, bottom_left, bottom_right corner radii.
1672    /// Arcs: (sin, cos) of the mid angle and of the half sweep — the shader's
1673    /// per-shape trig, precomputed so `sdf_arc_band` needs none per fragment.
1674    radii: [f32; 4],
1675    gradient_params: [f32; 4], // linear: start.xy,end.xy; radial: center.xy,radius,unused
1676    clip_rect: [f32; 4],       // clip_x, clip_y, clip_width, clip_height (0,0,0,0 = no clip)
1677    /// stroke width, packed flags (see [`pack_shape_flags`]), arc outer radius,
1678    /// arc inner radius. All zero for a plain fill.
1679    stroke_params: [f32; 4],
1680    /// arc center.xy, start angle, sweep angle (radians, 0 = +X, clockwise).
1681    arc_params: [f32; 4],
1682    /// Device-space quad corners 0 (xy) and 1 (zw).
1683    quad01: [f32; 4],
1684    /// Device-space quad corners 2 (xy) and 3 (zw).
1685    quad23: [f32; 4],
1686    /// Vertex color: the solid brush color, or the first gradient stop.
1687    color: [f32; 4],
1688    brush_type: u32,         // 0=solid, 1=linear_gradient, 2=radial_gradient
1689    gradient_start: u32,     // Starting index in gradient buffer
1690    gradient_count: u32,     // Number of gradient stops
1691    gradient_tile_mode: u32, // 0=Clamp, 1=Repeated, 2=Mirror, 3=Decal
1692}
1693
1694/// Shape kinds understood by `shape.wgsl`.
1695const SHAPE_KIND_FILL: u32 = 0;
1696const SHAPE_KIND_STROKE: u32 = 1;
1697const SHAPE_KIND_ARC: u32 = 2;
1698
1699fn stroke_cap_code(cap: StrokeCap) -> u32 {
1700    match cap {
1701        StrokeCap::Butt => 0,
1702        StrokeCap::Round => 1,
1703        StrokeCap::Square => 2,
1704    }
1705}
1706
1707fn stroke_join_code(join: StrokeJoin) -> u32 {
1708    match join {
1709        StrokeJoin::Miter => 0,
1710        StrokeJoin::Round => 1,
1711        StrokeJoin::Bevel => 2,
1712    }
1713}
1714
1715/// Packs kind/cap/join into the single float `ShapeData::stroke_params[1]`.
1716///
1717/// Three 2-bit fields fit in one f32 exactly (integers below 2^24 are exact),
1718/// which keeps `ShapeData` a slot smaller than it would be if each field got
1719/// its own float — batch capacity is set by this size on uniform backends.
1720fn pack_shape_flags(kind: u32, cap: StrokeCap, join: StrokeJoin) -> f32 {
1721    ((kind & 3) | (stroke_cap_code(cap) << 2) | (stroke_join_code(join) << 4)) as f32
1722}
1723
1724/// Whether a batch conversion fans out is decided by measurement — see
1725/// [`crate::cost_tuner::CostTuner`]. The floor of 256 matters: a device
1726/// whose uniform binding caps batches at ~409 shapes never crossed the old
1727/// fixed threshold of 512, so conversion ran serial on exactly the class of
1728/// hardware (watch-grade in-order cores) where fanning out pays most. The
1729/// 400 µs cheap floor keeps a big phone core, which clears such a batch in
1730/// well under that, from ever paying for a spawn wave.
1731#[cfg(not(target_arch = "wasm32"))]
1732static SHAPE_CONVERT_TUNER: crate::cost_tuner::CostTuner =
1733    crate::cost_tuner::CostTuner::new("shape-convert", 256, 400_000);
1734
1735#[cfg(not(target_arch = "wasm32"))]
1736pub(crate) fn shape_convert_worker_count() -> usize {
1737    static WORKERS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
1738    *WORKERS.get_or_init(|| {
1739        let cpus = std::thread::available_parallelism()
1740            .map(|count| count.get())
1741            .unwrap_or(1);
1742        let workers = cpus.clamp(1, 4);
1743        // One line per process: on devices whose scheduler confines the
1744        // process (affinity masks, cpusets), this is the number that
1745        // explains why fan-out stages stayed serial.
1746        log::info!("[shape-convert] fan-out width {workers} (available parallelism {cpus})");
1747        workers
1748    })
1749}
1750
1751#[cfg(target_arch = "wasm32")]
1752pub(crate) fn shape_convert_worker_count() -> usize {
1753    1
1754}
1755
1756fn shape_gradient_stop_count(shape: &DrawShape) -> usize {
1757    match &shape.brush {
1758        Brush::Solid(_) => 0,
1759        Brush::LinearGradient { colors, .. }
1760        | Brush::RadialGradient { colors, .. }
1761        | Brush::SweepGradient { colors, .. } => colors.len(),
1762    }
1763}
1764
1765/// Converts one [`DrawShape`] into its GPU representation, writing into
1766/// pre-sized slots so a batch can convert in parallel across disjoint
1767/// sub-slices. `gradient_start` is the shape's global offset into the batch
1768/// gradient buffer; `gradient_out` is exactly its span of that buffer.
1769fn convert_shape_into_slots(
1770    shape: &DrawShape,
1771    root_scale: f32,
1772    gradient_start: u32,
1773    shape_out: &mut ShapeData,
1774    gradient_out: &mut [GradientStop],
1775) {
1776    let snap_delta = shape
1777        .snap_anchor
1778        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
1779        .unwrap_or_default();
1780    let local_rect = shape.local_rect.translate(snap_delta.x, snap_delta.y);
1781    let quad = translate_quad(shape.quad, snap_delta);
1782    // Clips are resolved in scene space from their own layer ancestry. A draw
1783    // item's raster snap must never move a fixed ancestor clip.
1784    let clip = shape.clip;
1785    let canonicalize = shape.snap_anchor.is_some();
1786    let device_local_rect = if canonicalize {
1787        canonicalized_scaled_rect(local_rect, root_scale)
1788    } else {
1789        Rect {
1790            x: local_rect.x * root_scale,
1791            y: local_rect.y * root_scale,
1792            width: local_rect.width * root_scale,
1793            height: local_rect.height * root_scale,
1794        }
1795    };
1796    let device_quad = if canonicalize {
1797        canonicalized_scaled_quad(quad, root_scale)
1798    } else {
1799        scaled_quad(quad, root_scale)
1800    };
1801    let canonicalize_brush_coordinate = |value| {
1802        if canonicalize {
1803            canonicalize_device_coordinate(value)
1804        } else {
1805            value
1806        }
1807    };
1808
1809    // Clip rect (scaled to physical pixels)
1810    let clip_rect = if let Some(clip) = clip {
1811        let device_clip = if canonicalize {
1812            canonicalized_scaled_rect(clip, root_scale)
1813        } else {
1814            Rect {
1815                x: clip.x * root_scale,
1816                y: clip.y * root_scale,
1817                width: clip.width * root_scale,
1818                height: clip.height * root_scale,
1819            }
1820        };
1821        [
1822            device_clip.x,
1823            device_clip.y,
1824            device_clip.width,
1825            device_clip.height,
1826        ]
1827    } else {
1828        [0.0, 0.0, 0.0, 0.0]
1829    };
1830
1831    // Gradient parameters
1832    let mut fill_gradient_entries = |colors: &[Color], stops: Option<&[f32]>| {
1833        let count = colors.len();
1834        let explicit_stops = stops.filter(|values| values.len() == count);
1835        for (index, color) in colors.iter().enumerate() {
1836            let position = explicit_stops
1837                .map(|values| values[index])
1838                .unwrap_or_else(|| {
1839                    if count <= 1 {
1840                        0.0
1841                    } else {
1842                        index as f32 / (count - 1) as f32
1843                    }
1844                });
1845            gradient_out[index] = GradientStop {
1846                color: [color.r(), color.g(), color.b(), color.a()],
1847                position: [position, 0.0, 0.0, 0.0],
1848            };
1849        }
1850        count as u32
1851    };
1852    let mut gradient_params = [0.0f32; 4];
1853    let (brush_type, gradient_count, gradient_tile_mode) = match &shape.brush {
1854        Brush::Solid(_) => (0u32, 0u32, gradient_tile_mode_value(TileMode::Clamp)),
1855        Brush::LinearGradient {
1856            colors,
1857            stops,
1858            start,
1859            end,
1860            tile_mode,
1861        } => {
1862            let count = fill_gradient_entries(colors, stops.as_deref());
1863            gradient_params = [
1864                canonicalize_brush_coordinate(resolve_gradient_point(
1865                    device_local_rect.x,
1866                    device_local_rect.width,
1867                    start.x * root_scale,
1868                )),
1869                canonicalize_brush_coordinate(resolve_gradient_point(
1870                    device_local_rect.y,
1871                    device_local_rect.height,
1872                    start.y * root_scale,
1873                )),
1874                canonicalize_brush_coordinate(resolve_gradient_point(
1875                    device_local_rect.x,
1876                    device_local_rect.width,
1877                    end.x * root_scale,
1878                )),
1879                canonicalize_brush_coordinate(resolve_gradient_point(
1880                    device_local_rect.y,
1881                    device_local_rect.height,
1882                    end.y * root_scale,
1883                )),
1884            ];
1885            (1u32, count, gradient_tile_mode_value(*tile_mode))
1886        }
1887        Brush::RadialGradient {
1888            colors,
1889            stops,
1890            center,
1891            radius,
1892            tile_mode,
1893        } => {
1894            let count = fill_gradient_entries(colors, stops.as_deref());
1895            gradient_params = [
1896                canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
1897                canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
1898                (radius * root_scale).max(f32::EPSILON),
1899                0.0,
1900            ];
1901            (2u32, count, gradient_tile_mode_value(*tile_mode))
1902        }
1903        Brush::SweepGradient {
1904            colors,
1905            stops,
1906            center,
1907        } => {
1908            let count = fill_gradient_entries(colors, stops.as_deref());
1909            gradient_params = [
1910                canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
1911                canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
1912                0.0,
1913                0.0,
1914            ];
1915            (3u32, count, gradient_tile_mode_value(TileMode::Clamp))
1916        }
1917    };
1918
1919    // A stroked rect/round-rect was emitted with `local_rect` already
1920    // inflated by half the stroke width, so corner radii must resolve
1921    // against the geometry that was actually asked for, not the
1922    // inflated box. The shader shrinks `half_size` by the same amount.
1923    let stroke_outset = shape
1924        .stroke
1925        .map(|stroke| stroke.half_width())
1926        .unwrap_or(0.0);
1927    let geometry_width = (local_rect.width - stroke_outset * 2.0).max(0.0);
1928    let geometry_height = (local_rect.height - stroke_outset * 2.0).max(0.0);
1929
1930    let radii = if let Some(arc) = shape.arc {
1931        // Arcs never carry corner radii, so this slot ships the shader's
1932        // per-shape trig instead: (sin, cos) of the sweep's mid angle and of
1933        // the half sweep. Computing these here — once per shape — is what
1934        // lets `sdf_arc_band` run without a single transcendental per
1935        // fragment. A full ring is the common case (dots, particles) and
1936        // `ArcGeometry::new` normalizes it to start 0 / sweep TAU, whose
1937        // values are exact constants; the half-sweep sine is pinned to
1938        // non-negative just like the shader used to, so a closed ring keeps
1939        // its seam-free (0, -1) form.
1940        if arc.sweep_angle >= cranpose_ui_graphics::TAU && arc.start_angle == 0.0 {
1941            [0.0, -1.0, 0.0, -1.0]
1942        } else {
1943            let half_sweep = arc.sweep_angle.clamp(0.0, cranpose_ui_graphics::TAU) * 0.5;
1944            let (mid_sin, mid_cos) = (arc.start_angle + half_sweep).sin_cos();
1945            let (half_sin, half_cos) = half_sweep.sin_cos();
1946            [mid_sin, mid_cos, half_sin.max(0.0), half_cos]
1947        }
1948    } else if let Some(rounded) = shape.shape {
1949        let resolved = rounded.resolve(geometry_width, geometry_height);
1950        [
1951            resolved.top_left * root_scale,
1952            resolved.top_right * root_scale,
1953            resolved.bottom_left * root_scale,
1954            resolved.bottom_right * root_scale,
1955        ]
1956    } else {
1957        [0.0, 0.0, 0.0, 0.0]
1958    };
1959
1960    let device_rect = [
1961        device_local_rect.x,
1962        device_local_rect.y,
1963        device_local_rect.width,
1964        device_local_rect.height,
1965    ];
1966
1967    // Stroke/arc parameters ride in the same ShapeData and the same
1968    // pipeline as fills, so a stroked or arc shape never splits a
1969    // batch.
1970    let (stroke_params, arc_params) = match (shape.arc, shape.stroke) {
1971        (Some(arc), _) => (
1972            [
1973                0.0,
1974                pack_shape_flags(SHAPE_KIND_ARC, arc.cap, StrokeJoin::Miter),
1975                arc.outer_radius * root_scale,
1976                arc.inner_radius * root_scale,
1977            ],
1978            [
1979                (arc.center.x + snap_delta.x) * root_scale,
1980                (arc.center.y + snap_delta.y) * root_scale,
1981                arc.start_angle,
1982                arc.sweep_angle,
1983            ],
1984        ),
1985        (None, Some(stroke)) => (
1986            [
1987                stroke.width.max(0.0) * root_scale,
1988                pack_shape_flags(SHAPE_KIND_STROKE, stroke.cap, stroke.join),
1989                0.0,
1990                0.0,
1991            ],
1992            [0.0; 4],
1993        ),
1994        (None, None) => (
1995            [
1996                0.0,
1997                pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
1998                0.0,
1999                0.0,
2000            ],
2001            [0.0; 4],
2002        ),
2003    };
2004
2005    let color = match &shape.brush {
2006        Brush::Solid(c) => [c.r(), c.g(), c.b(), c.a()],
2007        Brush::LinearGradient { colors, .. } => {
2008            let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2009            [first.r(), first.g(), first.b(), first.a()]
2010        }
2011        Brush::RadialGradient { colors, .. } | Brush::SweepGradient { colors, .. } => {
2012            let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2013            [first.r(), first.g(), first.b(), first.a()]
2014        }
2015    };
2016
2017    *shape_out = ShapeData {
2018        rect: device_rect,
2019        radii,
2020        gradient_params,
2021        clip_rect,
2022        stroke_params,
2023        arc_params,
2024        quad01: [
2025            device_quad[0][0],
2026            device_quad[0][1],
2027            device_quad[1][0],
2028            device_quad[1][1],
2029        ],
2030        quad23: [
2031            device_quad[2][0],
2032            device_quad[2][1],
2033            device_quad[3][0],
2034            device_quad[3][1],
2035        ],
2036        color,
2037        brush_type,
2038        gradient_start,
2039        gradient_count,
2040        gradient_tile_mode,
2041    };
2042}
2043
2044/// `CRANPOSE_QUAD_AREA_DIAG=1` prints, per shape batch, how many device
2045/// pixels the emitted quads cover — split into arc quads, the true arc band
2046/// coverage inside them, and everything else. Fill cost is the product of
2047/// fragment count and shader cost, and this is the fragment-count half: it
2048/// is how the MEGA scene's ~10x overdraw (and the ~50% of arc-quad area that
2049/// the SDF discards) was measured.
2050fn quad_area_diag_enabled() -> bool {
2051    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2052    *ENABLED.get_or_init(|| std::env::var_os("CRANPOSE_QUAD_AREA_DIAG").is_some())
2053}
2054
2055/// Converts a batch of shapes into pre-sized output slices, fanning the work
2056/// across scoped threads when the batch is large enough to pay for spawns.
2057/// The outputs may be scratch vectors or mapped GPU staging memory; each
2058/// shape writes only its own disjoint slots, so chunked `split_at_mut`
2059/// hand-off keeps the parallel path free of any synchronization.
2060fn convert_shapes_into_outputs(
2061    shape_refs: &[&DrawShape],
2062    gradient_offsets: &[u32],
2063    root_scale: f32,
2064    shape_data_out: &mut [ShapeData],
2065    gradients_out: &mut [GradientStop],
2066) {
2067    let shape_count = shape_refs.len();
2068    #[cfg(not(target_arch = "wasm32"))]
2069    let convert_started = Instant::now();
2070    #[cfg(not(target_arch = "wasm32"))]
2071    let parallel =
2072        SHAPE_CONVERT_TUNER.choose_parallel(shape_count) && shape_convert_worker_count() > 1;
2073    if quad_area_diag_enabled() {
2074        let quad_area = |q: [[f32; 2]; 4]| {
2075            // Shoelace over the quad polygon TL, TR, BR, BL (corners 0,1,3,2).
2076            let poly = [q[0], q[1], q[3], q[2]];
2077            let mut twice = 0.0f64;
2078            for i in 0..4 {
2079                let a = poly[i];
2080                let b = poly[(i + 1) % 4];
2081                twice += a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64;
2082            }
2083            twice.abs() * 0.5
2084        };
2085        let mut arc_quad = 0.0f64; // quad px of arc shapes
2086        let mut arc_band = 0.0f64; // true band coverage of those arcs
2087        let mut arc_count = 0usize;
2088        let mut ring_count = 0usize;
2089        let mut other_quad = 0.0f64;
2090        let mut other_count = 0usize;
2091        // Largest non-arc quads: (area, index) so the tail of the diag can
2092        // name what the aggregate "other" fill actually is.
2093        let mut top_other: Vec<(f64, usize)> = Vec::new();
2094        for (index, shape) in shape_refs.iter().enumerate() {
2095            let area = quad_area(shape.quad);
2096            if let Some(arc) = shape.arc {
2097                arc_quad += area;
2098                arc_count += 1;
2099                if arc.sweep_angle >= cranpose_ui_graphics::TAU {
2100                    ring_count += 1;
2101                }
2102                let ra = arc.mid_radius() as f64;
2103                let rb = arc.half_thickness() as f64;
2104                arc_band +=
2105                    arc.sweep_angle as f64 * ra * (2.0 * rb) + std::f64::consts::PI * rb * rb;
2106            } else {
2107                other_quad += area;
2108                other_count += 1;
2109                top_other.push((area, index));
2110            }
2111        }
2112        let scale2 = (root_scale as f64) * (root_scale as f64);
2113        eprintln!(
2114            "[quad-area] arcs={arc_count} (rings={ring_count}) arc_quad_px={:.0} arc_band_px={:.0} | other={other_count} other_px={:.0}",
2115            arc_quad * scale2,
2116            arc_band * scale2,
2117            other_quad * scale2,
2118        );
2119        top_other.sort_by(|a, b| b.0.total_cmp(&a.0));
2120        for &(area, index) in top_other.iter().take(4) {
2121            let shape = shape_refs[index];
2122            let brush = match &shape.brush {
2123                cranpose_ui_graphics::Brush::Solid(color) => format!("solid a={:.2}", color.3),
2124                cranpose_ui_graphics::Brush::LinearGradient { colors, .. } => {
2125                    format!("linear n={}", colors.len())
2126                }
2127                cranpose_ui_graphics::Brush::RadialGradient { colors, .. } => {
2128                    format!("radial n={}", colors.len())
2129                }
2130                cranpose_ui_graphics::Brush::SweepGradient { colors, .. } => {
2131                    format!("sweep n={}", colors.len())
2132                }
2133            };
2134            eprintln!(
2135                "[quad-area]   top other: {:.0}px {}x{} at ({:.0},{:.0}) {} shape={} stroke={} clip={} blend={:?} z={}",
2136                area * scale2,
2137                shape.rect.width.round(),
2138                shape.rect.height.round(),
2139                shape.rect.x,
2140                shape.rect.y,
2141                brush,
2142                shape.shape.is_some(),
2143                shape.stroke.is_some(),
2144                shape.clip.is_some(),
2145                shape.blend_mode,
2146                shape.z_index,
2147            );
2148        }
2149    }
2150    #[cfg(target_arch = "wasm32")]
2151    let parallel = false;
2152    let workers = if parallel {
2153        shape_convert_worker_count()
2154    } else {
2155        1
2156    };
2157    if workers <= 1 {
2158        for (idx, shape) in shape_refs.iter().enumerate() {
2159            let gradient_start = gradient_offsets[idx];
2160            let gradient_end = gradient_offsets[idx + 1];
2161            convert_shape_into_slots(
2162                shape,
2163                root_scale,
2164                gradient_start,
2165                &mut shape_data_out[idx],
2166                &mut gradients_out[gradient_start as usize..gradient_end as usize],
2167            );
2168        }
2169        #[cfg(not(target_arch = "wasm32"))]
2170        SHAPE_CONVERT_TUNER.record(
2171            false,
2172            shape_count,
2173            convert_started.elapsed().as_nanos() as u64,
2174        );
2175        return;
2176    }
2177
2178    let chunk_len = shape_count.div_ceil(workers);
2179    let mut shape_data_rest = shape_data_out;
2180    let mut gradients_rest = gradients_out;
2181    std::thread::scope(|scope| {
2182        let mut chunk_start = 0usize;
2183        while chunk_start < shape_count {
2184            let chunk_end = (chunk_start + chunk_len).min(shape_count);
2185            let count = chunk_end - chunk_start;
2186            let gradient_base = gradient_offsets[chunk_start];
2187            let gradient_span = (gradient_offsets[chunk_end] - gradient_base) as usize;
2188            let (shape_data_chunk, rest) = std::mem::take(&mut shape_data_rest).split_at_mut(count);
2189            shape_data_rest = rest;
2190            let (gradient_chunk, rest) =
2191                std::mem::take(&mut gradients_rest).split_at_mut(gradient_span);
2192            gradients_rest = rest;
2193            let chunk_refs = &shape_refs[chunk_start..chunk_end];
2194            let chunk_offsets = &gradient_offsets[chunk_start..=chunk_end];
2195            let mut convert_chunk = move || {
2196                for (j, shape) in chunk_refs.iter().enumerate() {
2197                    let gradient_start = chunk_offsets[j];
2198                    let local_start = (gradient_start - gradient_base) as usize;
2199                    let local_end = (chunk_offsets[j + 1] - gradient_base) as usize;
2200                    convert_shape_into_slots(
2201                        shape,
2202                        root_scale,
2203                        gradient_start,
2204                        &mut shape_data_chunk[j],
2205                        &mut gradient_chunk[local_start..local_end],
2206                    );
2207                }
2208            };
2209            if chunk_end == shape_count {
2210                // The caller would only block at the scope join; converting
2211                // the final chunk inline puts that time to work and saves a
2212                // spawn.
2213                convert_chunk();
2214            } else {
2215                scope.spawn(convert_chunk);
2216            }
2217            chunk_start = chunk_end;
2218        }
2219    });
2220    #[cfg(not(target_arch = "wasm32"))]
2221    SHAPE_CONVERT_TUNER.record(
2222        true,
2223        shape_count,
2224        convert_started.elapsed().as_nanos() as u64,
2225    );
2226}
2227
2228#[repr(C)]
2229#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2230struct GradientStop {
2231    color: [f32; 4],
2232    position: [f32; 4],
2233}
2234
2235/// How many replay slots the shared transform buffer holds. Each slot's
2236/// transform lives at `slot * REPLAY_TRANSFORM_STRIDE`, aligned for the
2237/// strictest uniform-offset requirement any backend reports.
2238#[cfg(not(target_arch = "wasm32"))]
2239const MAX_REPLAY_SLOTS: u32 = 128;
2240#[cfg(not(target_arch = "wasm32"))]
2241const REPLAY_TRANSFORM_STRIDE: u64 = 256;
2242
2243/// One retained replay batch: converted shape slots captured on an earlier
2244/// frame, kept on the GPU and re-drawn each frame under the similarity
2245/// transform staged at `transform_offset`.
2246///
2247/// The immutable `ShapeData` and gradient buffers hold no handle here:
2248/// nothing addresses them after capture, and `bind_group` keeps them alive.
2249#[cfg(not(target_arch = "wasm32"))]
2250struct ReplaySlot {
2251    /// One `vec4<f32>` color per shape — the mutable paint the shader reads
2252    /// under `paint_select`, split out so recolor patches upload 16 bytes
2253    /// per shape while the 160-byte `ShapeData` stays immutable on the GPU
2254    /// from capture to release.
2255    paint_buffer: wgpu::Buffer,
2256    bind_group: wgpu::BindGroup,
2257    shape_count: u32,
2258    /// CPU mirror of the paint buffer. Recolor patches apply here first
2259    /// and upload as one contiguous span per slot per frame — MEGA's
2260    /// twinkle field recolors ~1.7k dots a frame, and that many individual
2261    /// copy commands stall a mobile GPU for longer than the spans' extra
2262    /// bytes ever could.
2263    paint_mirror: Vec<[f32; 4]>,
2264    /// Conservative capture-space arc/ring mesh, built once at capture.
2265    /// `None` when the kill switch is off, the slot meshed no arcs, or the
2266    /// vertex budget overflowed — those slots replay through the quad-expansion
2267    /// six-vertices-per-shape path.
2268    mesh: Option<ReplaySlotMesh>,
2269    /// Which capture created this slot's buffers, from the store's global
2270    /// monotone counter. Retained bundle keys carry it so a slot id that is
2271    /// released and recaptured — new bind group, new buffers, same id — can
2272    /// never be drawn through a bundle recorded against the old capture.
2273    capture_epoch: u64,
2274    /// Whether any captured shape carries gradient stops. False routes the
2275    /// slot's quad-expansion draws through the `fs_solid` pipelines; fixed
2276    /// for the life of the capture, so bundle keys need nothing beyond the
2277    /// capture epoch they already carry.
2278    has_gradient: bool,
2279}
2280
2281/// Vertex geometry a retained slot replays instead of per-shape quads: arc
2282/// bands get trapezoid strips covering only their antialiasing footprint,
2283/// every other shape gets a passthrough pair of triangles identical to the
2284/// quad expansion. See [`build_arc_mesh_vertices`].
2285#[cfg(not(target_arch = "wasm32"))]
2286struct ReplaySlotMesh {
2287    vertex_buffer: wgpu::Buffer,
2288    /// `u32` triangle-list indices into `vertex_buffer`: band-boundary
2289    /// vertices are emitted once and shared by both adjacent trapezoids, so
2290    /// per-arc vertex-shader work drops from ~30 executions to the unique
2291    /// boundary vertices (~10-14) — the amplification that made the
2292    /// non-indexed mesh SLOWER than plain quads on the watch's Adreno 702.
2293    index_buffer: wgpu::Buffer,
2294    /// Prefix table, `shape_count + 1` entries: shape `i`'s triangles occupy
2295    /// indices `index_prefix[i]..index_prefix[i + 1]`, so a retained span
2296    /// draws `index_prefix[first]..index_prefix[first + count]` — one
2297    /// `draw_indexed` per op, identical shape order, z untouched.
2298    index_prefix: Vec<u32>,
2299}
2300
2301/// Vertex of a retained slot's conservative arc mesh: capture-device-space
2302/// position, the uv reproducing `vs_main`'s affine rect map at that position,
2303/// and the shape index standing in for `vertex_index / 6`.
2304#[cfg(not(target_arch = "wasm32"))]
2305#[repr(C)]
2306#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2307struct MeshVertex {
2308    position: [f32; 2],
2309    uv: [f32; 2],
2310    shape_idx: u32,
2311}
2312
2313#[cfg(not(target_arch = "wasm32"))]
2314impl MeshVertex {
2315    const ATTRIBS: [wgpu::VertexAttribute; 3] =
2316        wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Uint32];
2317
2318    fn desc() -> wgpu::VertexBufferLayout<'static> {
2319        wgpu::VertexBufferLayout {
2320            array_stride: std::mem::size_of::<MeshVertex>() as wgpu::BufferAddress,
2321            step_mode: wgpu::VertexStepMode::Vertex,
2322            attributes: &Self::ATTRIBS,
2323        }
2324    }
2325}
2326
2327/// Kill switch, mirroring `command_feed_enabled`: default ON,
2328/// `CRANPOSE_ARC_MESH=0` (or the `debug.cranpose.arc_mesh` property on
2329/// Android) makes the next capture skip mesh building entirely, so a device
2330/// A/B needs no rebuild. Read per capture — captures are rare.
2331#[cfg(not(target_arch = "wasm32"))]
2332fn arc_mesh_enabled() -> bool {
2333    // Opt-in (CRANPOSE_ARC_MESH=1 / debug.cranpose.arc_mesh): the Gate 0
2334    // off-charger watch A/B measured the non-indexed mesh 5-7 fps SLOWER
2335    // than plain quads on the Adreno 702 — the 4-6x vertex amplification
2336    // outweighs the fragment savings on a small binning GPU (big desktop
2337    // GPUs and the at-vsync-ceiling Huawei masked it). Default returns to
2338    // quad expansion until indexed band-boundary geometry removes the
2339    // amplification; then the A/B is repeated.
2340    matches!(std::env::var("CRANPOSE_ARC_MESH").as_deref(), Ok(v) if v != "0")
2341}
2342
2343/// Dilation applied to the band's half-thickness before meshing, in capture
2344/// device pixels. The fragment SDF feathers over ±0.5 px
2345/// (`smoothstep(-0.5, 0.5, dist)`), so every pixel the shader keeps sits
2346/// within 0.5 px of the band; the other 0.5 px absorbs f32 slop between this
2347/// builder's trig and the converted shape's precomputed (sin, cos) pairs.
2348#[cfg(not(target_arch = "wasm32"))]
2349const ARC_MESH_MARGIN: f32 = 1.0;
2350
2351/// Chord overshoot budget in pixels: the segment count is chosen so pushing
2352/// outer edges tangent-outside the dilated outer circle overshoots it by
2353/// about this much at the chord ends.
2354#[cfg(not(target_arch = "wasm32"))]
2355const ARC_MESH_OVERSHOOT: f32 = 2.0;
2356
2357#[cfg(not(target_arch = "wasm32"))]
2358const ARC_MESH_MIN_SEGMENTS: usize = 4;
2359#[cfg(not(target_arch = "wasm32"))]
2360const ARC_MESH_MAX_SEGMENTS: usize = 64;
2361
2362/// Per-slot geometry budget in BYTES: 48 vertex-equivalents (~1 KB) per
2363/// shape, floored for tiny slots so a single huge ring still fits. The
2364/// non-indexed mesh spent this entirely on 20-byte vertices; the indexed
2365/// mesh counts vertices AND 4-byte indices against the same byte ceiling,
2366/// which indexed geometry fits with more headroom (MEGA's retained arcs
2367/// drop from ~30 vertices ≈ 600 B to ~12 unique vertices + ~30 indices
2368/// ≈ 360 B). Overflow falls back to whole-slot passthrough WITH a warning —
2369/// truncating silently would break the containment invariant.
2370#[cfg(not(target_arch = "wasm32"))]
2371const ARC_MESH_BUDGET_BYTES_PER_SHAPE: usize = 48 * std::mem::size_of::<MeshVertex>();
2372#[cfg(not(target_arch = "wasm32"))]
2373const ARC_MESH_BUDGET_FLOOR_BYTES: usize = 4096 * std::mem::size_of::<MeshVertex>();
2374
2375/// The budget-relevant size of an indexed mesh: what the GPU buffers will
2376/// actually hold.
2377#[cfg(not(target_arch = "wasm32"))]
2378fn arc_mesh_bytes(vertices: usize, indices: usize) -> usize {
2379    vertices * std::mem::size_of::<MeshVertex>() + indices * std::mem::size_of::<u32>()
2380}
2381
2382/// Band parameters of a captured arc that qualifies for a conservative mesh:
2383/// solid brush, no clip, and a quad that is exactly — tolerance zero — the
2384/// axis-aligned box of its rect. Everything else returns `None` and passes
2385/// through as today's two quad triangles.
2386#[cfg(not(target_arch = "wasm32"))]
2387struct ArcMeshBand {
2388    center: [f32; 2],
2389    inner: f32,
2390    outer: f32,
2391    start: f32,
2392    sweep: f32,
2393}
2394
2395#[cfg(not(target_arch = "wasm32"))]
2396fn arc_mesh_band(shape: &ShapeData) -> Option<ArcMeshBand> {
2397    // Mirror the fragment shader's flag decode (`u32(max(x, 0.0))`).
2398    let flags = shape.stroke_params[1].max(0.0) as u32;
2399    if flags & 3 != SHAPE_KIND_ARC {
2400        return None;
2401    }
2402    // Solid brushes only: gradients also derive from `rect_pos` and would
2403    // mesh in principle, but the hot retained scenes are solid and a narrow
2404    // gate keeps the byte-exactness surface small.
2405    if shape.brush_type != 0 {
2406        return None;
2407    }
2408    // A live clip is a hard `world_pos` comparison in the fragment shader.
2409    // Meshed arcs interpolate `world_pos` across different triangles than
2410    // the quad would, and one ulp of difference at the clip boundary flips
2411    // whole pixels — clipped arcs pass through untouched.
2412    if shape.clip_rect[2] > 0.0 && shape.clip_rect[3] > 0.0 {
2413        return None;
2414    }
2415    let [_, _, w, h] = shape.rect;
2416    if !(w > 0.0 && h > 0.0) {
2417        return None;
2418    }
2419    // The quad must be an axis-aligned box, tolerance zero: the mesh is
2420    // clipped to the quad's own corners, so as long as the quad IS a box its
2421    // rasterized pixel set equals the mesh clip region and the tight-AABB
2422    // tangent-point crop is reproduced exactly. (Comparing against `rect`
2423    // instead is an over-tight gate: under a non-dyadic root scale
2424    // `(x + w) * s` differs from `x * s + w * s` by an ulp and every arc
2425    // fell back to passthrough — observed on the Huawei at scale 2.75.)
2426    let [left, top, right, _] = shape.quad01;
2427    let [bl_x, bottom, br_x, br_y] = shape.quad23;
2428    let axis_aligned = shape.quad01[3] == top
2429        && bl_x == left
2430        && br_x == right
2431        && br_y == bottom
2432        && left < right
2433        && top < bottom;
2434    if !axis_aligned {
2435        return None;
2436    }
2437    let center = [shape.arc_params[0], shape.arc_params[1]];
2438    let start = shape.arc_params[2];
2439    let sweep = shape.arc_params[3];
2440    let outer = shape.stroke_params[2];
2441    let inner = shape.stroke_params[3];
2442    let finite = center[0].is_finite()
2443        && center[1].is_finite()
2444        && start.is_finite()
2445        && sweep.is_finite()
2446        && outer.is_finite()
2447        && inner.is_finite();
2448    if !finite || outer <= 0.0 || sweep <= 0.0 {
2449        return None;
2450    }
2451    Some(ArcMeshBand {
2452        center,
2453        inner,
2454        outer,
2455        start,
2456        sweep,
2457    })
2458}
2459
2460/// Emits the quad `vs_main` would expand for this shape as four shared
2461/// vertices plus the index pattern (0, 1, 2)(2, 1, 3) — the identical corner
2462/// order, corner uvs and positions straight from the captured quad, so a
2463/// passthrough shape rasterizes bit-identically to the quad-expansion
2464/// indexless path while spending four vertex executions instead of six.
2465#[cfg(not(target_arch = "wasm32"))]
2466fn emit_passthrough_quad(
2467    shape: &ShapeData,
2468    shape_idx: u32,
2469    vertices: &mut Vec<MeshVertex>,
2470    indices: &mut Vec<u32>,
2471) {
2472    let base = vertices.len() as u32;
2473    let corners = [
2474        ([shape.quad01[0], shape.quad01[1]], [0.0, 0.0]),
2475        ([shape.quad01[2], shape.quad01[3]], [1.0, 0.0]),
2476        ([shape.quad23[0], shape.quad23[1]], [0.0, 1.0]),
2477        ([shape.quad23[2], shape.quad23[3]], [1.0, 1.0]),
2478    ];
2479    for (position, uv) in corners {
2480        vertices.push(MeshVertex {
2481            position,
2482            uv,
2483            shape_idx,
2484        });
2485    }
2486    indices.extend([0u32, 1, 2, 2, 1, 3].map(|corner| base + corner));
2487}
2488
2489/// One Sutherland–Hodgman pass against an axis-aligned half-plane.
2490///
2491/// Two properties the byte-exactness bar depends on:
2492/// * the clipped coordinate is set to `bound` EXACTLY rather than recomputed
2493///   through `p + t * (q - p)`, so every clipped polygon's boundary lies
2494///   bitwise on the clip line;
2495/// * the intersection is computed on the lexicographically ordered endpoint
2496///   pair, so the shared radial edge of two adjacent trapezoids — traversed
2497///   in opposite directions — clips to bitwise-identical points, keeping the
2498///   strip watertight (no pixel shaded twice or missed along the seam).
2499#[cfg(not(target_arch = "wasm32"))]
2500fn clip_polygon_axis(
2501    input: &[[f32; 2]],
2502    axis: usize,
2503    bound: f32,
2504    keep_at_most: bool,
2505    output: &mut Vec<[f32; 2]>,
2506) {
2507    output.clear();
2508    let inside = |p: [f32; 2]| {
2509        if keep_at_most {
2510            p[axis] <= bound
2511        } else {
2512            p[axis] >= bound
2513        }
2514    };
2515    let intersect = |a: [f32; 2], b: [f32; 2]| {
2516        let (p, q) = if (b[0], b[1]) < (a[0], a[1]) {
2517            (b, a)
2518        } else {
2519            (a, b)
2520        };
2521        let t = (bound - p[axis]) / (q[axis] - p[axis]);
2522        let mut point = [0.0f32; 2];
2523        point[axis] = bound;
2524        point[1 - axis] = p[1 - axis] + t * (q[1 - axis] - p[1 - axis]);
2525        point
2526    };
2527    for (index, &current) in input.iter().enumerate() {
2528        let previous = input[(index + input.len() - 1) % input.len()];
2529        match (inside(previous), inside(current)) {
2530            (true, true) => output.push(current),
2531            (true, false) => output.push(intersect(previous, current)),
2532            (false, true) => {
2533                output.push(intersect(previous, current));
2534                output.push(current);
2535            }
2536            (false, false) => {}
2537        }
2538    }
2539}
2540
2541/// Emits the conservative trapezoid-strip mesh for one qualifying arc band.
2542///
2543/// CONTAINMENT INVARIANT (the byte-exactness bar): the union of emitted
2544/// triangles is a superset of `{ p in the capture quad's box :
2545/// sdf_arc_band(p) <= 0.5 }` — every pixel the fragment shader would keep.
2546/// Over-inclusion is free (the SDF discards those pixels identically to
2547/// today's quad); only under-inclusion can diverge, and
2548/// `arc_mesh_contains_every_band_pixel` checks it never happens.
2549///
2550/// Geometry: outer vertices ride at `Ro / cos(step / 2)` so every chord is
2551/// tangent-outside the dilated outer circle; inner vertices ride at the
2552/// dilated inner radius, whose chords lie inside the hole. Cap coverage is
2553/// bounded by the round-cap disc about the band endpoint (butt/square caps
2554/// only cut that disc with planes — see `sdf_arc_band`), so padding the
2555/// angular range by the disc's angular half-extent contains every cap. Each
2556/// trapezoid is clipped to the quad box and fan-triangulated IN INDEX SPACE:
2557/// a trapezoid the clipper left untouched shares its two boundary vertices
2558/// with each neighbor through the index list (closed rings wrap the sharing
2559/// modulo the boundary count), so the strip is watertight by construction —
2560/// the seam edge is one vertex pair, not two bitwise-equal copies — and the
2561/// per-arc vertex count collapses from three-per-triangle to the unique
2562/// boundary vertices. Clipped trapezoids cannot share boundary vertices (the
2563/// clipper rewrote them), so their fan vertices are appended PRIVATELY after
2564/// the shared block and indexed directly; seams against neighbors still hold
2565/// because a boundary edge either survives the clip on both sides
2566/// bitwise-identically (same input edge, same planes, same float ops — see
2567/// `clip_polygon_axis`) or is cut on both sides identically. Triangles are
2568/// emitted in exact segment order either way, so the indexed mesh's
2569/// primitive stream is triangle-for-triangle the one the non-indexed
2570/// emitter produced.
2571///
2572/// Returns the emitted segment count, or `None` when the mesh came out empty
2573/// — the caller emits the passthrough quad instead (never risk
2574/// under-coverage).
2575#[cfg(not(target_arch = "wasm32"))]
2576fn emit_arc_band_mesh(
2577    shape: &ShapeData,
2578    shape_idx: u32,
2579    band: &ArcMeshBand,
2580    vertices: &mut Vec<MeshVertex>,
2581    indices: &mut Vec<u32>,
2582) -> Option<usize> {
2583    let [cx, cy] = band.center;
2584    let ra = (band.outer + band.inner) * 0.5;
2585    let rb = ((band.outer - band.inner) * 0.5).max(0.0);
2586    let rb_m = rb + ARC_MESH_MARGIN;
2587    let ro = ra + rb_m;
2588    let ri = (ra - rb_m).max(0.0);
2589    let tau = cranpose_ui_graphics::TAU;
2590
2591    let (range_start, range) = if band.sweep >= tau {
2592        (0.0, tau)
2593    } else {
2594        let pad = if rb_m < ra {
2595            (rb_m / ra).asin() + 0.05
2596        } else {
2597            // The cap disc wraps the center; such shapes are tiny, take the
2598            // whole circle.
2599            std::f32::consts::PI
2600        };
2601        let padded = band.sweep + pad + pad;
2602        if padded >= tau {
2603            (0.0, tau)
2604        } else {
2605            (band.start - pad, padded)
2606        }
2607    };
2608    let closed = range >= tau;
2609
2610    let dtheta = (2.0 * (ro / (ro + ARC_MESH_OVERSHOOT)).acos()).clamp(tau / 64.0, tau / 6.0);
2611    let segments =
2612        ((range / dtheta).ceil() as usize).clamp(ARC_MESH_MIN_SEGMENTS, ARC_MESH_MAX_SEGMENTS);
2613    let step = range / segments as f32;
2614    let rc = ro / (step * 0.5).cos();
2615
2616    // Boundary vertices are computed once and shared by both adjacent
2617    // trapezoids: bitwise-equal edge endpoints are what let the rasterizer's
2618    // fill rule shade each seam exactly once.
2619    let boundary_count = if closed { segments } else { segments + 1 };
2620    let mut boundaries = Vec::with_capacity(boundary_count);
2621    for j in 0..boundary_count {
2622        let (sin, cos) = (range_start + step * j as f32).sin_cos();
2623        boundaries.push((
2624            [cx + cos * ri, cy + sin * ri],
2625            [cx + cos * rc, cy + sin * rc],
2626        ));
2627    }
2628
2629    let quad_min = [shape.quad01[0], shape.quad01[1]];
2630    let quad_max = [shape.quad23[2], shape.quad23[3]];
2631
2632    /// One trapezoid's clip outcome (see the function docs): `Shared` means
2633    /// the clip output is bitwise the input quad, so its corners index the
2634    /// shared boundary block; `Fan` carries the clipped polygon for private
2635    /// fan triangulation; `Empty` was clipped away entirely.
2636    enum SegmentGeometry {
2637        Shared,
2638        Fan(Vec<[f32; 2]>),
2639        Empty,
2640    }
2641
2642    // Phase 1: clip every trapezoid and classify it.
2643    let mut polygon: Vec<[f32; 2]> = Vec::with_capacity(8);
2644    let mut scratch: Vec<[f32; 2]> = Vec::with_capacity(8);
2645    let mut segment_geometry = Vec::with_capacity(segments);
2646    let mut boundary_used = vec![false; boundary_count];
2647    for j in 0..segments {
2648        let jb = (j + 1) % boundary_count;
2649        let (inner_a, outer_a) = boundaries[j];
2650        let (inner_b, outer_b) = boundaries[jb];
2651        polygon.clear();
2652        polygon.extend_from_slice(&[inner_a, outer_a, outer_b, inner_b]);
2653        clip_polygon_axis(&polygon, 0, quad_min[0], false, &mut scratch);
2654        clip_polygon_axis(&scratch, 0, quad_max[0], true, &mut polygon);
2655        clip_polygon_axis(&polygon, 1, quad_min[1], false, &mut scratch);
2656        clip_polygon_axis(&scratch, 1, quad_max[1], true, &mut polygon);
2657        // Collapse exact duplicates (an `Ri == 0` pie wedge duplicates the
2658        // center) before fanning.
2659        scratch.clear();
2660        for &point in polygon.iter() {
2661            if scratch.last() != Some(&point) {
2662                scratch.push(point);
2663            }
2664        }
2665        while scratch.len() > 1 && scratch.first() == scratch.last() {
2666            scratch.pop();
2667        }
2668        if scratch.len() < 3 {
2669            segment_geometry.push(SegmentGeometry::Empty);
2670        } else if scratch[..] == [inner_a, outer_a, outer_b, inner_b] {
2671            boundary_used[j] = true;
2672            boundary_used[jb] = true;
2673            segment_geometry.push(SegmentGeometry::Shared);
2674        } else {
2675            segment_geometry.push(SegmentGeometry::Fan(scratch.clone()));
2676        }
2677    }
2678
2679    let push_vertex = |vertices: &mut Vec<MeshVertex>, position: [f32; 2]| -> u32 {
2680        let index = vertices.len() as u32;
2681        vertices.push(MeshVertex {
2682            position,
2683            uv: [
2684                (position[0] - shape.rect[0]) / shape.rect[2],
2685                (position[1] - shape.rect[1]) / shape.rect[3],
2686            ],
2687            shape_idx,
2688        });
2689        index
2690    };
2691
2692    // Shared block: every boundary referenced by a surviving whole trapezoid
2693    // gets its (inner, outer) vertex pair exactly once, in boundary order.
2694    let mut boundary_vertex = vec![[0u32; 2]; boundary_count];
2695    for (j, used) in boundary_used.iter().enumerate() {
2696        if *used {
2697            let (inner, outer) = boundaries[j];
2698            boundary_vertex[j] = [push_vertex(vertices, inner), push_vertex(vertices, outer)];
2699        }
2700    }
2701
2702    // Phase 2: indices in exact segment order — the primitive stream matches
2703    // the non-indexed emitter triangle for triangle.
2704    let start_len = indices.len();
2705    for (j, geometry) in segment_geometry.iter().enumerate() {
2706        match geometry {
2707            SegmentGeometry::Empty => {}
2708            SegmentGeometry::Shared => {
2709                let jb = (j + 1) % boundary_count;
2710                let [in_a, out_a] = boundary_vertex[j];
2711                let [in_b, out_b] = boundary_vertex[jb];
2712                // The fan the non-indexed emitter produced for an untouched
2713                // trapezoid: (in_a, out_a, out_b)(in_a, out_b, in_b) — the
2714                // same quad diagonal.
2715                indices.extend_from_slice(&[in_a, out_a, out_b, in_a, out_b, in_b]);
2716            }
2717            SegmentGeometry::Fan(points) => {
2718                let base = vertices.len() as u32;
2719                for &point in points {
2720                    push_vertex(vertices, point);
2721                }
2722                for i in 1..points.len() as u32 - 1 {
2723                    indices.extend_from_slice(&[base, base + i, base + i + 1]);
2724                }
2725            }
2726        }
2727    }
2728    if indices.len() == start_len {
2729        return None;
2730    }
2731    Some(segments)
2732}
2733
2734/// Unsigned shoelace area of an emitted indexed triangle list, for
2735/// telemetry.
2736#[cfg(not(target_arch = "wasm32"))]
2737fn triangles_shoelace_area(vertices: &[MeshVertex], indices: &[u32]) -> f64 {
2738    indices
2739        .chunks_exact(3)
2740        .map(|tri| {
2741            let [a, b, c] = [
2742                vertices[tri[0] as usize].position,
2743                vertices[tri[1] as usize].position,
2744                vertices[tri[2] as usize].position,
2745            ];
2746            let cross = (b[0] as f64 - a[0] as f64) * (c[1] as f64 - a[1] as f64)
2747                - (b[1] as f64 - a[1] as f64) * (c[0] as f64 - a[0] as f64);
2748            cross.abs() * 0.5
2749        })
2750        .sum()
2751}
2752
2753/// Unsigned area of the two triangles the quad-expansion path would rasterize for
2754/// this shape, for telemetry.
2755#[cfg(not(target_arch = "wasm32"))]
2756fn quad_shoelace_area(shape: &ShapeData) -> f64 {
2757    let corners = [
2758        [shape.quad01[0] as f64, shape.quad01[1] as f64],
2759        [shape.quad01[2] as f64, shape.quad01[3] as f64],
2760        [shape.quad23[0] as f64, shape.quad23[1] as f64],
2761        [shape.quad23[2] as f64, shape.quad23[3] as f64],
2762    ];
2763    let tri = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
2764        ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() * 0.5
2765    };
2766    tri(corners[0], corners[1], corners[2]) + tri(corners[2], corners[1], corners[3])
2767}
2768
2769#[cfg(not(target_arch = "wasm32"))]
2770struct ArcMeshBuild {
2771    vertices: Vec<MeshVertex>,
2772    /// Triangle-list indices into `vertices`; see [`ReplaySlotMesh`].
2773    indices: Vec<u32>,
2774    /// `shape_count + 1` entries; shape `i` owns triangles
2775    /// `indices[index_prefix[i]..index_prefix[i + 1]]`.
2776    index_prefix: Vec<u32>,
2777    meshed_arcs: usize,
2778    meshed_segments: usize,
2779    passthrough: usize,
2780    quad_area: f64,
2781    mesh_area: f64,
2782}
2783
2784/// Builds a slot's conservative indexed mesh: arc bands become
2785/// vertex-sharing trapezoid strips, every other shape a passthrough quad
2786/// (four vertices, six indices), in the exact capture shape order. Returns
2787/// `None` when the byte budget overflows — the caller warns and the whole
2788/// slot replays through the quad-expansion path (silent truncation would
2789/// break the containment invariant).
2790#[cfg(not(target_arch = "wasm32"))]
2791fn build_arc_mesh_vertices(shape_data: &[ShapeData]) -> Option<ArcMeshBuild> {
2792    let budget_bytes =
2793        (shape_data.len() * ARC_MESH_BUDGET_BYTES_PER_SHAPE).max(ARC_MESH_BUDGET_FLOOR_BYTES);
2794    let mut build = ArcMeshBuild {
2795        vertices: Vec::new(),
2796        indices: Vec::new(),
2797        index_prefix: Vec::with_capacity(shape_data.len() + 1),
2798        meshed_arcs: 0,
2799        meshed_segments: 0,
2800        passthrough: 0,
2801        quad_area: 0.0,
2802        mesh_area: 0.0,
2803    };
2804    build.index_prefix.push(0);
2805    for (index, shape) in shape_data.iter().enumerate() {
2806        let start = build.indices.len();
2807        let meshed = arc_mesh_band(shape).and_then(|band| {
2808            emit_arc_band_mesh(
2809                shape,
2810                index as u32,
2811                &band,
2812                &mut build.vertices,
2813                &mut build.indices,
2814            )
2815        });
2816        match meshed {
2817            Some(segments) => {
2818                build.meshed_arcs += 1;
2819                build.meshed_segments += segments;
2820            }
2821            None => {
2822                emit_passthrough_quad(shape, index as u32, &mut build.vertices, &mut build.indices);
2823                build.passthrough += 1;
2824            }
2825        }
2826        if arc_mesh_bytes(build.vertices.len(), build.indices.len()) > budget_bytes {
2827            return None;
2828        }
2829        build.index_prefix.push(build.indices.len() as u32);
2830        build.quad_area += quad_shoelace_area(shape);
2831        build.mesh_area += triangles_shoelace_area(&build.vertices, &build.indices[start..]);
2832    }
2833    Some(build)
2834}
2835
2836/// The renderer's registry of live replay slots. The replay cache (scene
2837/// side) owns slot LIFECYCLE decisions; this store owns the GPU resources.
2838#[cfg(not(target_arch = "wasm32"))]
2839struct ReplaySlotStore {
2840    slots: std::collections::HashMap<u32, ReplaySlot, cranpose_ui_graphics::FxBuildHasher>,
2841    transform_buffer: wgpu::Buffer,
2842    free_ids: Vec<u32>,
2843    /// Global capture counter feeding [`ReplaySlot::capture_epoch`]: bumped
2844    /// on every capture, never reused, so an epoch identifies one capture's
2845    /// buffers for the renderer's whole lifetime.
2846    next_capture_epoch: u64,
2847}
2848
2849#[cfg(not(target_arch = "wasm32"))]
2850impl ReplaySlotStore {
2851    fn new(device: &wgpu::Device) -> Self {
2852        let transform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2853            label: Some("Replay Transform Buffer"),
2854            size: MAX_REPLAY_SLOTS as u64 * REPLAY_TRANSFORM_STRIDE,
2855            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2856            mapped_at_creation: false,
2857        });
2858        Self {
2859            slots: std::collections::HashMap::default(),
2860            transform_buffer,
2861            free_ids: (0..MAX_REPLAY_SLOTS).rev().collect(),
2862            next_capture_epoch: 1,
2863        }
2864    }
2865}
2866
2867/// Kill switch for cached retained render bundles, mirroring
2868/// `command_feed_enabled`: default ON, `CRANPOSE_RETAINED_BUNDLES=0` (or the
2869/// `debug.cranpose.retained_bundles` property on Android) drops the fused
2870/// retained arms back to direct per-op encoding, so a device A/B needs no
2871/// rebuild. Read per partition — the parity harness flips it between passes.
2872#[cfg(not(target_arch = "wasm32"))]
2873fn retained_bundles_enabled() -> bool {
2874    std::env::var("CRANPOSE_RETAINED_BUNDLES").as_deref() != Ok("0")
2875}
2876
2877/// Kill switch for instanced ordinary-shape quads: default ON,
2878/// `CRANPOSE_INSTANCED_QUADS=0` (or the `debug.cranpose.instanced_quads`
2879/// property on Android) reverts every ordinary shape draw to the six-vertex
2880/// `vs_main` expansion. Unlike the per-partition bundle flag this is read
2881/// ONCE per [`GpuRenderer`] construction into a field: cached retained
2882/// bundles encode the selected pipeline, so a flag that moved per draw would
2883/// let a cached bundle replay a selection the direct path no longer makes.
2884#[cfg(not(target_arch = "wasm32"))]
2885fn instanced_quads_enabled() -> bool {
2886    std::env::var("CRANPOSE_INSTANCED_QUADS").as_deref() != Ok("0")
2887}
2888
2889/// The index pattern of one instanced quad: the exact triangle pair
2890/// `vs_main`'s six-slot corner mapping produces — (0, 1, 2)(2, 1, 3), same
2891/// diagonal, same winding — shared by every instance.
2892#[cfg(not(target_arch = "wasm32"))]
2893const INSTANCED_QUAD_INDICES: [u16; 6] = [0, 1, 2, 2, 1, 3];
2894
2895/// The latched instanced-quad selection: `Some` exactly when the renderer
2896/// was constructed in storage mode with [`instanced_quads_enabled`]. Both
2897/// blend variants exist because ordinary batches draw SrcOver and DstOut;
2898/// the `vs_main` pipelines coexist untouched so the `=0` revert (and the
2899/// uniform-mode path) still has its six-vertex draws.
2900#[cfg(not(target_arch = "wasm32"))]
2901struct InstancedQuadPipelines {
2902    pipeline: LazyGpuResource<wgpu::RenderPipeline>,
2903    pipeline_dst_out: LazyGpuResource<wgpu::RenderPipeline>,
2904    /// `fs_solid` twin of `pipeline` (SrcOver only): chosen for draws whose
2905    /// shapes carry no gradient stops, which is nearly every draw of an
2906    /// arc-heavy scene.
2907    pipeline_solid: LazyGpuResource<wgpu::RenderPipeline>,
2908    /// Static `[0, 1, 2, 2, 1, 3]` u16 index buffer, created once and shared
2909    /// by every instanced draw.
2910    index_buffer: wgpu::Buffer,
2911}
2912
2913/// Everything that decides the commands one retained op contributes to a
2914/// cached bundle. Equal op keys imply identical encoded commands:
2915/// `capture_epoch` pins the slot's bind group and buffers to one capture,
2916/// `has_mesh` pins the pipeline and vertex-buffer choice, `first..last` is
2917/// the clamped draw range, and `retained_index` is the dynamic transform
2918/// offset. Transforms and paints are NOT here — they are data-buffer
2919/// contents the bundle reads at execution.
2920#[cfg(not(target_arch = "wasm32"))]
2921#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2922struct RetainedBundleOpKey {
2923    slot: u32,
2924    /// The slot's capture epoch at key time, `None` while the slot is absent
2925    /// from the store (the op encodes nothing). Epochs are globally unique
2926    /// per capture, so a recaptured slot reusing its id can never satisfy a
2927    /// key recorded against the previous capture's buffers.
2928    capture_epoch: Option<u64>,
2929    first: u32,
2930    last: u32,
2931    retained_index: u32,
2932    has_mesh: bool,
2933}
2934
2935/// Key of one maximal consecutive retained stretch: the op keys in draw
2936/// order. Any reorder, count change, range change, recapture, or slot
2937/// release changes the key and forces a rebuild.
2938#[cfg(not(target_arch = "wasm32"))]
2939#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
2940struct RetainedBundleKey {
2941    ops: Vec<RetainedBundleOpKey>,
2942}
2943
2944#[cfg(not(target_arch = "wasm32"))]
2945struct RetainedBundleCacheEntry<B> {
2946    bundle: B,
2947    last_used_frame: u64,
2948}
2949
2950/// Cache of encoded render bundles for retained stretches, generic over the
2951/// bundle payload so the reuse/invalidation/eviction logic is unit-testable
2952/// without a GPU. The full [`RetainedBundleKey`] is the map key — a fresh
2953/// key can only ever build a fresh bundle, never alias a stale one.
2954///
2955/// The surface format and the group-0 uniform bind group are deliberately
2956/// not part of the key: both are fixed for a `GpuRenderer`'s lifetime (a
2957/// surface reconfigure builds a new renderer, and with it an empty cache).
2958#[cfg(not(target_arch = "wasm32"))]
2959struct RetainedBundleCacheImpl<B> {
2960    entries: HashMap<RetainedBundleKey, RetainedBundleCacheEntry<B>>,
2961    frame: u64,
2962    rebuilds: u64,
2963    cached_executes: u64,
2964    window_rebuilds: u64,
2965    window_executes: u64,
2966}
2967
2968#[cfg(not(target_arch = "wasm32"))]
2969type RetainedBundleCache = RetainedBundleCacheImpl<wgpu::RenderBundle>;
2970
2971#[cfg(not(target_arch = "wasm32"))]
2972impl<B> RetainedBundleCacheImpl<B> {
2973    fn new() -> Self {
2974        Self {
2975            entries: HashMap::default(),
2976            frame: 0,
2977            rebuilds: 0,
2978            cached_executes: 0,
2979            window_rebuilds: 0,
2980            window_executes: 0,
2981        }
2982    }
2983
2984    /// True when a bundle for `key` is cached; marks it used this frame and
2985    /// counts a cached execute.
2986    fn hit(&mut self, key: &RetainedBundleKey) -> bool {
2987        let frame = self.frame;
2988        match self.entries.get_mut(key) {
2989            Some(entry) => {
2990                entry.last_used_frame = frame;
2991                self.cached_executes += 1;
2992                self.window_executes += 1;
2993                true
2994            }
2995            None => false,
2996        }
2997    }
2998
2999    /// Stores a freshly built bundle, counting a rebuild.
3000    fn insert(&mut self, key: RetainedBundleKey, bundle: B) {
3001        self.rebuilds += 1;
3002        self.window_rebuilds += 1;
3003        self.entries.insert(
3004            key,
3005            RetainedBundleCacheEntry {
3006                bundle,
3007                last_used_frame: self.frame,
3008            },
3009        );
3010    }
3011
3012    fn get(&self, key: &RetainedBundleKey) -> Option<&B> {
3013        self.entries.get(key).map(|entry| &entry.bundle)
3014    }
3015
3016    /// Drops every cached bundle. Called whenever a replay slot is released:
3017    /// the key compare already makes stale entries unreachable (their epochs
3018    /// can never recur), so this only releases the dropped capture's GPU
3019    /// resources promptly instead of one frame later via eviction.
3020    fn clear(&mut self) {
3021        self.entries.clear();
3022    }
3023
3024    /// Frame boundary: evicts entries the frame did not use — a bundle
3025    /// holds references on its slot's buffers, so unused entries must not
3026    /// accumulate — and emits the rate-limited rebuild/execute telemetry.
3027    fn end_frame(&mut self) {
3028        let frame = self.frame;
3029        self.entries
3030            .retain(|_, entry| entry.last_used_frame >= frame);
3031        self.frame = self.frame.wrapping_add(1);
3032        // Always-on at a cadence that cannot spam; every perf window (120
3033        // frames) under the replay diagnostics flag so short A/B runs see
3034        // the counts. log::warn because log::info is invisible on the
3035        // desktop console.
3036        let due = self.frame.is_multiple_of(1024)
3037            || (cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG")
3038                && self.frame.is_multiple_of(120));
3039        if due && self.window_rebuilds + self.window_executes > 0 {
3040            log::warn!(
3041                "[retained-bundles] {} stretches, {} rebuilds, {} cached executes ({} live bundles)",
3042                self.window_rebuilds + self.window_executes,
3043                self.window_rebuilds,
3044                self.window_executes,
3045                self.entries.len(),
3046            );
3047            self.window_rebuilds = 0;
3048            self.window_executes = 0;
3049        }
3050    }
3051
3052    /// Lifetime (rebuilds, cached executes) for tests and diagnostics.
3053    fn stats(&self) -> (u64, u64) {
3054        (self.rebuilds, self.cached_executes)
3055    }
3056}
3057
3058struct CachedImageTexture {
3059    _texture: wgpu::Texture,
3060    _view: wgpu::TextureView,
3061    nearest_bind_group: wgpu::BindGroup,
3062    linear_bind_group: wgpu::BindGroup,
3063    /// GPU bytes this entry pins (w×h×4): the cache is bounded by BYTES as
3064    /// well as count. A live camera publishes a new multi-MB bitmap id every
3065    /// frame; 256 count-slots of those is ~1.5GB of dead preview textures —
3066    /// which on iOS unified memory counts straight against the process's
3067    /// jetsam limit (measured: the app died mid-scan under an open camera
3068    /// with exactly that ballast).
3069    bytes: usize,
3070}
3071
3072impl CachedImageTexture {
3073    fn bind_group(&self, sampling: ImageSampling) -> &wgpu::BindGroup {
3074        match sampling {
3075            ImageSampling::Nearest => &self.nearest_bind_group,
3076            ImageSampling::Linear => &self.linear_bind_group,
3077        }
3078    }
3079}
3080
3081#[derive(Clone, Copy)]
3082struct GlyphAtlasEntry {
3083    x: u32,
3084    y: u32,
3085    width: u32,
3086    height: u32,
3087}
3088
3089/// Side length the glyph atlas should be rebuilt at after it overflowed at
3090/// `current`: one doubling, never past `max`.
3091///
3092/// Doubling (rather than jumping straight to `max`) is what makes the atlas
3093/// cost track the workload: an app that overflows once needs a little more
3094/// room, not sixteen times more.
3095fn next_glyph_atlas_size(current: u32, max: u32) -> u32 {
3096    current.saturating_mul(2).clamp(1, max.max(1))
3097}
3098
3099struct TextGlyphAtlas {
3100    texture: wgpu::Texture,
3101    _view: wgpu::TextureView,
3102    bind_group: wgpu::BindGroup,
3103    entries: BoundedLruCache<SoftwareGlyphAtlasKey, GlyphAtlasEntry>,
3104    generation: u64,
3105    /// Side length of `texture`, between `TEXT_GLYPH_ATLAS_MIN_SIZE` and the
3106    /// device's ceiling. Every UV is normalised against it, so it has to travel
3107    /// with the atlas rather than be read back off a constant.
3108    size: u32,
3109    /// Largest side length this atlas may grow to: the smaller of
3110    /// `TEXT_GLYPH_ATLAS_MAX_SIZE` and what the device grants. Mobile devices
3111    /// are requested `downlevel_defaults()` limits raised by `using_resolution`,
3112    /// so a device that only offers 2048 would otherwise fail to create the
3113    /// texture outright.
3114    max_size: u32,
3115    cursor_x: u32,
3116    cursor_y: u32,
3117    row_height: u32,
3118    upload_scratch: Vec<u8>,
3119}
3120
3121impl TextGlyphAtlas {
3122    fn new(
3123        device: &wgpu::Device,
3124        image_layout: &wgpu::BindGroupLayout,
3125        sampler: &wgpu::Sampler,
3126        size: u32,
3127    ) -> Self {
3128        let max_size = TEXT_GLYPH_ATLAS_MAX_SIZE.min(device.limits().max_texture_dimension_2d);
3129        let size = size.clamp(TEXT_GLYPH_ATLAS_MIN_SIZE.min(max_size), max_size);
3130        let texture = Self::create_texture(device, size);
3131        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3132        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3133            label: Some("Text Glyph Atlas Bind Group"),
3134            layout: image_layout,
3135            entries: &[
3136                wgpu::BindGroupEntry {
3137                    binding: 0,
3138                    resource: wgpu::BindingResource::TextureView(&view),
3139                },
3140                wgpu::BindGroupEntry {
3141                    binding: 1,
3142                    resource: wgpu::BindingResource::Sampler(sampler),
3143                },
3144            ],
3145        });
3146        Self {
3147            texture,
3148            _view: view,
3149            bind_group,
3150            entries: BoundedLruCache::with_capacity_at_least_one(MAX_TEXT_GLYPH_ATLAS_ITEMS),
3151            generation: 0,
3152            size,
3153            max_size,
3154            cursor_x: TEXT_GLYPH_ATLAS_PADDING,
3155            cursor_y: TEXT_GLYPH_ATLAS_PADDING,
3156            row_height: 0,
3157            upload_scratch: Vec::new(),
3158        }
3159    }
3160
3161    fn create_texture(device: &wgpu::Device, size: u32) -> wgpu::Texture {
3162        device.create_texture(&wgpu::TextureDescriptor {
3163            label: Some("Text Glyph Atlas Texture"),
3164            size: wgpu::Extent3d {
3165                width: size,
3166                height: size,
3167                depth_or_array_layers: 1,
3168            },
3169            mip_level_count: 1,
3170            sample_count: 1,
3171            dimension: wgpu::TextureDimension::D2,
3172            format: wgpu::TextureFormat::R8Unorm,
3173            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3174            view_formats: &[],
3175        })
3176    }
3177
3178    /// Throws every cached glyph away and starts over on a texture one doubling
3179    /// larger, up to [`TextGlyphAtlas::max_size`].
3180    ///
3181    /// `allocate` is a one-way shelf cursor with no compaction, so the only
3182    /// recovery from a full atlas is to start again — and starting again at the
3183    /// same size makes a workload whose live glyph set genuinely does not fit
3184    /// re-raster every glyph every frame. Treating each overflow as the signal
3185    /// to double means the atlas converges on the size the workload actually
3186    /// needs: a text-heavy screen reaches the old fixed 4096 after at most three
3187    /// resets and behaves identically from then on, while a watch face that
3188    /// never overflows never pays for space it will not use.
3189    ///
3190    /// Bumping the generation is what invalidates the cached glyph runs, whose
3191    /// UVs are normalised against the previous size and would otherwise sample
3192    /// the wrong part of the new texture.
3193    fn reset(
3194        &mut self,
3195        device: &wgpu::Device,
3196        image_layout: &wgpu::BindGroupLayout,
3197        sampler: &wgpu::Sampler,
3198    ) {
3199        let generation = self.generation.wrapping_add(1);
3200        let grown = next_glyph_atlas_size(self.size, self.max_size);
3201        let mut next = Self::new(device, image_layout, sampler, grown);
3202        next.generation = generation;
3203        *self = next;
3204    }
3205
3206    fn generation(&self) -> u64 {
3207        self.generation
3208    }
3209
3210    fn size(&self) -> u32 {
3211        self.size
3212    }
3213
3214    fn entry(&mut self, key: &SoftwareGlyphAtlasKey) -> Option<GlyphAtlasEntry> {
3215        self.entries.get(key).copied()
3216    }
3217
3218    fn allocate(&mut self, width: u32, height: u32) -> Option<GlyphAtlasEntry> {
3219        if width == 0
3220            || height == 0
3221            || width + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
3222            || height + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
3223        {
3224            return None;
3225        }
3226
3227        if self.cursor_x + width + TEXT_GLYPH_ATLAS_PADDING > self.size {
3228            self.cursor_x = TEXT_GLYPH_ATLAS_PADDING;
3229            self.cursor_y = self
3230                .cursor_y
3231                .saturating_add(self.row_height)
3232                .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
3233            self.row_height = 0;
3234        }
3235        if self.cursor_y + height + TEXT_GLYPH_ATLAS_PADDING > self.size {
3236            return None;
3237        }
3238
3239        let entry = GlyphAtlasEntry {
3240            x: self.cursor_x,
3241            y: self.cursor_y,
3242            width,
3243            height,
3244        };
3245        self.cursor_x = self
3246            .cursor_x
3247            .saturating_add(width)
3248            .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
3249        self.row_height = self.row_height.max(height);
3250        Some(entry)
3251    }
3252
3253    fn upload_glyph(
3254        &mut self,
3255        key: SoftwareGlyphAtlasKey,
3256        glyph: &SoftwareGlyphAtlasGlyph,
3257        queue: &wgpu::Queue,
3258        executor: &mut WgpuFrameGraphExecutor,
3259        frame_stats: &mut gpu_stats::FrameStats,
3260    ) -> Option<GlyphAtlasEntry> {
3261        if let Some(entry) = self.entry(&key) {
3262            frame_stats.record_text_glyph_atlas_hit();
3263            return Some(entry);
3264        }
3265
3266        let width = u32::try_from(glyph.mask.width).ok()?;
3267        let height = u32::try_from(glyph.mask.height).ok()?;
3268        let entry = self.allocate(width, height)?;
3269        self.upload_scratch.clear();
3270        self.upload_scratch.reserve(
3271            glyph
3272                .mask
3273                .alpha
3274                .len()
3275                .saturating_sub(self.upload_scratch.capacity()),
3276        );
3277        self.upload_scratch.extend(
3278            glyph
3279                .mask
3280                .alpha
3281                .iter()
3282                .map(|alpha| (alpha.clamp(0.0, 1.0) * 255.0).round() as u8),
3283        );
3284
3285        let upload_stats = executor.upload_texture(
3286            queue,
3287            wgpu::TexelCopyTextureInfo {
3288                texture: &self.texture,
3289                mip_level: 0,
3290                origin: wgpu::Origin3d {
3291                    x: entry.x,
3292                    y: entry.y,
3293                    z: 0,
3294                },
3295                aspect: wgpu::TextureAspect::All,
3296            },
3297            &self.upload_scratch,
3298            wgpu::TexelCopyBufferLayout {
3299                offset: 0,
3300                bytes_per_row: Some(entry.width),
3301                rows_per_image: Some(entry.height),
3302            },
3303            wgpu::Extent3d {
3304                width: entry.width,
3305                height: entry.height,
3306                depth_or_array_layers: 1,
3307            },
3308        );
3309        frame_stats.record_command_stats(upload_stats);
3310        frame_stats.record_text_glyph_atlas_miss(entry.width, entry.height);
3311        self.entries.put(key, entry);
3312        Some(entry)
3313    }
3314}
3315
3316struct ImageDrawCmd {
3317    index_start: u32,
3318    scissor: (u32, u32, u32, u32),
3319    image_id: u64,
3320    sampling: ImageSampling,
3321}
3322
3323#[derive(Clone, Copy)]
3324enum GlyphDrawSource {
3325    Shared {
3326        index_start: u32,
3327        index_count: u32,
3328    },
3329    #[cfg(not(target_arch = "wasm32"))]
3330    Retained {
3331        cache_key: TextGlyphRunCacheKey,
3332        uniform_slot: usize,
3333    },
3334}
3335
3336#[derive(Clone, Copy)]
3337struct GlyphDrawCmd {
3338    source: GlyphDrawSource,
3339    scissor: (u32, u32, u32, u32),
3340}
3341
3342impl GlyphDrawCmd {
3343    fn shared(index_start: u32, index_count: u32, scissor: (u32, u32, u32, u32)) -> Self {
3344        Self {
3345            source: GlyphDrawSource::Shared {
3346                index_start,
3347                index_count,
3348            },
3349            scissor,
3350        }
3351    }
3352
3353    #[cfg(not(target_arch = "wasm32"))]
3354    fn retained(
3355        cache_key: TextGlyphRunCacheKey,
3356        uniform_slot: usize,
3357        scissor: (u32, u32, u32, u32),
3358    ) -> Self {
3359        Self {
3360            source: GlyphDrawSource::Retained {
3361                cache_key,
3362                uniform_slot,
3363            },
3364            scissor,
3365        }
3366    }
3367}
3368
3369#[derive(Clone, Copy, Debug, PartialEq)]
3370struct ImageUvRect {
3371    min: [f32; 2],
3372    max: [f32; 2],
3373    sample_bounds: [f32; 4],
3374}
3375
3376// Text raster cache is owned by GpuRenderer and backed by software text images
3377// between measurement and rendering to eliminate duplicate text shaping
3378
3379/// Persistent GPU buffers for batched shape rendering. There is no vertex or
3380/// index buffer: the shape shader pulls quad corners straight out of
3381/// `ShapeData` by `vertex_index`, so the batch is drawn unindexed.
3382struct ShapeBatchBuffers {
3383    shape_buffer: wgpu::Buffer,
3384    gradient_buffer: wgpu::Buffer,
3385    bind_group: wgpu::BindGroup,
3386    shape_capacity: usize,
3387    gradient_capacity: usize,
3388    batch_limits: ShapeBatchLimits,
3389}
3390
3391#[cfg(target_arch = "wasm32")]
3392struct UniformBatchBuffer {
3393    buffer: wgpu::Buffer,
3394    bind_group: wgpu::BindGroup,
3395}
3396
3397#[cfg(target_arch = "wasm32")]
3398struct ImageBatchBuffers {
3399    vertex_buffer: wgpu::Buffer,
3400    index_buffer: wgpu::Buffer,
3401    vertex_capacity: usize,
3402    index_capacity: usize,
3403}
3404
3405#[derive(Clone, Copy, Debug, PartialEq)]
3406struct ViewportUniformParams {
3407    width: u32,
3408    height: u32,
3409    offset: [f32; 2],
3410}
3411
3412#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3413#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
3414enum UploadTarget {
3415    Uniform,
3416    ShapeData,
3417    ShapeGradient,
3418    ImageVertex,
3419    ImageIndex,
3420    #[cfg(not(target_arch = "wasm32"))]
3421    RetainedGlyphUniform,
3422    /// The shared replay-transform buffer; copies land at each slot's fixed
3423    /// 256-byte-aligned offset.
3424    #[cfg(not(target_arch = "wasm32"))]
3425    ReplayTransform,
3426    /// A replay slot's retained paint buffer (color patches land here).
3427    #[cfg(not(target_arch = "wasm32"))]
3428    ReplayPaintData(u32),
3429}
3430
3431#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3432#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
3433struct PendingBufferCopy {
3434    source_offset: u64,
3435    target_offset: u64,
3436    size: u64,
3437    target: UploadTarget,
3438}
3439
3440#[derive(Default)]
3441struct StagedBufferUploads {
3442    bytes: Vec<u8>,
3443    copies: Vec<PendingBufferCopy>,
3444}
3445
3446impl StagedBufferUploads {
3447    fn clear(&mut self) {
3448        self.bytes.clear();
3449        self.copies.clear();
3450    }
3451
3452    fn shrink_retained_capacity(&mut self, max_bytes: usize, max_copies: usize) -> bool {
3453        let mut shrunk = false;
3454        if self.bytes.len() <= max_bytes && self.bytes.capacity() > max_bytes {
3455            self.bytes.shrink_to(max_bytes);
3456            shrunk = true;
3457        }
3458        if self.copies.len() <= max_copies && self.copies.capacity() > max_copies {
3459            self.copies.shrink_to(max_copies);
3460            shrunk = true;
3461        }
3462        shrunk
3463    }
3464
3465    fn is_empty(&self) -> bool {
3466        self.copies.is_empty()
3467    }
3468
3469    #[cfg(test)]
3470    fn payload_for_copy(&self, copy: PendingBufferCopy) -> &[u8] {
3471        let start = copy.source_offset as usize;
3472        let end = start + copy.size as usize;
3473        &self.bytes[start..end]
3474    }
3475
3476    #[cfg(not(target_arch = "wasm32"))]
3477    fn stage(&mut self, target: UploadTarget, bytes: &[u8]) {
3478        self.stage_at(target, 0, bytes);
3479    }
3480
3481    /// Records a GPU copy whose source bytes were already written into the
3482    /// frame upload buffer (via `Queue::write_buffer_with`), so nothing is
3483    /// appended to `bytes`. `source_offset` is relative to the same base the
3484    /// caller later passes to `flush_staged_uploads_at`.
3485    #[cfg(not(target_arch = "wasm32"))]
3486    fn record_upload_copy(
3487        &mut self,
3488        target: UploadTarget,
3489        source_offset: u64,
3490        target_offset: u64,
3491        size: u64,
3492    ) {
3493        if size == 0 {
3494            return;
3495        }
3496        self.copies.push(PendingBufferCopy {
3497            source_offset,
3498            target_offset,
3499            size,
3500            target,
3501        });
3502    }
3503
3504    #[cfg(not(target_arch = "wasm32"))]
3505    fn stage_at(&mut self, target: UploadTarget, target_offset: u64, bytes: &[u8]) {
3506        if bytes.is_empty() {
3507            return;
3508        }
3509
3510        debug_assert_eq!(
3511            bytes.len() % wgpu::COPY_BUFFER_ALIGNMENT as usize,
3512            0,
3513            "buffer uploads must be aligned to copy requirements"
3514        );
3515
3516        let aligned_offset = align_usize_to(self.bytes.len(), wgpu::COPY_BUFFER_ALIGNMENT as usize);
3517        if aligned_offset > self.bytes.len() {
3518            self.bytes.resize(aligned_offset, 0);
3519        }
3520
3521        let source_offset = self.bytes.len() as u64;
3522        self.bytes.extend_from_slice(bytes);
3523        self.copies.push(PendingBufferCopy {
3524            source_offset,
3525            target_offset,
3526            size: bytes.len() as u64,
3527            target,
3528        });
3529    }
3530
3531    fn truncate(&mut self, bytes_len: usize, copies_len: usize) {
3532        self.bytes.truncate(bytes_len);
3533        self.copies.truncate(copies_len);
3534    }
3535}
3536
3537/// The fresh-batch entry list for the shape bind group layout: the batch's
3538/// own data buffers, the shared identity similarity buffer, and — storage
3539/// mode only, where the layout carries the paint entry — the renderer-wide
3540/// dummy paint buffer (fresh draws leave `paint_select` at 0.0).
3541fn shape_batch_bind_group_entries<'a>(
3542    shape_buffer: &'a wgpu::Buffer,
3543    gradient_buffer: &'a wgpu::Buffer,
3544    similarity_buffer: &'a wgpu::Buffer,
3545    paint_buffer: Option<&'a wgpu::Buffer>,
3546) -> Vec<wgpu::BindGroupEntry<'a>> {
3547    let mut entries = vec![
3548        wgpu::BindGroupEntry {
3549            binding: 0,
3550            resource: shape_buffer.as_entire_binding(),
3551        },
3552        wgpu::BindGroupEntry {
3553            binding: 1,
3554            resource: gradient_buffer.as_entire_binding(),
3555        },
3556        wgpu::BindGroupEntry {
3557            binding: 2,
3558            resource: similarity_buffer.as_entire_binding(),
3559        },
3560    ];
3561    if let Some(paint_buffer) = paint_buffer {
3562        entries.push(wgpu::BindGroupEntry {
3563            binding: 3,
3564            resource: paint_buffer.as_entire_binding(),
3565        });
3566    }
3567    entries
3568}
3569
3570impl ShapeBatchBuffers {
3571    fn new(
3572        device: &wgpu::Device,
3573        bind_group_layout: &wgpu::BindGroupLayout,
3574        similarity_buffer: &wgpu::Buffer,
3575        paint_buffer: Option<&wgpu::Buffer>,
3576        batch_limits: ShapeBatchLimits,
3577    ) -> Self {
3578        debug_assert_eq!(
3579            paint_buffer.is_some(),
3580            batch_limits.storage,
3581            "the paint binding exists exactly when the layout is in storage mode"
3582        );
3583        let initial_shape_cap = batch_limits.initial_shape_capacity();
3584        let initial_gradient_cap = batch_limits.initial_gradient_capacity();
3585
3586        let shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3587            label: Some("Shape Data Buffer"),
3588            size: (std::mem::size_of::<ShapeData>() * initial_shape_cap) as u64,
3589            usage: batch_limits.data_buffer_usage(),
3590            mapped_at_creation: false,
3591        });
3592
3593        let gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3594            label: Some("Gradient Buffer"),
3595            size: (std::mem::size_of::<GradientStop>() * initial_gradient_cap) as u64,
3596            usage: batch_limits.data_buffer_usage(),
3597            mapped_at_creation: false,
3598        });
3599
3600        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3601            label: Some("Shape Bind Group"),
3602            layout: bind_group_layout,
3603            entries: &shape_batch_bind_group_entries(
3604                &shape_buffer,
3605                &gradient_buffer,
3606                similarity_buffer,
3607                paint_buffer,
3608            ),
3609        });
3610
3611        Self {
3612            shape_buffer,
3613            gradient_buffer,
3614            bind_group,
3615            shape_capacity: initial_shape_cap,
3616            gradient_capacity: initial_gradient_cap,
3617            batch_limits,
3618        }
3619    }
3620
3621    /// Ensure buffers have enough capacity, resizing if needed.
3622    /// Clamps growth to prevent excessive allocations for huge scenes.
3623    fn ensure_capacity(
3624        &mut self,
3625        device: &wgpu::Device,
3626        bind_group_layout: &wgpu::BindGroupLayout,
3627        similarity_buffer: &wgpu::Buffer,
3628        paint_buffer: Option<&wgpu::Buffer>,
3629        shapes_needed: usize,
3630        gradients_needed: usize,
3631    ) {
3632        let mut need_bind_group_update = false;
3633
3634        // In uniform mode the shape and gradient buffers start at the cap
3635        // (the shader's fixed-size array length) so these never fire; in
3636        // storage mode they double toward the cap as scenes demand.
3637        if shapes_needed > self.shape_capacity
3638            && self.shape_capacity < self.batch_limits.max_shapes_per_batch
3639        {
3640            let new_cap = shapes_needed
3641                .next_power_of_two()
3642                .min(self.batch_limits.max_shapes_per_batch);
3643            self.shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3644                label: Some("Shape Data Buffer"),
3645                size: (std::mem::size_of::<ShapeData>() * new_cap) as u64,
3646                usage: self.batch_limits.data_buffer_usage(),
3647                mapped_at_creation: false,
3648            });
3649            self.shape_capacity = new_cap;
3650            need_bind_group_update = true;
3651        }
3652
3653        if gradients_needed > self.gradient_capacity
3654            && self.gradient_capacity < self.batch_limits.max_gradient_stops
3655        {
3656            let new_cap = gradients_needed
3657                .max(1)
3658                .next_power_of_two()
3659                .min(self.batch_limits.max_gradient_stops);
3660            self.gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3661                label: Some("Gradient Buffer"),
3662                size: (std::mem::size_of::<GradientStop>() * new_cap) as u64,
3663                usage: self.batch_limits.data_buffer_usage(),
3664                mapped_at_creation: false,
3665            });
3666            self.gradient_capacity = new_cap;
3667            need_bind_group_update = true;
3668        }
3669
3670        if need_bind_group_update {
3671            self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3672                label: Some("Shape Bind Group"),
3673                layout: bind_group_layout,
3674                entries: &shape_batch_bind_group_entries(
3675                    &self.shape_buffer,
3676                    &self.gradient_buffer,
3677                    similarity_buffer,
3678                    paint_buffer,
3679                ),
3680            });
3681        }
3682    }
3683}
3684
3685#[cfg(target_arch = "wasm32")]
3686impl UniformBatchBuffer {
3687    fn new(device: &wgpu::Device, bind_group_layout: &wgpu::BindGroupLayout) -> Self {
3688        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
3689            label: Some("Viewport Uniform Batch Buffer"),
3690            size: std::mem::size_of::<Uniforms>() as u64,
3691            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3692            mapped_at_creation: false,
3693        });
3694        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3695            label: Some("Viewport Uniform Batch Bind Group"),
3696            layout: bind_group_layout,
3697            entries: &[wgpu::BindGroupEntry {
3698                binding: 0,
3699                resource: buffer.as_entire_binding(),
3700            }],
3701        });
3702        Self { buffer, bind_group }
3703    }
3704}
3705
3706#[cfg(target_arch = "wasm32")]
3707impl ImageBatchBuffers {
3708    fn new(device: &wgpu::Device) -> Self {
3709        let vertex_capacity = 4;
3710        let index_capacity = 6;
3711        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3712            label: Some("Image Vertex Batch Buffer"),
3713            size: (std::mem::size_of::<Vertex>() * vertex_capacity) as u64,
3714            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
3715            mapped_at_creation: false,
3716        });
3717        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3718            label: Some("Image Index Batch Buffer"),
3719            size: (std::mem::size_of::<u32>() * index_capacity) as u64,
3720            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
3721            mapped_at_creation: false,
3722        });
3723        Self {
3724            vertex_buffer,
3725            index_buffer,
3726            vertex_capacity,
3727            index_capacity,
3728        }
3729    }
3730
3731    fn ensure_capacity(
3732        &mut self,
3733        device: &wgpu::Device,
3734        vertices_needed: usize,
3735        indices_needed: usize,
3736    ) {
3737        let hard_max_bytes = HARD_MAX_BUFFER_MB * 1024 * 1024;
3738        if vertices_needed > self.vertex_capacity {
3739            let desired = vertices_needed.next_power_of_two();
3740            let max_count = hard_max_bytes / std::mem::size_of::<Vertex>();
3741            let new_cap = desired.min(max_count);
3742            self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3743                label: Some("Image Vertex Batch Buffer"),
3744                size: (std::mem::size_of::<Vertex>() * new_cap) as u64,
3745                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
3746                mapped_at_creation: false,
3747            });
3748            self.vertex_capacity = new_cap;
3749        }
3750        if indices_needed > self.index_capacity {
3751            let desired = indices_needed.next_power_of_two();
3752            let max_count = hard_max_bytes / std::mem::size_of::<u32>();
3753            let new_cap = desired.min(max_count);
3754            self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3755                label: Some("Image Index Batch Buffer"),
3756                size: (std::mem::size_of::<u32>() * new_cap) as u64,
3757                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
3758                mapped_at_creation: false,
3759            });
3760            self.index_capacity = new_cap;
3761        }
3762    }
3763}
3764
3765// Text image cache keys are local to rasterized WGPU text batches
3766
3767pub struct GpuRenderer {
3768    pub(crate) device: Arc<wgpu::Device>,
3769    pub(crate) queue: Arc<wgpu::Queue>,
3770    /// This instance's renderer epoch, stamped by `init_gpu` at
3771    /// construction. A packet whose `renderer_epoch` differs was built
3772    /// against another instance and is cancelled at the head of
3773    /// [`Self::render`], never drawn.
3774    renderer_epoch: u64,
3775    /// The producer feed generation this store's slot universe belongs to:
3776    /// seeded at construction, advanced by `consume_replay_ops` when a
3777    /// higher-generation batch arrives (the batch itself carries the
3778    /// retirement releases). The store never reads the producer's
3779    /// thread-local — this field is its only generation authority.
3780    #[cfg(not(target_arch = "wasm32"))]
3781    store_feed_generation: u64,
3782    surface_format: wgpu::TextureFormat,
3783    adapter_backend: wgpu::Backend,
3784    shape_batch_limits: ShapeBatchLimits,
3785    pipeline: LazyGpuResource<wgpu::RenderPipeline>,
3786    pipeline_dst_out: LazyGpuResource<wgpu::RenderPipeline>,
3787    /// `fs_solid` twin of `pipeline` (SrcOver only), for gradient-free draws.
3788    pipeline_solid: LazyGpuResource<wgpu::RenderPipeline>,
3789    /// `Some` exactly in storage mode: the retained-mesh pipeline (`vs_mesh`
3790    /// over a vertex buffer) that replay slots with a captured arc mesh draw
3791    /// through. Uniform-mode devices never host retained slots.
3792    #[cfg(not(target_arch = "wasm32"))]
3793    mesh_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
3794    /// `Some` exactly when this renderer latched the instanced-quad path at
3795    /// construction (storage mode && `CRANPOSE_INSTANCED_QUADS` != 0). Read
3796    /// ONCE per renderer lifetime — cached retained bundles encode the
3797    /// selection, so it must never move under them (see
3798    /// [`instanced_quads_enabled`]).
3799    #[cfg(not(target_arch = "wasm32"))]
3800    instanced_quads: Option<InstancedQuadPipelines>,
3801    uniform_bind_group_layout: wgpu::BindGroupLayout,
3802    shape_bind_group_layout: wgpu::BindGroupLayout,
3803    /// `Some` exactly in storage mode: the 16-byte stand-in every fresh
3804    /// batch binds at the paint entry (see `shape_batch_bind_group_entries`).
3805    dummy_paint_buffer: Option<wgpu::Buffer>,
3806    /// Shared identity binding for `@group(1) @binding(2)`: every freshly
3807    /// converted shape batch draws untransformed through this one buffer.
3808    identity_similarity_buffer: wgpu::Buffer,
3809    #[cfg(not(target_arch = "wasm32"))]
3810    replay_slots: ReplaySlotStore,
3811    image_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
3812    image_pipeline_dst_out: LazyGpuResource<wgpu::RenderPipeline>,
3813    glyph_atlas_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
3814    #[cfg(not(target_arch = "wasm32"))]
3815    retained_glyph_atlas_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
3816    image_bind_group_layout: wgpu::BindGroupLayout,
3817    #[cfg(not(target_arch = "wasm32"))]
3818    retained_glyph_uniform_bind_group_layout: wgpu::BindGroupLayout,
3819    image_nearest_sampler: wgpu::Sampler,
3820    image_linear_sampler: wgpu::Sampler,
3821    text_fonts: SoftwareTextFontSet,
3822    // Persistent GPU buffers (reused across frames)
3823    #[cfg(not(target_arch = "wasm32"))]
3824    upload_buffer: wgpu::Buffer,
3825    #[cfg(not(target_arch = "wasm32"))]
3826    uniform_buffer: wgpu::Buffer,
3827    #[cfg(not(target_arch = "wasm32"))]
3828    uniform_bind_group: wgpu::BindGroup,
3829    #[cfg(not(target_arch = "wasm32"))]
3830    shape_buffers: ShapeBatchBuffers,
3831    #[cfg(not(target_arch = "wasm32"))]
3832    image_vertex_buffer: wgpu::Buffer,
3833    #[cfg(not(target_arch = "wasm32"))]
3834    image_index_buffer: wgpu::Buffer,
3835    #[cfg(not(target_arch = "wasm32"))]
3836    retained_glyph_uniform_buffer: wgpu::Buffer,
3837    #[cfg(not(target_arch = "wasm32"))]
3838    retained_glyph_uniform_bind_group: wgpu::BindGroup,
3839    #[cfg(not(target_arch = "wasm32"))]
3840    retained_glyph_uniform_stride: u64,
3841    #[cfg(not(target_arch = "wasm32"))]
3842    retained_glyph_uniform_capacity: usize,
3843    #[cfg(not(target_arch = "wasm32"))]
3844    retained_glyph_uniform_cursor: usize,
3845    #[cfg(target_arch = "wasm32")]
3846    wasm_uniform_batches: Vec<UniformBatchBuffer>,
3847    #[cfg(target_arch = "wasm32")]
3848    wasm_uniform_batch_cursor: usize,
3849    #[cfg(target_arch = "wasm32")]
3850    wasm_shape_batches: Vec<ShapeBatchBuffers>,
3851    #[cfg(target_arch = "wasm32")]
3852    wasm_shape_batch_cursor: usize,
3853    #[cfg(target_arch = "wasm32")]
3854    wasm_image_batches: Vec<ImageBatchBuffers>,
3855    #[cfg(target_arch = "wasm32")]
3856    wasm_image_batch_cursor: usize,
3857    image_texture_cache: BoundedLruCache<u64, CachedImageTexture>,
3858    /// Total `CachedImageTexture::bytes` currently in the cache.
3859    image_texture_cache_bytes: usize,
3860    text_image_cache: BoundedLruCache<TextImageCacheKey, CachedTextImage>,
3861    text_glyph_atlas: TextGlyphAtlas,
3862    text_glyph_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedTextGlyphRun>,
3863    #[cfg(not(target_arch = "wasm32"))]
3864    text_glyph_gpu_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedGpuTextGlyphRun>,
3865    text_glyph_mask_cache: SoftwareGlyphRasterCache,
3866    text_line_index_cache: TextLineIndexCache,
3867    scratch_shape_data: Vec<ShapeData>,
3868    scratch_gradients: Vec<GradientStop>,
3869    scratch_image_vertices: Vec<Vertex>,
3870    scratch_image_indices: Vec<u32>,
3871    scratch_image_cmds: Vec<ImageDrawCmd>,
3872    scratch_glyph_cmds: Vec<GlyphDrawCmd>,
3873    scratch_text_glyph_run: Vec<SoftwareGlyphAtlasRunGlyph>,
3874    scratch_text_glyph_placements: Vec<SoftwareGlyphAtlasPlacement>,
3875    scratch_text_glyph_quads: Vec<CachedTextGlyphQuad>,
3876    scratch_segment_items: Vec<(usize, SegmentDrawItem)>,
3877    scratch_effect_ranges: Vec<Range<usize>>,
3878    scratch_layer_events: Vec<LayerEvent>,
3879    staged_uploads: StagedBufferUploads,
3880    frame_graph_executor: WgpuFrameGraphExecutor,
3881    deferred_offscreen_releases: Vec<OffscreenTarget>,
3882    effect_renderer: EffectRenderer,
3883    layer_surface_cache: LayerSurfaceCache,
3884    observed_scene_range_cache_misses: BoundedLruCache<LayerRasterCacheKey, ()>,
3885    shadow_surface_cache: BoundedLruCache<ShadowSurfaceCacheKey, CachedShadowSurface>,
3886    shadow_surface_cache_bytes: u64,
3887    frame_stats: gpu_stats::FrameStats,
3888    last_frame_stats: Option<gpu_stats::FrameStatsSnapshot>,
3889    pending_frame_warmup_frames: u8,
3890    frame_count: u64,
3891    gpu_stats_enabled: bool,
3892    warning_state: RendererWarningState,
3893    #[cfg(not(target_arch = "wasm32"))]
3894    replay_upload_stats: ReplayUploadStats,
3895    /// The frame's replay recolor patches, parked here by
3896    /// `consume_replay_ops` until the retained prepare arms drain them
3897    /// (`stage_replay_patches`). The vec this frame's ops displace is last
3898    /// frame's, already drained empty, and returns to the producer with
3899    /// the ack — capacity ping-pongs planner queue → packet ops → here →
3900    /// ack return, so neither side allocates per frame (P4b).
3901    #[cfg(not(target_arch = "wasm32"))]
3902    replay_color_patches: Vec<crate::scene::ColorPatch>,
3903    /// Drain arena for `replay_color_patches`: `stage_replay_patches`
3904    /// swaps against this instead of `mem::take`, so both keep their
3905    /// high-water capacity across frames. Always empty between drains.
3906    #[cfg(not(target_arch = "wasm32"))]
3907    color_patch_scratch: Vec<crate::scene::ColorPatch>,
3908    /// Recycled confirmations buffer for the next [`crate::frame_packet::ReplayAck`]:
3909    /// `consume_replay_ops` fills it, the planner drains it in `apply_ack`,
3910    /// and the render loop hands the emptied vec (capacity intact) back
3911    /// here — the ack channel's half of the P4b no-allocation contract.
3912    #[cfg(not(target_arch = "wasm32"))]
3913    replay_ack_confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
3914    /// Lifetime count of replay-ops batches dropped whole by the
3915    /// generation check in `consume_replay_ops` — fail-closed against ops
3916    /// planned under a slot universe this store no longer holds.
3917    /// Synchronously impossible today; structural for the pipeline split.
3918    #[cfg(not(target_arch = "wasm32"))]
3919    replay_generation_drops: u64,
3920    /// Cached render bundles for maximal consecutive retained stretches in
3921    /// the fused segment pass (`CRANPOSE_RETAINED_BUNDLES` kill switch).
3922    #[cfg(not(target_arch = "wasm32"))]
3923    retained_bundle_cache: RetainedBundleCache,
3924}
3925
3926/// Running totals for retained-slot patch uploads, the paint-bandwidth
3927/// instrument: recolors upload 16-byte paint records (plus gradient stop
3928/// spans), coalesced per slot between the lowest and highest patched
3929/// index, so `bytes` versus `ideal_bytes` (patched colors alone) is just
3930/// the untouched records inside each coalesced span.
3931#[cfg(not(target_arch = "wasm32"))]
3932#[derive(Default)]
3933struct ReplayUploadStats {
3934    calls: u64,
3935    patched_calls: u64,
3936    patches: u64,
3937    slots: u64,
3938    records: u64,
3939    bytes: u64,
3940    ideal_bytes: u64,
3941    max_frame_bytes: u64,
3942}
3943
3944#[cfg(not(target_arch = "wasm32"))]
3945impl ReplayUploadStats {
3946    /// One aggregate line roughly every few seconds: cheap enough to stay
3947    /// on unconditionally, which matters because the watch cannot take
3948    /// setprop-backed diag flags — its logcat is the only channel, and a
3949    /// measurement window must catch several lines. Counts every drain
3950    /// call (the drain runs several times per frame; only the first sees
3951    /// patches) so a target with zero paint traffic still reports an
3952    /// affirmative zero instead of silence, while the averages divide by
3953    /// PATCHED calls so they read as per-frame numbers.
3954    /// warn level: the platform loggers filter info on desktop.
3955    const REPORT_CALLS: u64 = 1024;
3956
3957    fn note_frame(&mut self, patches: u64, slots: u64, records: u64, bytes: u64, ideal: u64) {
3958        self.calls += 1;
3959        if patches > 0 {
3960            self.patched_calls += 1;
3961            self.patches += patches;
3962            self.slots += slots;
3963            self.records += records;
3964            self.bytes += bytes;
3965            self.ideal_bytes += ideal;
3966            self.max_frame_bytes = self.max_frame_bytes.max(bytes);
3967        }
3968        if self.calls >= Self::REPORT_CALLS {
3969            let patched = self.patched_calls.max(1);
3970            log::warn!(
3971                "[replay-upload] {} patched of {} drains: avg {:.1} KB/frame (max {:.1} KB), \
3972                 color-only would be {:.1} KB/frame; avg {} patches over {} records in {} slots",
3973                self.patched_calls,
3974                self.calls,
3975                self.bytes as f64 / patched as f64 / 1024.0,
3976                self.max_frame_bytes as f64 / 1024.0,
3977                self.ideal_bytes as f64 / patched as f64 / 1024.0,
3978                self.patches / patched,
3979                self.records / patched,
3980                self.slots / patched,
3981            );
3982            *self = Self::default();
3983        }
3984    }
3985}
3986
3987fn image_sampler_descriptor(sampling: ImageSampling) -> wgpu::SamplerDescriptor<'static> {
3988    let filter = match sampling {
3989        ImageSampling::Nearest => wgpu::FilterMode::Nearest,
3990        ImageSampling::Linear => wgpu::FilterMode::Linear,
3991    };
3992    wgpu::SamplerDescriptor {
3993        label: Some(match sampling {
3994            ImageSampling::Nearest => "Nearest Image Sampler",
3995            ImageSampling::Linear => "Linear Image Sampler",
3996        }),
3997        address_mode_u: wgpu::AddressMode::ClampToEdge,
3998        address_mode_v: wgpu::AddressMode::ClampToEdge,
3999        address_mode_w: wgpu::AddressMode::ClampToEdge,
4000        mag_filter: filter,
4001        min_filter: filter,
4002        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
4003        ..Default::default()
4004    }
4005}
4006
4007#[cfg(test)]
4008fn layer_raster_cache_candidate(
4009    layer: &LayerNode,
4010    root_scale: f32,
4011    has_backdrop_underlay: bool,
4012    allow_runtime_cache: bool,
4013) -> Option<(LayerRasterCacheKey, Rect)> {
4014    let mut layer_surface_requirements_cache = HashMap::new();
4015    let surface_requirements =
4016        layer_surface_requirements_cached(layer, &mut layer_surface_requirements_cache);
4017    let runtime_cache_is_safe = allow_runtime_cache
4018        && surface_requirements
4019            .surface_requirements
4020            .has_isolating_requirement()
4021        && !surface_requirements.contains_runtime_shader;
4022    let cache_is_allowed = layer.cache_policy == CachePolicy::Auto
4023        || (allow_runtime_cache && surface_requirements.has_renderer_forced_surface())
4024        || runtime_cache_is_safe;
4025    if !cache_is_allowed {
4026        return None;
4027    }
4028    if layer_uses_external_backdrop_input(layer, has_backdrop_underlay) {
4029        return None;
4030    }
4031    // Not just this layer's own effect: a shader anywhere below it makes the
4032    // whole subtree change every frame with nothing in any hash to say so.
4033    if surface_requirements.contains_runtime_shader {
4034        return None;
4035    }
4036
4037    let logical_rect = estimate_layer_surface_rect(layer);
4038    let pixel_size = surface_target_size(logical_rect, root_scale, u32::MAX);
4039    Some((
4040        LayerRasterCacheKey::new(
4041            layer.node_id,
4042            layer.target_content_hash(),
4043            layer.effect_hash(),
4044            logical_rect,
4045            pixel_size,
4046            ScaleBucket::from_scale(root_scale),
4047        ),
4048        logical_rect,
4049    ))
4050}
4051
4052impl GpuRenderer {
4053    pub fn new(
4054        device: Arc<wgpu::Device>,
4055        queue: Arc<wgpu::Queue>,
4056        surface_format: wgpu::TextureFormat,
4057        adapter_backend: wgpu::Backend,
4058        text_fonts: SoftwareTextFontSet,
4059        renderer_epoch: u64,
4060        store_feed_generation: u64,
4061    ) -> Self {
4062        #[cfg(target_arch = "wasm32")]
4063        let _ = store_feed_generation;
4064        let shape_batch_limits = ShapeBatchLimits::for_device(&device);
4065        let uniform_bind_group_layout =
4066            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4067                label: Some("Uniform Bind Group Layout"),
4068                entries: &[wgpu::BindGroupLayoutEntry {
4069                    binding: 0,
4070                    visibility: wgpu::ShaderStages::VERTEX,
4071                    ty: wgpu::BindingType::Buffer {
4072                        ty: wgpu::BufferBindingType::Uniform,
4073                        has_dynamic_offset: false,
4074                        min_binding_size: None,
4075                    },
4076                    count: None,
4077                }],
4078            });
4079        #[cfg(not(target_arch = "wasm32"))]
4080        let retained_glyph_uniform_bind_group_layout =
4081            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4082                label: Some("Retained Glyph Dynamic Uniform Bind Group Layout"),
4083                entries: &[wgpu::BindGroupLayoutEntry {
4084                    binding: 0,
4085                    visibility: wgpu::ShaderStages::VERTEX,
4086                    ty: wgpu::BindingType::Buffer {
4087                        ty: wgpu::BufferBindingType::Uniform,
4088                        has_dynamic_offset: true,
4089                        min_binding_size: wgpu::BufferSize::new(
4090                            std::mem::size_of::<Uniforms>() as u64
4091                        ),
4092                    },
4093                    count: None,
4094                }],
4095            });
4096
4097        // Read-only storage bindings where the device has them (so a whole
4098        // scene fits one batch); uniform arrays on WebGL-class devices, which
4099        // have no storage buffers in fragment shaders. The shape array is
4100        // visible to the vertex stage as well: the pipeline has no vertex
4101        // buffer and `vs_main` pulls quad corners from ShapeData. (Storage
4102        // mode is gated on `max_storage_buffers_per_shader_stage`, which GL
4103        // backends report as the minimum across stages, so a device that
4104        // cannot read storage from the vertex stage falls back to uniforms.)
4105        let mut shape_bind_group_layout_entries = vec![
4106            wgpu::BindGroupLayoutEntry {
4107                binding: 0,
4108                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
4109                ty: wgpu::BindingType::Buffer {
4110                    ty: shape_batch_limits.data_binding_type(),
4111                    has_dynamic_offset: false,
4112                    min_binding_size: None,
4113                },
4114                count: None,
4115            },
4116            wgpu::BindGroupLayoutEntry {
4117                binding: 1,
4118                visibility: wgpu::ShaderStages::FRAGMENT,
4119                ty: wgpu::BindingType::Buffer {
4120                    ty: shape_batch_limits.data_binding_type(),
4121                    has_dynamic_offset: false,
4122                    min_binding_size: None,
4123                },
4124                count: None,
4125            },
4126            // The similarity transform rides a dynamic offset so
4127            // retained draws sharing one captured batch can each
4128            // apply their own transform; ordinary batches pass
4129            // offset 0 into the identity buffer.
4130            wgpu::BindGroupLayoutEntry {
4131                binding: 2,
4132                visibility: wgpu::ShaderStages::VERTEX,
4133                ty: wgpu::BindingType::Buffer {
4134                    ty: wgpu::BufferBindingType::Uniform,
4135                    has_dynamic_offset: true,
4136                    min_binding_size: wgpu::BufferSize::new(
4137                        std::mem::size_of::<SimilarityTransform>() as u64,
4138                    ),
4139                },
4140                count: None,
4141            },
4142        ];
4143        // Retained-slot paint colors, read by the vertex stage under
4144        // `paint_select` (see `shape_shader_source`). Storage mode only:
4145        // the uniform-variant shader never declares the array, and
4146        // uniform-mode devices never host retained slots, so their layout
4147        // stays exactly the three-entry one the uniform pipeline expects.
4148        if shape_batch_limits.storage {
4149            shape_bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry {
4150                binding: 3,
4151                visibility: wgpu::ShaderStages::VERTEX,
4152                ty: wgpu::BindingType::Buffer {
4153                    ty: wgpu::BufferBindingType::Storage { read_only: true },
4154                    has_dynamic_offset: false,
4155                    min_binding_size: None,
4156                },
4157                count: None,
4158            });
4159        }
4160        let shape_bind_group_layout =
4161            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4162                label: Some("Shape Bind Group Layout"),
4163                entries: &shape_bind_group_layout_entries,
4164            });
4165
4166        let identity_similarity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4167            label: Some("Identity Similarity Buffer"),
4168            size: std::mem::size_of::<SimilarityTransform>() as u64,
4169            usage: wgpu::BufferUsages::UNIFORM,
4170            mapped_at_creation: true,
4171        });
4172        identity_similarity_buffer
4173            .slice(..)
4174            .get_mapped_range_mut()
4175            .copy_from_slice(bytemuck::bytes_of(&SimilarityTransform::IDENTITY));
4176        identity_similarity_buffer.unmap();
4177
4178        // Fresh-batch bind groups need a resource at the paint binding even
4179        // though their draws leave `paint_select` at 0.0 and never use the
4180        // value; one minimal buffer (a single never-read vec4) serves every
4181        // batch. Uniform-mode layouts have no paint entry, so none exists.
4182        let dummy_paint_buffer = shape_batch_limits.storage.then(|| {
4183            device.create_buffer(&wgpu::BufferDescriptor {
4184                label: Some("Dummy Paint Buffer"),
4185                size: std::mem::size_of::<[f32; 4]>() as u64,
4186                usage: wgpu::BufferUsages::STORAGE,
4187                mapped_at_creation: false,
4188            })
4189        });
4190        #[cfg(not(target_arch = "wasm32"))]
4191        let replay_slot_store = ReplaySlotStore::new(&device);
4192
4193        let pipeline = LazyGpuResource::new("shape/src-over");
4194        let pipeline_dst_out = LazyGpuResource::new("shape/dst-out");
4195        let pipeline_solid = LazyGpuResource::new("shape/solid-src-over");
4196        #[cfg(not(target_arch = "wasm32"))]
4197        let mesh_pipeline = LazyGpuResource::new("shape/mesh");
4198        // The instanced-quad selection is LATCHED here, once per renderer:
4199        // cached retained bundles encode whichever pipelines this resolves
4200        // to, so a per-draw env read could let a bundle replay a selection
4201        // the direct path no longer makes. Storage mode only — the
4202        // uniform/WebGL path keeps `vs_main` and its plain draws untouched.
4203        #[cfg(not(target_arch = "wasm32"))]
4204        let instanced_quads =
4205            (shape_batch_limits.storage && instanced_quads_enabled()).then(|| {
4206                let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4207                    label: Some("Instanced Quad Index Buffer"),
4208                    size: std::mem::size_of_val(&INSTANCED_QUAD_INDICES) as u64,
4209                    usage: wgpu::BufferUsages::INDEX,
4210                    mapped_at_creation: true,
4211                });
4212                index_buffer
4213                    .slice(..)
4214                    .get_mapped_range_mut()
4215                    .copy_from_slice(bytemuck::cast_slice(&INSTANCED_QUAD_INDICES));
4216                index_buffer.unmap();
4217                InstancedQuadPipelines {
4218                    pipeline: LazyGpuResource::new("shape/instanced-src-over"),
4219                    pipeline_dst_out: LazyGpuResource::new("shape/instanced-dst-out"),
4220                    pipeline_solid: LazyGpuResource::new("shape/instanced-solid"),
4221                    index_buffer,
4222                }
4223            });
4224
4225        let image_bind_group_layout =
4226            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4227                label: Some("Image Texture Bind Group Layout"),
4228                entries: &[
4229                    wgpu::BindGroupLayoutEntry {
4230                        binding: 0,
4231                        visibility: wgpu::ShaderStages::FRAGMENT,
4232                        ty: wgpu::BindingType::Texture {
4233                            multisampled: false,
4234                            view_dimension: wgpu::TextureViewDimension::D2,
4235                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
4236                        },
4237                        count: None,
4238                    },
4239                    wgpu::BindGroupLayoutEntry {
4240                        binding: 1,
4241                        visibility: wgpu::ShaderStages::FRAGMENT,
4242                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
4243                        count: None,
4244                    },
4245                ],
4246            });
4247
4248        let image_pipeline = LazyGpuResource::new("image/src-over");
4249        let image_pipeline_dst_out = LazyGpuResource::new("image/dst-out");
4250        let glyph_atlas_pipeline = LazyGpuResource::new("glyph/shared");
4251        #[cfg(not(target_arch = "wasm32"))]
4252        let retained_glyph_atlas_pipeline = LazyGpuResource::new("glyph/retained");
4253
4254        #[cfg(not(target_arch = "wasm32"))]
4255        let upload_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4256            label: Some("Frame Upload Buffer"),
4257            size: INITIAL_UPLOAD_BUFFER_BYTES,
4258            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
4259            mapped_at_creation: false,
4260        });
4261
4262        #[cfg(not(target_arch = "wasm32"))]
4263        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4264            label: Some("Uniform Buffer"),
4265            size: std::mem::size_of::<Uniforms>() as u64,
4266            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4267            mapped_at_creation: false,
4268        });
4269
4270        #[cfg(not(target_arch = "wasm32"))]
4271        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
4272            label: Some("Uniform Bind Group"),
4273            layout: &uniform_bind_group_layout,
4274            entries: &[wgpu::BindGroupEntry {
4275                binding: 0,
4276                resource: uniform_buffer.as_entire_binding(),
4277            }],
4278        });
4279
4280        #[cfg(not(target_arch = "wasm32"))]
4281        let shape_buffers = ShapeBatchBuffers::new(
4282            &device,
4283            &shape_bind_group_layout,
4284            &identity_similarity_buffer,
4285            dummy_paint_buffer.as_ref(),
4286            shape_batch_limits,
4287        );
4288
4289        let image_nearest_sampler =
4290            device.create_sampler(&image_sampler_descriptor(ImageSampling::Nearest));
4291        let image_linear_sampler =
4292            device.create_sampler(&image_sampler_descriptor(ImageSampling::Linear));
4293        let text_glyph_atlas = TextGlyphAtlas::new(
4294            &device,
4295            &image_bind_group_layout,
4296            &image_nearest_sampler,
4297            TEXT_GLYPH_ATLAS_MIN_SIZE,
4298        );
4299
4300        #[cfg(not(target_arch = "wasm32"))]
4301        let image_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4302            label: Some("Image Vertex Buffer"),
4303            size: (std::mem::size_of::<Vertex>() * 4) as u64,
4304            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
4305            mapped_at_creation: false,
4306        });
4307
4308        #[cfg(not(target_arch = "wasm32"))]
4309        let image_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4310            label: Some("Image Index Buffer"),
4311            size: (std::mem::size_of::<u32>() * 6) as u64,
4312            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
4313            mapped_at_creation: false,
4314        });
4315        #[cfg(not(target_arch = "wasm32"))]
4316        let retained_glyph_uniform_stride = align_usize_to(
4317            std::mem::size_of::<Uniforms>(),
4318            (device.limits().min_uniform_buffer_offset_alignment as usize)
4319                .max(wgpu::COPY_BUFFER_ALIGNMENT as usize),
4320        ) as u64;
4321        #[cfg(not(target_arch = "wasm32"))]
4322        let retained_glyph_uniform_capacity = INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS;
4323        #[cfg(not(target_arch = "wasm32"))]
4324        let retained_glyph_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4325            label: Some("Retained Glyph Uniform Buffer"),
4326            size: retained_glyph_uniform_stride * retained_glyph_uniform_capacity as u64,
4327            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4328            mapped_at_creation: false,
4329        });
4330        #[cfg(not(target_arch = "wasm32"))]
4331        let retained_glyph_uniform_bind_group =
4332            device.create_bind_group(&wgpu::BindGroupDescriptor {
4333                label: Some("Retained Glyph Uniform Bind Group"),
4334                layout: &retained_glyph_uniform_bind_group_layout,
4335                entries: &[wgpu::BindGroupEntry {
4336                    binding: 0,
4337                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
4338                        buffer: &retained_glyph_uniform_buffer,
4339                        offset: 0,
4340                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
4341                    }),
4342                }],
4343            });
4344
4345        let effect_renderer = EffectRenderer::new(&device, surface_format, adapter_backend);
4346
4347        Self {
4348            device,
4349            queue,
4350            renderer_epoch,
4351            #[cfg(not(target_arch = "wasm32"))]
4352            store_feed_generation,
4353            surface_format,
4354            adapter_backend,
4355            shape_batch_limits,
4356            pipeline,
4357            pipeline_dst_out,
4358            pipeline_solid,
4359            #[cfg(not(target_arch = "wasm32"))]
4360            mesh_pipeline,
4361            #[cfg(not(target_arch = "wasm32"))]
4362            instanced_quads,
4363            uniform_bind_group_layout,
4364            shape_bind_group_layout,
4365            dummy_paint_buffer,
4366            identity_similarity_buffer,
4367            #[cfg(not(target_arch = "wasm32"))]
4368            replay_slots: replay_slot_store,
4369            image_pipeline,
4370            image_pipeline_dst_out,
4371            glyph_atlas_pipeline,
4372            #[cfg(not(target_arch = "wasm32"))]
4373            retained_glyph_atlas_pipeline,
4374            image_bind_group_layout,
4375            #[cfg(not(target_arch = "wasm32"))]
4376            retained_glyph_uniform_bind_group_layout,
4377            image_nearest_sampler,
4378            image_linear_sampler,
4379            text_fonts,
4380            #[cfg(not(target_arch = "wasm32"))]
4381            upload_buffer,
4382            #[cfg(not(target_arch = "wasm32"))]
4383            uniform_buffer,
4384            #[cfg(not(target_arch = "wasm32"))]
4385            uniform_bind_group,
4386            #[cfg(not(target_arch = "wasm32"))]
4387            shape_buffers,
4388            #[cfg(not(target_arch = "wasm32"))]
4389            image_vertex_buffer,
4390            #[cfg(not(target_arch = "wasm32"))]
4391            image_index_buffer,
4392            #[cfg(not(target_arch = "wasm32"))]
4393            retained_glyph_uniform_buffer,
4394            #[cfg(not(target_arch = "wasm32"))]
4395            retained_glyph_uniform_bind_group,
4396            #[cfg(not(target_arch = "wasm32"))]
4397            retained_glyph_uniform_stride,
4398            #[cfg(not(target_arch = "wasm32"))]
4399            retained_glyph_uniform_capacity,
4400            #[cfg(not(target_arch = "wasm32"))]
4401            retained_glyph_uniform_cursor: 0,
4402            #[cfg(target_arch = "wasm32")]
4403            wasm_uniform_batches: Vec::new(),
4404            #[cfg(target_arch = "wasm32")]
4405            wasm_uniform_batch_cursor: 0,
4406            #[cfg(target_arch = "wasm32")]
4407            wasm_shape_batches: Vec::new(),
4408            #[cfg(target_arch = "wasm32")]
4409            wasm_shape_batch_cursor: 0,
4410            #[cfg(target_arch = "wasm32")]
4411            wasm_image_batches: Vec::new(),
4412            #[cfg(target_arch = "wasm32")]
4413            wasm_image_batch_cursor: 0,
4414            image_texture_cache: BoundedLruCache::with_capacity_at_least_one(
4415                MAX_TEXTURE_CACHE_ITEMS,
4416            ),
4417            image_texture_cache_bytes: 0,
4418            text_image_cache: BoundedLruCache::with_capacity_at_least_one(
4419                MAX_TEXT_IMAGE_CACHE_ITEMS,
4420            ),
4421            text_glyph_atlas,
4422            text_glyph_run_cache: BoundedLruCache::with_capacity_at_least_one(
4423                MAX_TEXT_GLYPH_RUN_CACHE_ITEMS,
4424            ),
4425            #[cfg(not(target_arch = "wasm32"))]
4426            text_glyph_gpu_run_cache: BoundedLruCache::with_capacity_at_least_one(
4427                MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS,
4428            ),
4429            text_glyph_mask_cache: SoftwareGlyphRasterCache::with_capacity_at_least_one(
4430                MAX_TEXT_GLYPH_MASK_CACHE_ITEMS,
4431            ),
4432            text_line_index_cache: TextLineIndexCache::new(MAX_TEXT_LINE_INDEX_CACHE_ITEMS),
4433            scratch_shape_data: Vec::new(),
4434            scratch_gradients: Vec::new(),
4435            scratch_image_vertices: Vec::new(),
4436            scratch_image_indices: Vec::new(),
4437            scratch_image_cmds: Vec::new(),
4438            scratch_glyph_cmds: Vec::new(),
4439            scratch_text_glyph_run: Vec::new(),
4440            scratch_text_glyph_placements: Vec::new(),
4441            scratch_text_glyph_quads: Vec::new(),
4442            scratch_segment_items: Vec::new(),
4443            scratch_effect_ranges: Vec::new(),
4444            scratch_layer_events: Vec::new(),
4445            staged_uploads: StagedBufferUploads::default(),
4446            frame_graph_executor: WgpuFrameGraphExecutor::new(),
4447            deferred_offscreen_releases: Vec::new(),
4448            effect_renderer,
4449            layer_surface_cache: LayerSurfaceCache::new(),
4450            observed_scene_range_cache_misses: BoundedLruCache::with_capacity_at_least_one(
4451                MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES,
4452            ),
4453            shadow_surface_cache: BoundedLruCache::with_capacity_at_least_one(
4454                MAX_SHADOW_SURFACE_CACHE_ITEMS,
4455            ),
4456            shadow_surface_cache_bytes: 0,
4457            frame_stats: gpu_stats::FrameStats::default(),
4458            last_frame_stats: None,
4459            pending_frame_warmup_frames: 0,
4460            frame_count: 0,
4461            gpu_stats_enabled: gpu_stats_enabled(),
4462            warning_state: RendererWarningState::default(),
4463            #[cfg(not(target_arch = "wasm32"))]
4464            replay_upload_stats: ReplayUploadStats::default(),
4465            #[cfg(not(target_arch = "wasm32"))]
4466            replay_color_patches: Vec::new(),
4467            #[cfg(not(target_arch = "wasm32"))]
4468            color_patch_scratch: Vec::new(),
4469            #[cfg(not(target_arch = "wasm32"))]
4470            replay_ack_confirmations: Vec::new(),
4471            #[cfg(not(target_arch = "wasm32"))]
4472            replay_generation_drops: 0,
4473            #[cfg(not(target_arch = "wasm32"))]
4474            retained_bundle_cache: RetainedBundleCache::new(),
4475        }
4476    }
4477
4478    fn shape_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
4479        let resource = match blend_mode {
4480            BlendMode::DstOut => &self.pipeline_dst_out,
4481            _ => &self.pipeline,
4482        };
4483        resource.get_or_init(self.adapter_backend, || {
4484            create_shape_pipeline(
4485                &self.device,
4486                self.surface_format,
4487                &self.uniform_bind_group_layout,
4488                &self.shape_bind_group_layout,
4489                blend_mode,
4490                self.shape_batch_limits,
4491                "fs_main",
4492            )
4493        })
4494    }
4495
4496    /// The `fs_solid` twin of [`Self::shape_pipeline`], SrcOver only. Callers
4497    /// pick it exactly when the draw's shapes carry zero gradient stops; the
4498    /// coverage math is byte-identical, the gradient machinery is compiled
4499    /// out of the fragment stage.
4500    fn shape_pipeline_solid(&self) -> &wgpu::RenderPipeline {
4501        self.pipeline_solid.get_or_init(self.adapter_backend, || {
4502            create_shape_pipeline(
4503                &self.device,
4504                self.surface_format,
4505                &self.uniform_bind_group_layout,
4506                &self.shape_bind_group_layout,
4507                BlendMode::SrcOver,
4508                self.shape_batch_limits,
4509                "fs_solid",
4510            )
4511        })
4512    }
4513
4514    #[cfg(not(target_arch = "wasm32"))]
4515    fn mesh_pipeline(&self) -> &wgpu::RenderPipeline {
4516        self.mesh_pipeline.get_or_init(self.adapter_backend, || {
4517            create_mesh_shape_pipeline(
4518                &self.device,
4519                self.surface_format,
4520                &self.uniform_bind_group_layout,
4521                &self.shape_bind_group_layout,
4522                self.shape_batch_limits,
4523            )
4524        })
4525    }
4526
4527    #[cfg(not(target_arch = "wasm32"))]
4528    fn instanced_pipeline<'a>(
4529        &'a self,
4530        instanced: &'a InstancedQuadPipelines,
4531        blend_mode: BlendMode,
4532    ) -> &'a wgpu::RenderPipeline {
4533        let resource = match blend_mode {
4534            BlendMode::DstOut => &instanced.pipeline_dst_out,
4535            _ => &instanced.pipeline,
4536        };
4537        resource.get_or_init(self.adapter_backend, || {
4538            create_instanced_shape_pipeline(
4539                &self.device,
4540                self.surface_format,
4541                &self.uniform_bind_group_layout,
4542                &self.shape_bind_group_layout,
4543                blend_mode,
4544                self.shape_batch_limits,
4545                "fs_main",
4546            )
4547        })
4548    }
4549
4550    /// The `fs_solid` twin of [`Self::instanced_pipeline`], SrcOver only.
4551    #[cfg(not(target_arch = "wasm32"))]
4552    fn instanced_pipeline_solid<'a>(
4553        &'a self,
4554        instanced: &'a InstancedQuadPipelines,
4555    ) -> &'a wgpu::RenderPipeline {
4556        instanced
4557            .pipeline_solid
4558            .get_or_init(self.adapter_backend, || {
4559                create_instanced_shape_pipeline(
4560                    &self.device,
4561                    self.surface_format,
4562                    &self.uniform_bind_group_layout,
4563                    &self.shape_bind_group_layout,
4564                    BlendMode::SrcOver,
4565                    self.shape_batch_limits,
4566                    "fs_solid",
4567                )
4568            })
4569    }
4570
4571    fn image_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
4572        let resource = match blend_mode {
4573            BlendMode::DstOut => &self.image_pipeline_dst_out,
4574            _ => &self.image_pipeline,
4575        };
4576        resource.get_or_init(self.adapter_backend, || {
4577            create_image_pipeline(
4578                &self.device,
4579                self.surface_format,
4580                &self.uniform_bind_group_layout,
4581                &self.image_bind_group_layout,
4582                blend_mode,
4583            )
4584        })
4585    }
4586
4587    fn glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
4588        self.glyph_atlas_pipeline
4589            .get_or_init(self.adapter_backend, || {
4590                create_glyph_atlas_pipeline(
4591                    &self.device,
4592                    self.surface_format,
4593                    &self.uniform_bind_group_layout,
4594                    &self.image_bind_group_layout,
4595                )
4596            })
4597    }
4598
4599    #[cfg(not(target_arch = "wasm32"))]
4600    fn retained_glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
4601        self.retained_glyph_atlas_pipeline
4602            .get_or_init(self.adapter_backend, || {
4603                create_glyph_atlas_pipeline(
4604                    &self.device,
4605                    self.surface_format,
4606                    &self.retained_glyph_uniform_bind_group_layout,
4607                    &self.image_bind_group_layout,
4608                )
4609            })
4610    }
4611
4612    fn ensure_image_cached(&mut self, image: &ImageBitmap) -> Result<(), String> {
4613        if self.image_texture_cache.get(&image.id()).is_some() {
4614            return Ok(());
4615        }
4616
4617        let size = wgpu::Extent3d {
4618            width: image.width(),
4619            height: image.height(),
4620            depth_or_array_layers: 1,
4621        };
4622
4623        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
4624            label: Some("Image Texture"),
4625            size,
4626            mip_level_count: 1,
4627            sample_count: 1,
4628            dimension: wgpu::TextureDimension::D2,
4629            format: wgpu::TextureFormat::Rgba8Unorm,
4630            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4631            view_formats: &[],
4632        });
4633
4634        let upload_stats = self.frame_graph_executor.upload_texture(
4635            &self.queue,
4636            wgpu::TexelCopyTextureInfo {
4637                texture: &texture,
4638                mip_level: 0,
4639                origin: wgpu::Origin3d::ZERO,
4640                aspect: wgpu::TextureAspect::All,
4641            },
4642            image.pixels(),
4643            wgpu::TexelCopyBufferLayout {
4644                offset: 0,
4645                bytes_per_row: Some(4 * image.width()),
4646                rows_per_image: Some(image.height()),
4647            },
4648            size,
4649        );
4650        self.frame_stats.record_command_stats(upload_stats);
4651
4652        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
4653        let nearest_bind_group = self.image_bind_group(&view, &self.image_nearest_sampler);
4654        let linear_bind_group = self.image_bind_group(&view, &self.image_linear_sampler);
4655
4656        let bytes = image.width() as usize * image.height() as usize * 4;
4657        if let Some(replaced) = self.image_texture_cache.put(
4658            image.id(),
4659            CachedImageTexture {
4660                _texture: texture,
4661                _view: view,
4662                nearest_bind_group,
4663                linear_bind_group,
4664                bytes,
4665            },
4666        ) {
4667            self.image_texture_cache_bytes = self
4668                .image_texture_cache_bytes
4669                .saturating_sub(replaced.bytes);
4670        }
4671        self.image_texture_cache_bytes += bytes;
4672        // Byte-bounded eviction on top of the count bound: never evict the
4673        // entry just inserted (this frame draws it).
4674        while self.image_texture_cache_bytes > MAX_IMAGE_TEXTURE_CACHE_BYTES
4675            && self.image_texture_cache.len() > 1
4676        {
4677            let Some((_, evicted)) = self.image_texture_cache.pop_lru() else {
4678                break;
4679            };
4680            self.image_texture_cache_bytes =
4681                self.image_texture_cache_bytes.saturating_sub(evicted.bytes);
4682        }
4683        Ok(())
4684    }
4685
4686    fn image_bind_group(
4687        &self,
4688        view: &wgpu::TextureView,
4689        sampler: &wgpu::Sampler,
4690    ) -> wgpu::BindGroup {
4691        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4692            label: Some("Image Texture Bind Group"),
4693            layout: &self.image_bind_group_layout,
4694            entries: &[
4695                wgpu::BindGroupEntry {
4696                    binding: 0,
4697                    resource: wgpu::BindingResource::TextureView(view),
4698                },
4699                wgpu::BindGroupEntry {
4700                    binding: 1,
4701                    resource: wgpu::BindingResource::Sampler(sampler),
4702                },
4703            ],
4704        })
4705    }
4706
4707    /// Acquire an offscreen target from the pool with stats tracking.
4708    /// Uses split borrows to avoid conflicting borrows on self.
4709    fn max_texture_dim(&self) -> u32 {
4710        self.effect_renderer.max_texture_dim()
4711    }
4712
4713    fn acquire_offscreen(&mut self, width: u32, height: u32) -> OffscreenTarget {
4714        self.effect_renderer
4715            .acquire_offscreen(&self.device, width, height, Some(&self.frame_stats))
4716    }
4717
4718    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
4719        self.acquire_offscreen(width, height)
4720    }
4721
4722    fn transient_offscreen_descriptor(
4723        &self,
4724        label: &'static str,
4725        width: u32,
4726        height: u32,
4727    ) -> FrameTextureDescriptor {
4728        let max_texture_dim = self.max_texture_dim();
4729        FrameTextureDescriptor::render_attachment(
4730            label,
4731            width.min(max_texture_dim),
4732            height.min(max_texture_dim),
4733            self.surface_format,
4734        )
4735    }
4736
4737    fn defer_offscreen_release(&mut self, target: OffscreenTarget) {
4738        self.deferred_offscreen_releases.push(target);
4739    }
4740
4741    fn flush_deferred_offscreen_releases(&mut self) {
4742        for target in self.deferred_offscreen_releases.drain(..) {
4743            self.effect_renderer.release_offscreen(target);
4744        }
4745    }
4746
4747    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
4748        if let LayerSurfaceTexture::Owned(target) = target {
4749            self.defer_offscreen_release(target);
4750        }
4751    }
4752
4753    fn cached_layer_surface(
4754        &mut self,
4755        key: &LayerRasterCacheKey,
4756    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
4757        self.layer_surface_cache.get(key, &self.frame_stats)
4758    }
4759
4760    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
4761        admit_layer_surface_cache_miss_impl(key, &mut self.observed_scene_range_cache_misses)
4762    }
4763
4764    fn insert_cached_layer_surface(
4765        &mut self,
4766        key: LayerRasterCacheKey,
4767        target: OffscreenTarget,
4768        logical_rect: Rect,
4769    ) -> Rc<OffscreenTarget> {
4770        self.layer_surface_cache
4771            .insert(key, target, logical_rect, &self.frame_stats)
4772    }
4773
4774    fn cached_shadow_surface(
4775        &mut self,
4776        key: &ShadowSurfaceCacheKey,
4777    ) -> Option<Rc<OffscreenTarget>> {
4778        self.shadow_surface_cache
4779            .get(key)
4780            .map(|cached| cached.target.clone())
4781    }
4782
4783    fn cached_shape_shadow_composite(
4784        &mut self,
4785        shadow: &ShadowDraw,
4786        width: u32,
4787        height: u32,
4788        root_scale: f32,
4789    ) -> Option<CachedShadowComposite> {
4790        if shadow.blur_radius <= 0.0 || shadow.shapes.is_empty() || !shadow.texts.is_empty() {
4791            return None;
4792        }
4793
4794        let plan = shape_shadow_surface_plan(
4795            &shadow.shapes,
4796            shadow.clip,
4797            shadow.blur_radius,
4798            width,
4799            height,
4800            root_scale,
4801            self.max_texture_dim(),
4802        )?;
4803        let key = shape_shadow_surface_cache_key(
4804            &shadow.shapes,
4805            plan.source_device_bounds,
4806            plan.pixel_radius,
4807            root_scale,
4808        )?;
4809        let cached = self.cached_shadow_surface(&key)?;
4810        let viewport_offset = [plan.source_device_bounds.x, plan.source_device_bounds.y];
4811        self.frame_stats.record_shadow_shape_cache_hit(
4812            plan.source_device_bounds.width,
4813            plan.source_device_bounds.height,
4814        );
4815
4816        let clip_scissor = shadow
4817            .clip
4818            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
4819        let scissor = clip_scissor.or(plan.processing_scissor);
4820        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
4821            mask.rect[0] -= viewport_offset[0];
4822            mask.rect[1] -= viewport_offset[1];
4823            mask
4824        });
4825        let dest_viewport = Some((
4826            viewport_offset[0],
4827            viewport_offset[1],
4828            plan.source_device_bounds.width as f32,
4829            plan.source_device_bounds.height as f32,
4830        ));
4831
4832        Some(CachedShadowComposite {
4833            source: cached,
4834            scissor,
4835            rounded_mask,
4836            dest_viewport,
4837        })
4838    }
4839
4840    fn insert_cached_shadow_surface(
4841        &mut self,
4842        key: ShadowSurfaceCacheKey,
4843        target: OffscreenTarget,
4844    ) {
4845        let byte_size = offscreen_byte_size(target.width, target.height);
4846        while self.shadow_surface_cache_bytes + byte_size > MAX_SHADOW_SURFACE_CACHE_BYTES {
4847            let Some((_evicted_key, evicted_entry)) = self.shadow_surface_cache.pop_lru() else {
4848                break;
4849            };
4850            self.shadow_surface_cache_bytes = self
4851                .shadow_surface_cache_bytes
4852                .saturating_sub(evicted_entry.byte_size);
4853        }
4854
4855        let cached = CachedShadowSurface {
4856            target: Rc::new(target),
4857            byte_size,
4858        };
4859        if let Some((_replaced_key, replaced_entry)) = self.shadow_surface_cache.push(key, cached) {
4860            self.shadow_surface_cache_bytes = self
4861                .shadow_surface_cache_bytes
4862                .saturating_sub(replaced_entry.byte_size);
4863        }
4864        self.shadow_surface_cache_bytes = self.shadow_surface_cache_bytes.saturating_add(byte_size);
4865    }
4866
4867    fn supports_render_effect(&self, effect: &RenderEffect) -> bool {
4868        is_render_effect_supported(effect)
4869    }
4870}
4871
4872struct RecordingSurfaceBackend<'renderer, 'recorder, C: FrameCommandRecorder> {
4873    renderer: &'renderer mut GpuRenderer,
4874    recorder: &'recorder mut C,
4875}
4876
4877impl<C: FrameCommandRecorder> RecordingSurfaceBackend<'_, '_, C> {
4878    #[allow(clippy::too_many_arguments)]
4879    fn render_range_with_layer_events_to_target_recorded(
4880        &mut self,
4881        target: &OffscreenTarget,
4882        shapes: &[DrawShape],
4883        images: &[ImageDraw],
4884        texts: &[TextDraw],
4885        shadow_draws: &[ShadowDraw],
4886        draw_ops: &[DrawOp],
4887        effect_layers: &[EffectLayer],
4888        backdrop_layers: &[BackdropLayer],
4889        z_start: usize,
4890        z_end: usize,
4891        excluded_effect_layer: Option<usize>,
4892        width: u32,
4893        height: u32,
4894        root_scale: f32,
4895        backdrop_underlay: Option<&OffscreenTarget>,
4896        initial_load_op: wgpu::LoadOp<wgpu::Color>,
4897    ) -> Result<(), String> {
4898        if z_start >= z_end {
4899            if matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
4900                self.clear_target_view_with_load_op(&target.view, initial_load_op);
4901            }
4902            return Ok(());
4903        }
4904
4905        let mut effect_z_ranges = std::mem::take(&mut self.renderer.scratch_effect_ranges);
4906        collect_effect_ranges(
4907            effect_layers,
4908            z_start,
4909            z_end,
4910            excluded_effect_layer,
4911            &mut effect_z_ranges,
4912        );
4913        let mut events = std::mem::take(&mut self.renderer.scratch_layer_events);
4914        collect_layer_events(
4915            effect_layers,
4916            backdrop_layers,
4917            z_start,
4918            z_end,
4919            excluded_effect_layer,
4920            &mut events,
4921        );
4922
4923        let result = (|| -> Result<(), String> {
4924            let mut next_load_op = initial_load_op;
4925            let mut cursor_z = z_start;
4926            for event in &events {
4927                if event.z_index > cursor_z {
4928                    self.render_non_effect_segment(
4929                        &target.view,
4930                        shapes,
4931                        images,
4932                        texts,
4933                        shadow_draws,
4934                        // Windowed scenes never carry retained draws — see
4935                        // `build_scene_window`.
4936                        &[],
4937                        draw_ops,
4938                        cursor_z,
4939                        event.z_index,
4940                        &effect_z_ranges,
4941                        width,
4942                        height,
4943                        root_scale,
4944                        next_load_op,
4945                    )?;
4946                    next_load_op = wgpu::LoadOp::Load;
4947                    cursor_z = event.z_index;
4948                } else if event.z_index < cursor_z {
4949                    continue;
4950                }
4951
4952                if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
4953                    self.clear_target_view_with_load_op(&target.view, next_load_op);
4954                    next_load_op = wgpu::LoadOp::Load;
4955                }
4956
4957                match event.kind {
4958                    LayerEventKind::Backdrop(index) => {
4959                        let layer = &backdrop_layers[index];
4960                        let effective_backdrop_underlay = if backdrop_underlay.is_some()
4961                            && backdrop_underlay_is_covered_by_local_content(
4962                                shapes,
4963                                images,
4964                                shadow_draws,
4965                                draw_ops,
4966                                effect_layers,
4967                                backdrop_layers,
4968                                layer,
4969                            ) {
4970                            None
4971                        } else {
4972                            backdrop_underlay
4973                        };
4974                        execute_apply_backdrop_layer_to_target(
4975                            self,
4976                            target,
4977                            layer,
4978                            effective_backdrop_underlay,
4979                            width,
4980                            height,
4981                            root_scale,
4982                            None,
4983                        )?;
4984                    }
4985                    LayerEventKind::Effect(index) => {
4986                        let layer = &effect_layers[index];
4987                        if layer.z_start < cursor_z {
4988                            continue;
4989                        }
4990                        execute_render_effect_layer_to_target(
4991                            self,
4992                            target,
4993                            shapes,
4994                            images,
4995                            texts,
4996                            shadow_draws,
4997                            draw_ops,
4998                            effect_layers,
4999                            backdrop_layers,
5000                            index,
5001                            backdrop_underlay,
5002                            width,
5003                            height,
5004                            root_scale,
5005                        )?;
5006                        cursor_z = cursor_z.max(layer.z_end);
5007                    }
5008                }
5009            }
5010
5011            if cursor_z < z_end {
5012                self.render_non_effect_segment(
5013                    &target.view,
5014                    shapes,
5015                    images,
5016                    texts,
5017                    shadow_draws,
5018                    &[],
5019                    draw_ops,
5020                    cursor_z,
5021                    z_end,
5022                    &effect_z_ranges,
5023                    width,
5024                    height,
5025                    root_scale,
5026                    next_load_op,
5027                )?;
5028            } else if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
5029                self.clear_target_view_with_load_op(&target.view, next_load_op);
5030            }
5031
5032            Ok(())
5033        })();
5034
5035        self.renderer.scratch_effect_ranges = effect_z_ranges;
5036        self.renderer.scratch_layer_events = events;
5037        result
5038    }
5039
5040    #[allow(clippy::too_many_arguments)]
5041    fn record_shader_composite(
5042        &mut self,
5043        source: &OffscreenTarget,
5044        shader: &RuntimeShader,
5045        effect_rect: [f32; 4],
5046        dest_view: &wgpu::TextureView,
5047        alpha: f32,
5048        load_op: wgpu::LoadOp<wgpu::Color>,
5049        scissor: Option<(u32, u32, u32, u32)>,
5050        blend_mode: BlendMode,
5051        dest_viewport: Option<(f32, f32, f32, f32)>,
5052        sample_mode: CompositeSampleMode,
5053    ) {
5054        let device = self.renderer.device.clone();
5055        if let Some(viewport) = direct_shader_composite_viewport(
5056            alpha,
5057            blend_mode,
5058            dest_viewport,
5059            sample_mode,
5060            (source.width, source.height),
5061        ) {
5062            let shader_applied = self
5063                .renderer
5064                .effect_renderer
5065                .encode_shader_src_over_to_view(
5066                    self.recorder,
5067                    &device,
5068                    source,
5069                    dest_view,
5070                    shader,
5071                    effect_rect,
5072                    load_op,
5073                    scissor,
5074                    viewport,
5075                );
5076            if shader_applied {
5077                self.renderer
5078                    .effect_renderer
5079                    .debug_effects
5080                    .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5081                self.recorder.record_pass();
5082                self.renderer.effect_renderer.record_composite_pass();
5083                return;
5084            }
5085        }
5086        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5087            "Shader Effect Composite Scratch",
5088            source.width,
5089            source.height,
5090        );
5091        let scratch = self
5092            .recorder
5093            .acquire_transient_offscreen(&device, scratch_descriptor);
5094        let shader_applied = {
5095            self.renderer.effect_renderer.encode_shader(
5096                self.recorder,
5097                &device,
5098                source,
5099                &scratch.view,
5100                shader,
5101                effect_rect,
5102            )
5103        };
5104        let composite_source = if shader_applied {
5105            self.renderer
5106                .effect_renderer
5107                .debug_effects
5108                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5109            self.recorder.record_pass();
5110            &scratch
5111        } else {
5112            source
5113        };
5114        {
5115            self.renderer
5116                .effect_renderer
5117                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
5118                    self.recorder,
5119                    &device,
5120                    composite_source,
5121                    dest_view,
5122                    alpha,
5123                    load_op,
5124                    scissor,
5125                    None,
5126                    supported_blend_mode(blend_mode),
5127                    dest_viewport,
5128                    sample_mode,
5129                );
5130        }
5131        self.recorder.record_pass();
5132        self.renderer.effect_renderer.record_composite_pass();
5133        self.recorder
5134            .release_transient_offscreen(scratch_descriptor, scratch);
5135    }
5136
5137    #[allow(clippy::too_many_arguments)]
5138    fn record_shader_projective_composite(
5139        &mut self,
5140        source: &OffscreenTarget,
5141        shader: &RuntimeShader,
5142        effect_rect: [f32; 4],
5143        dest_view: &wgpu::TextureView,
5144        viewport: (u32, u32),
5145        source_size: (f32, f32),
5146        inverse_matrix: [[f32; 3]; 3],
5147        dest_bounds: [[f32; 2]; 4],
5148        alpha: f32,
5149        load_op: wgpu::LoadOp<wgpu::Color>,
5150        scissor: Option<(u32, u32, u32, u32)>,
5151        blend_mode: BlendMode,
5152        sample_mode: CompositeSampleMode,
5153    ) {
5154        if projective_dest_bounds_rect(dest_bounds).is_none() {
5155            return;
5156        }
5157        let device = self.renderer.device.clone();
5158        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5159            "Shader Projective Composite Scratch",
5160            source.width,
5161            source.height,
5162        );
5163        let scratch = self
5164            .recorder
5165            .acquire_transient_offscreen(&device, scratch_descriptor);
5166        let shader_applied = {
5167            self.renderer.effect_renderer.encode_shader(
5168                self.recorder,
5169                &device,
5170                source,
5171                &scratch.view,
5172                shader,
5173                effect_rect,
5174            )
5175        };
5176        let composite_source = if shader_applied {
5177            self.renderer
5178                .effect_renderer
5179                .debug_effects
5180                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5181            self.recorder.record_pass();
5182            &scratch
5183        } else {
5184            source
5185        };
5186        let composited = {
5187            self.renderer
5188                .effect_renderer
5189                .encode_composite_to_view_projective(
5190                    self.recorder,
5191                    &device,
5192                    composite_source,
5193                    dest_view,
5194                    viewport,
5195                    source_size,
5196                    inverse_matrix,
5197                    dest_bounds,
5198                    alpha,
5199                    load_op,
5200                    scissor,
5201                    supported_blend_mode(blend_mode),
5202                    sample_mode,
5203                )
5204        };
5205        if composited {
5206            self.recorder.record_pass();
5207            self.renderer.effect_renderer.record_composite_pass();
5208        }
5209        self.recorder
5210            .release_transient_offscreen(scratch_descriptor, scratch);
5211    }
5212
5213    #[allow(clippy::too_many_arguments)]
5214    fn record_effect_with_direct_shader_tail_composite(
5215        &mut self,
5216        source: &OffscreenTarget,
5217        first_effect: &RenderEffect,
5218        shader: &RuntimeShader,
5219        effect_rect: [f32; 4],
5220        dest_view: &wgpu::TextureView,
5221        load_op: wgpu::LoadOp<wgpu::Color>,
5222        scissor: Option<(u32, u32, u32, u32)>,
5223        dest_viewport: (f32, f32, f32, f32),
5224    ) -> Result<bool, String> {
5225        let device = self.renderer.device.clone();
5226        let intermediate_descriptor = self.renderer.transient_offscreen_descriptor(
5227            "Render Effect Direct Shader Tail Intermediate",
5228            source.width,
5229            source.height,
5230        );
5231        let intermediate = self
5232            .recorder
5233            .acquire_transient_offscreen(&device, intermediate_descriptor);
5234        let effect_scratch_targets = self
5235            .renderer
5236            .effect_renderer
5237            .acquire_recorded_effect_scratch_targets(
5238                self.recorder,
5239                &device,
5240                first_effect,
5241                source.width,
5242                source.height,
5243                self.renderer.surface_format,
5244            );
5245        let first_passes = {
5246            let mut effect_scratch_refs = effect_scratch_targets.refs();
5247            let pass_count = self.renderer.effect_renderer.encode_effect(
5248                self.recorder,
5249                &device,
5250                source,
5251                &intermediate.view,
5252                first_effect,
5253                effect_rect,
5254                &mut effect_scratch_refs,
5255            );
5256            match pass_count {
5257                Ok(pass_count) => effect_scratch_refs.assert_consumed().map(|()| pass_count),
5258                Err(error) => Err(error),
5259            }
5260        };
5261        let first_passes = match first_passes {
5262            Ok(pass_count) => pass_count,
5263            Err(error) => {
5264                effect_scratch_targets.release_into(self.recorder);
5265                self.recorder
5266                    .release_transient_offscreen(intermediate_descriptor, intermediate);
5267                return Err(error);
5268            }
5269        };
5270        let shader_applied = self
5271            .renderer
5272            .effect_renderer
5273            .encode_shader_src_over_to_view(
5274                self.recorder,
5275                &device,
5276                &intermediate,
5277                dest_view,
5278                shader,
5279                effect_rect,
5280                load_op,
5281                scissor,
5282                dest_viewport,
5283            );
5284        self.recorder
5285            .record_passes(first_passes.saturating_add(u32::from(shader_applied)));
5286        effect_scratch_targets.release_into(self.recorder);
5287        self.recorder
5288            .release_transient_offscreen(intermediate_descriptor, intermediate);
5289        if !shader_applied {
5290            return Ok(false);
5291        }
5292        self.renderer
5293            .effect_renderer
5294            .debug_effects
5295            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5296        self.renderer.effect_renderer.record_composite_pass();
5297        Ok(true)
5298    }
5299
5300    #[allow(clippy::too_many_arguments)]
5301    fn record_effect_composite(
5302        &mut self,
5303        source: &OffscreenTarget,
5304        effect: &RenderEffect,
5305        effect_rect: [f32; 4],
5306        dest_view: &wgpu::TextureView,
5307        alpha: f32,
5308        load_op: wgpu::LoadOp<wgpu::Color>,
5309        scissor: Option<(u32, u32, u32, u32)>,
5310        blend_mode: BlendMode,
5311        dest_viewport: Option<(f32, f32, f32, f32)>,
5312        sample_mode: CompositeSampleMode,
5313    ) -> Result<(), String> {
5314        if let (
5315            RenderEffect::Chain { first, second },
5316            Some(viewport),
5317            BlendMode::SrcOver,
5318            CompositeSampleMode::Linear,
5319        ) = (
5320            effect,
5321            dest_viewport,
5322            supported_blend_mode(blend_mode),
5323            sample_mode,
5324        ) {
5325            if let (
5326                RenderEffect::Blur {
5327                    radius_x,
5328                    radius_y,
5329                    edge_treatment,
5330                },
5331                RenderEffect::Shader { shader },
5332            ) = (first.as_ref(), second.as_ref())
5333            {
5334                if *radius_x > 0.0 || *radius_y > 0.0 {
5335                    let device = self.renderer.device.clone();
5336                    let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5337                        "Blur Rounded Mask Scratch",
5338                        source.width,
5339                        source.height,
5340                    );
5341                    let scratch = self
5342                        .recorder
5343                        .acquire_transient_offscreen(&device, scratch_descriptor);
5344                    let fused = self
5345                        .renderer
5346                        .effect_renderer
5347                        .encode_blur_then_rounded_mask_src_over_to_view(
5348                            self.recorder,
5349                            &device,
5350                            source,
5351                            &scratch,
5352                            dest_view,
5353                            *radius_x,
5354                            *radius_y,
5355                            *edge_treatment,
5356                            shader,
5357                            effect_rect,
5358                            load_op,
5359                            scissor,
5360                            viewport,
5361                        );
5362                    if fused {
5363                        self.recorder.record_passes(2);
5364                        self.renderer.effect_renderer.record_blur_pass();
5365                        self.renderer
5366                            .effect_renderer
5367                            .debug_effects
5368                            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5369                        self.renderer.effect_renderer.record_composite_pass();
5370                        self.recorder
5371                            .release_transient_offscreen(scratch_descriptor, scratch);
5372                        return Ok(());
5373                    }
5374                    self.recorder
5375                        .release_transient_offscreen(scratch_descriptor, scratch);
5376                }
5377            }
5378        }
5379        if let Some((first_effect, shader, viewport)) = direct_shader_tail_composite(
5380            effect,
5381            alpha,
5382            blend_mode,
5383            dest_viewport,
5384            sample_mode,
5385            (source.width, source.height),
5386        ) {
5387            if self.record_effect_with_direct_shader_tail_composite(
5388                source,
5389                first_effect,
5390                shader,
5391                effect_rect,
5392                dest_view,
5393                load_op,
5394                scissor,
5395                viewport,
5396            )? {
5397                return Ok(());
5398            }
5399        }
5400        let device = self.renderer.device.clone();
5401        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5402            "Render Effect Composite Scratch",
5403            source.width,
5404            source.height,
5405        );
5406        let scratch = self
5407            .recorder
5408            .acquire_transient_offscreen(&device, scratch_descriptor);
5409        let effect_scratch_targets = self
5410            .renderer
5411            .effect_renderer
5412            .acquire_recorded_effect_scratch_targets(
5413                self.recorder,
5414                &device,
5415                effect,
5416                source.width,
5417                source.height,
5418                self.renderer.surface_format,
5419            );
5420        let effect_passes = {
5421            let mut effect_scratch_refs = effect_scratch_targets.refs();
5422            let pass_count = self.renderer.effect_renderer.encode_effect(
5423                self.recorder,
5424                &device,
5425                source,
5426                &scratch.view,
5427                effect,
5428                effect_rect,
5429                &mut effect_scratch_refs,
5430            )?;
5431            effect_scratch_refs.assert_consumed()?;
5432            Ok(pass_count)
5433        };
5434        let effect_passes = match effect_passes {
5435            Ok(pass_count) => pass_count,
5436            Err(error) => {
5437                effect_scratch_targets.release_into(self.recorder);
5438                self.recorder
5439                    .release_transient_offscreen(scratch_descriptor, scratch);
5440                return Err(error);
5441            }
5442        };
5443        {
5444            self.renderer
5445                .effect_renderer
5446                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
5447                    self.recorder,
5448                    &device,
5449                    &scratch,
5450                    dest_view,
5451                    alpha,
5452                    load_op,
5453                    scissor,
5454                    None,
5455                    supported_blend_mode(blend_mode),
5456                    dest_viewport,
5457                    sample_mode,
5458                );
5459        }
5460        self.recorder.record_passes(effect_passes.saturating_add(1));
5461        self.renderer.effect_renderer.record_composite_pass();
5462        effect_scratch_targets.release_into(self.recorder);
5463        self.recorder
5464            .release_transient_offscreen(scratch_descriptor, scratch);
5465        Ok(())
5466    }
5467
5468    #[allow(clippy::too_many_arguments)]
5469    fn record_effect_projective_composite(
5470        &mut self,
5471        source: &OffscreenTarget,
5472        effect: &RenderEffect,
5473        effect_rect: [f32; 4],
5474        dest_view: &wgpu::TextureView,
5475        viewport: (u32, u32),
5476        source_size: (f32, f32),
5477        inverse_matrix: [[f32; 3]; 3],
5478        dest_bounds: [[f32; 2]; 4],
5479        alpha: f32,
5480        load_op: wgpu::LoadOp<wgpu::Color>,
5481        scissor: Option<(u32, u32, u32, u32)>,
5482        blend_mode: BlendMode,
5483        sample_mode: CompositeSampleMode,
5484    ) -> Result<(), String> {
5485        if projective_dest_bounds_rect(dest_bounds).is_none() {
5486            return Ok(());
5487        }
5488        let device = self.renderer.device.clone();
5489        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5490            "Render Effect Projective Composite Scratch",
5491            source.width,
5492            source.height,
5493        );
5494        let scratch = self
5495            .recorder
5496            .acquire_transient_offscreen(&device, scratch_descriptor);
5497        let effect_scratch_targets = self
5498            .renderer
5499            .effect_renderer
5500            .acquire_recorded_effect_scratch_targets(
5501                self.recorder,
5502                &device,
5503                effect,
5504                source.width,
5505                source.height,
5506                self.renderer.surface_format,
5507            );
5508        let effect_passes = {
5509            let mut effect_scratch_refs = effect_scratch_targets.refs();
5510            let pass_count = self.renderer.effect_renderer.encode_effect(
5511                self.recorder,
5512                &device,
5513                source,
5514                &scratch.view,
5515                effect,
5516                effect_rect,
5517                &mut effect_scratch_refs,
5518            )?;
5519            effect_scratch_refs.assert_consumed()?;
5520            Ok(pass_count)
5521        };
5522        let effect_passes = match effect_passes {
5523            Ok(pass_count) => pass_count,
5524            Err(error) => {
5525                effect_scratch_targets.release_into(self.recorder);
5526                self.recorder
5527                    .release_transient_offscreen(scratch_descriptor, scratch);
5528                return Err(error);
5529            }
5530        };
5531        let composited = {
5532            self.renderer
5533                .effect_renderer
5534                .encode_composite_to_view_projective(
5535                    self.recorder,
5536                    &device,
5537                    &scratch,
5538                    dest_view,
5539                    viewport,
5540                    source_size,
5541                    inverse_matrix,
5542                    dest_bounds,
5543                    alpha,
5544                    load_op,
5545                    scissor,
5546                    supported_blend_mode(blend_mode),
5547                    sample_mode,
5548                )
5549        };
5550        if composited {
5551            self.recorder.record_passes(effect_passes.saturating_add(1));
5552            self.renderer.effect_renderer.record_composite_pass();
5553        } else {
5554            self.recorder.record_passes(effect_passes);
5555        }
5556        effect_scratch_targets.release_into(self.recorder);
5557        self.recorder
5558            .release_transient_offscreen(scratch_descriptor, scratch);
5559        Ok(())
5560    }
5561}
5562
5563impl<C: FrameCommandRecorder> SurfaceExecutionBackend for RecordingSurfaceBackend<'_, '_, C> {
5564    fn max_texture_dim(&self) -> u32 {
5565        self.renderer.max_texture_dim()
5566    }
5567
5568    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
5569        self.renderer.acquire_retained_surface(width, height)
5570    }
5571
5572    fn acquire_frame_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
5573        let descriptor =
5574            self.renderer
5575                .transient_offscreen_descriptor("Frame Surface", width, height);
5576        self.recorder
5577            .acquire_transient_offscreen(&self.renderer.device, descriptor)
5578    }
5579
5580    fn release_frame_surface(&mut self, target: OffscreenTarget) {
5581        let descriptor = self.renderer.transient_offscreen_descriptor(
5582            "Frame Surface",
5583            target.width,
5584            target.height,
5585        );
5586        self.recorder
5587            .release_transient_offscreen(descriptor, target);
5588    }
5589
5590    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
5591        self.renderer.release_layer_surface_target(target);
5592    }
5593
5594    fn cached_layer_surface(
5595        &mut self,
5596        key: &LayerRasterCacheKey,
5597    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
5598        self.renderer.cached_layer_surface(key)
5599    }
5600
5601    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
5602        self.renderer.admit_layer_surface_cache_miss(key)
5603    }
5604
5605    fn insert_cached_layer_surface(
5606        &mut self,
5607        key: LayerRasterCacheKey,
5608        target: OffscreenTarget,
5609        logical_rect: Rect,
5610    ) -> Rc<OffscreenTarget> {
5611        self.renderer
5612            .insert_cached_layer_surface(key, target, logical_rect)
5613    }
5614
5615    fn clear_target_view_with_load_op(
5616        &mut self,
5617        target_view: &wgpu::TextureView,
5618        load_op: wgpu::LoadOp<wgpu::Color>,
5619    ) {
5620        {
5621            let _clear = self
5622                .recorder
5623                .encoder()
5624                .begin_render_pass(&wgpu::RenderPassDescriptor {
5625                    label: Some("Layer Event Clear Pass"),
5626                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
5627                        view: target_view,
5628                        resolve_target: None,
5629                        depth_slice: None,
5630                        ops: wgpu::Operations {
5631                            load: load_op,
5632                            store: wgpu::StoreOp::Store,
5633                        },
5634                    })],
5635                    depth_stencil_attachment: None,
5636                    timestamp_writes: None,
5637                    occlusion_query_set: None,
5638                    multiview_mask: None,
5639                });
5640        }
5641        self.recorder.record_pass();
5642    }
5643
5644    #[allow(clippy::too_many_arguments)]
5645    fn render_non_effect_segment(
5646        &mut self,
5647        target_view: &wgpu::TextureView,
5648        shapes: &[DrawShape],
5649        images: &[ImageDraw],
5650        texts: &[TextDraw],
5651        shadow_draws: &[ShadowDraw],
5652        retained_draws: &[RetainedDraw],
5653        draw_ops: &[DrawOp],
5654        z_start: usize,
5655        z_end: usize,
5656        effect_z_ranges: &[Range<usize>],
5657        width: u32,
5658        height: u32,
5659        root_scale: f32,
5660        initial_load_op: wgpu::LoadOp<wgpu::Color>,
5661    ) -> Result<(), String> {
5662        self.render_non_effect_segment_with_composites(
5663            target_view,
5664            shapes,
5665            images,
5666            texts,
5667            shadow_draws,
5668            retained_draws,
5669            draw_ops,
5670            z_start,
5671            z_end,
5672            effect_z_ranges,
5673            &[],
5674            &[],
5675            width,
5676            height,
5677            root_scale,
5678            initial_load_op,
5679        )
5680    }
5681
5682    #[allow(clippy::too_many_arguments)]
5683    fn render_non_effect_segment_with_composites(
5684        &mut self,
5685        target_view: &wgpu::TextureView,
5686        shapes: &[DrawShape],
5687        images: &[ImageDraw],
5688        texts: &[TextDraw],
5689        shadow_draws: &[ShadowDraw],
5690        retained_draws: &[RetainedDraw],
5691        draw_ops: &[DrawOp],
5692        z_start: usize,
5693        z_end: usize,
5694        effect_z_ranges: &[Range<usize>],
5695        composites: &[(usize, CompositeBatchItem<'_>)],
5696        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
5697        width: u32,
5698        height: u32,
5699        root_scale: f32,
5700        initial_load_op: wgpu::LoadOp<wgpu::Color>,
5701    ) -> Result<(), String> {
5702        let mut ordered_items = std::mem::take(&mut self.renderer.scratch_segment_items);
5703        collect_non_effect_segment_items(
5704            shapes,
5705            images,
5706            texts,
5707            shadow_draws,
5708            draw_ops,
5709            z_start,
5710            z_end,
5711            effect_z_ranges,
5712            width,
5713            height,
5714            root_scale,
5715            &mut ordered_items,
5716        );
5717        #[cfg(not(target_arch = "wasm32"))]
5718        let raw_shadow_items = ordered_items
5719            .iter()
5720            .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
5721            .count();
5722        let culled_shadow_items = retain_renderable_shadow_items(
5723            &mut ordered_items,
5724            shadow_draws,
5725            width,
5726            height,
5727            root_scale,
5728            self.renderer.max_texture_dim(),
5729        );
5730        #[cfg(target_arch = "wasm32")]
5731        let _ = culled_shadow_items;
5732        let mut cached_shadow_composites: Vec<(usize, CachedShadowComposite)> = Vec::new();
5733        ordered_items.extend(
5734            composites
5735                .iter()
5736                .enumerate()
5737                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::Composite(index))),
5738        );
5739        ordered_items.extend(
5740            shader_composites
5741                .iter()
5742                .enumerate()
5743                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::ShaderComposite(index))),
5744        );
5745        for (z_index, item) in &mut ordered_items {
5746            let SegmentDrawItem::Shadow(shadow_index) = *item else {
5747                continue;
5748            };
5749            let Some(composite) = self.renderer.cached_shape_shadow_composite(
5750                &shadow_draws[shadow_index],
5751                width,
5752                height,
5753                root_scale,
5754            ) else {
5755                continue;
5756            };
5757            let composite_index = composites.len() + cached_shadow_composites.len();
5758            cached_shadow_composites.push((*z_index, composite));
5759            *item = SegmentDrawItem::Composite(composite_index);
5760        }
5761        let mut merged_composites = Vec::with_capacity(
5762            composites
5763                .len()
5764                .saturating_add(cached_shadow_composites.len()),
5765        );
5766        merged_composites.extend(composites.iter().copied());
5767        merged_composites.extend(
5768            cached_shadow_composites
5769                .iter()
5770                .map(|(z_index, composite)| (*z_index, composite.batch_item())),
5771        );
5772        // Z indices are unique — the scene hands every op its own `next_z` — so an
5773        // unstable sort cannot reorder anything a stable one wouldn't, and it skips
5774        // the stable sort's scratch allocation, paid here once per segment per frame.
5775        ordered_items.sort_unstable_by_key(|(z_index, _)| *z_index);
5776        #[cfg(not(target_arch = "wasm32"))]
5777        maybe_print_segment_diag(
5778            z_start..z_end,
5779            &ordered_items,
5780            shapes,
5781            images,
5782            SegmentDiagCounts {
5783                raw_shadow_items,
5784                culled_shadow_items,
5785                cached_shadow_composites: cached_shadow_composites.len(),
5786                composite_items: merged_composites.len(),
5787                shader_composite_items: shader_composites.len(),
5788            },
5789            self.renderer.shape_batch_limits,
5790        );
5791        let result = if ordered_items.is_empty() {
5792            Ok(SegmentCommandEncodeOutcome { first_batch: true })
5793        } else {
5794            self.renderer.encode_non_effect_segment_commands(
5795                self.recorder,
5796                target_view,
5797                &ordered_items,
5798                &merged_composites,
5799                shader_composites,
5800                shapes,
5801                images,
5802                texts,
5803                shadow_draws,
5804                retained_draws,
5805                initial_load_op,
5806                width,
5807                height,
5808                root_scale,
5809            )
5810        };
5811        self.renderer.scratch_segment_items = ordered_items;
5812        let outcome = result?;
5813        if outcome.first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
5814            self.clear_target_view_with_load_op(target_view, initial_load_op);
5815        }
5816        Ok(())
5817    }
5818
5819    fn render_range_with_layer_events_to_target(
5820        &mut self,
5821        target: &OffscreenTarget,
5822        shapes: &[DrawShape],
5823        images: &[ImageDraw],
5824        texts: &[TextDraw],
5825        shadow_draws: &[ShadowDraw],
5826        draw_ops: &[DrawOp],
5827        effect_layers: &[EffectLayer],
5828        backdrop_layers: &[BackdropLayer],
5829        z_start: usize,
5830        z_end: usize,
5831        excluded_effect_layer: Option<usize>,
5832        width: u32,
5833        height: u32,
5834        root_scale: f32,
5835        backdrop_underlay: Option<&OffscreenTarget>,
5836        initial_load_op: wgpu::LoadOp<wgpu::Color>,
5837    ) -> Result<(), String> {
5838        self.render_range_with_layer_events_to_target_recorded(
5839            target,
5840            shapes,
5841            images,
5842            texts,
5843            shadow_draws,
5844            draw_ops,
5845            effect_layers,
5846            backdrop_layers,
5847            z_start,
5848            z_end,
5849            excluded_effect_layer,
5850            width,
5851            height,
5852            root_scale,
5853            backdrop_underlay,
5854            initial_load_op,
5855        )
5856    }
5857
5858    fn render_shadow_draw(
5859        &mut self,
5860        target_view: &wgpu::TextureView,
5861        shadow: &ShadowDraw,
5862        width: u32,
5863        height: u32,
5864        root_scale: f32,
5865    ) {
5866        self.renderer.encode_shadow_draw(
5867            self.recorder,
5868            target_view,
5869            shadow,
5870            width,
5871            height,
5872            root_scale,
5873        );
5874    }
5875
5876    fn composite_to_view_projective(
5877        &mut self,
5878        source: &OffscreenTarget,
5879        dest_view: &wgpu::TextureView,
5880        viewport: (u32, u32),
5881        source_size: (f32, f32),
5882        inverse_matrix: [[f32; 3]; 3],
5883        dest_bounds: [[f32; 2]; 4],
5884        alpha: f32,
5885        load_op: wgpu::LoadOp<wgpu::Color>,
5886        scissor: Option<(u32, u32, u32, u32)>,
5887        blend_mode: BlendMode,
5888        sample_mode: CompositeSampleMode,
5889    ) {
5890        let device = self.renderer.device.clone();
5891        let composited = {
5892            self.renderer
5893                .effect_renderer
5894                .encode_composite_to_view_projective(
5895                    self.recorder,
5896                    &device,
5897                    source,
5898                    dest_view,
5899                    viewport,
5900                    source_size,
5901                    inverse_matrix,
5902                    dest_bounds,
5903                    alpha,
5904                    load_op,
5905                    scissor,
5906                    supported_blend_mode(blend_mode),
5907                    sample_mode,
5908                )
5909        };
5910        if composited {
5911            self.recorder.record_pass();
5912            self.renderer.effect_renderer.record_composite_pass();
5913        }
5914    }
5915
5916    fn composite_projective_surfaces_to_view(
5917        &mut self,
5918        dest_view: &wgpu::TextureView,
5919        viewport: (u32, u32),
5920        composites: &[ProjectiveSurfaceComposite<'_>],
5921    ) {
5922        let device = self.renderer.device.clone();
5923        let mut composite_count = 0_u32;
5924        for composite in composites
5925            .iter()
5926            .copied()
5927            .filter(|composite| projective_dest_bounds_rect(composite.dest_bounds).is_some())
5928        {
5929            let composited = {
5930                self.renderer
5931                    .effect_renderer
5932                    .encode_composite_to_view_projective(
5933                        self.recorder,
5934                        &device,
5935                        composite.source,
5936                        dest_view,
5937                        viewport,
5938                        composite.source_size,
5939                        composite.inverse_matrix,
5940                        composite.dest_bounds,
5941                        composite.alpha,
5942                        composite.load_op,
5943                        composite.scissor,
5944                        supported_blend_mode(composite.blend_mode),
5945                        composite.sample_mode,
5946                    )
5947            };
5948            if composited {
5949                composite_count = composite_count.saturating_add(1);
5950            }
5951        }
5952        if composite_count > 0 {
5953            self.recorder.record_passes(composite_count);
5954            self.renderer
5955                .effect_renderer
5956                .debug_composites
5957                .set(self.renderer.effect_renderer.debug_composites.get() + composite_count);
5958        }
5959    }
5960
5961    fn composite_surface_batch_to_view(
5962        &mut self,
5963        dest_view: &wgpu::TextureView,
5964        viewport: (u32, u32),
5965        load_op: wgpu::LoadOp<wgpu::Color>,
5966        composites: &[CompositeBatchItem<'_>],
5967    ) {
5968        if composites.is_empty() {
5969            return;
5970        }
5971        let device = self.renderer.device.clone();
5972        self.renderer
5973            .effect_renderer
5974            .encode_composite_batch_to_view_pass(
5975                self.recorder,
5976                &device,
5977                dest_view,
5978                viewport,
5979                load_op,
5980                composites,
5981            );
5982        self.recorder.record_pass();
5983        self.renderer.effect_renderer.record_composite_pass();
5984    }
5985
5986    fn copy_texture_region_to_target(
5987        &mut self,
5988        source: &OffscreenTarget,
5989        source_origin: (u32, u32),
5990        target: &OffscreenTarget,
5991        size: (u32, u32),
5992    ) -> bool {
5993        let (width, height) = size;
5994        if width == 0 || height == 0 || width > target.width || height > target.height {
5995            return false;
5996        }
5997        let Some(source_right) = source_origin.0.checked_add(width) else {
5998            return false;
5999        };
6000        let Some(source_bottom) = source_origin.1.checked_add(height) else {
6001            return false;
6002        };
6003        if source_right > source.width || source_bottom > source.height {
6004            return false;
6005        }
6006
6007        self.recorder.encoder().copy_texture_to_texture(
6008            wgpu::TexelCopyTextureInfo {
6009                texture: source.texture(),
6010                mip_level: 0,
6011                origin: wgpu::Origin3d {
6012                    x: source_origin.0,
6013                    y: source_origin.1,
6014                    z: 0,
6015                },
6016                aspect: wgpu::TextureAspect::All,
6017            },
6018            wgpu::TexelCopyTextureInfo {
6019                texture: target.texture(),
6020                mip_level: 0,
6021                origin: wgpu::Origin3d::ZERO,
6022                aspect: wgpu::TextureAspect::All,
6023            },
6024            wgpu::Extent3d {
6025                width,
6026                height,
6027                depth_or_array_layers: 1,
6028            },
6029        );
6030        true
6031    }
6032
6033    fn shader_composite_batch_to_view(
6034        &mut self,
6035        dest_view: &wgpu::TextureView,
6036        viewport: (u32, u32),
6037        load_op: wgpu::LoadOp<wgpu::Color>,
6038        composites: &[ShaderCompositeBatchItem<'_>],
6039    ) -> bool {
6040        if composites.is_empty() {
6041            return true;
6042        }
6043        let device = self.renderer.device.clone();
6044        let encoded = self
6045            .renderer
6046            .effect_renderer
6047            .encode_shader_batch_src_over_to_view(
6048                self.recorder,
6049                &device,
6050                dest_view,
6051                viewport,
6052                load_op,
6053                composites,
6054            );
6055        if encoded {
6056            self.recorder.record_pass();
6057            self.renderer.effect_renderer.record_composite_pass();
6058            self.renderer
6059                .effect_renderer
6060                .debug_effects
6061                .set(self.renderer.effect_renderer.debug_effects.get() + composites.len() as u32);
6062        }
6063        encoded
6064    }
6065
6066    fn composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
6067        &mut self,
6068        source: &OffscreenTarget,
6069        dest_view: &wgpu::TextureView,
6070        alpha: f32,
6071        load_op: wgpu::LoadOp<wgpu::Color>,
6072        scissor: Option<(u32, u32, u32, u32)>,
6073        rounded_mask: Option<RoundedCompositeMask>,
6074        blend_mode: BlendMode,
6075        dest_viewport: Option<(f32, f32, f32, f32)>,
6076        sample_mode: CompositeSampleMode,
6077    ) {
6078        let device = self.renderer.device.clone();
6079        {
6080            self.renderer
6081                .effect_renderer
6082                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
6083                    self.recorder,
6084                    &device,
6085                    source,
6086                    dest_view,
6087                    alpha,
6088                    load_op,
6089                    scissor,
6090                    rounded_mask,
6091                    supported_blend_mode(blend_mode),
6092                    dest_viewport,
6093                    sample_mode,
6094                );
6095        }
6096        self.recorder.record_pass();
6097        self.renderer.effect_renderer.record_composite_pass();
6098    }
6099
6100    fn apply_effect_and_composite_to_view(
6101        &mut self,
6102        source: &OffscreenTarget,
6103        effect: &RenderEffect,
6104        effect_rect: [f32; 4],
6105        dest_view: &wgpu::TextureView,
6106        alpha: f32,
6107        load_op: wgpu::LoadOp<wgpu::Color>,
6108        scissor: Option<(u32, u32, u32, u32)>,
6109        blend_mode: BlendMode,
6110        dest_viewport: Option<(f32, f32, f32, f32)>,
6111        sample_mode: CompositeSampleMode,
6112    ) -> Result<(), String> {
6113        self.record_effect_composite(
6114            source,
6115            effect,
6116            effect_rect,
6117            dest_view,
6118            alpha,
6119            load_op,
6120            scissor,
6121            blend_mode,
6122            dest_viewport,
6123            sample_mode,
6124        )
6125    }
6126
6127    fn apply_shader_and_composite_to_view(
6128        &mut self,
6129        source: &OffscreenTarget,
6130        shader: &RuntimeShader,
6131        effect_rect: [f32; 4],
6132        dest_view: &wgpu::TextureView,
6133        alpha: f32,
6134        load_op: wgpu::LoadOp<wgpu::Color>,
6135        scissor: Option<(u32, u32, u32, u32)>,
6136        blend_mode: BlendMode,
6137        dest_viewport: Option<(f32, f32, f32, f32)>,
6138        sample_mode: CompositeSampleMode,
6139    ) {
6140        self.record_shader_composite(
6141            source,
6142            shader,
6143            effect_rect,
6144            dest_view,
6145            alpha,
6146            load_op,
6147            scissor,
6148            blend_mode,
6149            dest_viewport,
6150            sample_mode,
6151        );
6152    }
6153
6154    fn apply_shader_and_composite_to_view_projective(
6155        &mut self,
6156        source: &OffscreenTarget,
6157        shader: &RuntimeShader,
6158        effect_rect: [f32; 4],
6159        dest_view: &wgpu::TextureView,
6160        viewport: (u32, u32),
6161        source_size: (f32, f32),
6162        inverse_matrix: [[f32; 3]; 3],
6163        dest_bounds: [[f32; 2]; 4],
6164        alpha: f32,
6165        load_op: wgpu::LoadOp<wgpu::Color>,
6166        scissor: Option<(u32, u32, u32, u32)>,
6167        blend_mode: BlendMode,
6168        sample_mode: CompositeSampleMode,
6169    ) {
6170        self.record_shader_projective_composite(
6171            source,
6172            shader,
6173            effect_rect,
6174            dest_view,
6175            viewport,
6176            source_size,
6177            inverse_matrix,
6178            dest_bounds,
6179            alpha,
6180            load_op,
6181            scissor,
6182            blend_mode,
6183            sample_mode,
6184        );
6185    }
6186
6187    fn apply_effect_and_composite_to_view_projective(
6188        &mut self,
6189        source: &OffscreenTarget,
6190        effect: &RenderEffect,
6191        effect_rect: [f32; 4],
6192        dest_view: &wgpu::TextureView,
6193        viewport: (u32, u32),
6194        source_size: (f32, f32),
6195        inverse_matrix: [[f32; 3]; 3],
6196        dest_bounds: [[f32; 2]; 4],
6197        alpha: f32,
6198        load_op: wgpu::LoadOp<wgpu::Color>,
6199        scissor: Option<(u32, u32, u32, u32)>,
6200        blend_mode: BlendMode,
6201        sample_mode: CompositeSampleMode,
6202    ) -> Result<(), String> {
6203        self.record_effect_projective_composite(
6204            source,
6205            effect,
6206            effect_rect,
6207            dest_view,
6208            viewport,
6209            source_size,
6210            inverse_matrix,
6211            dest_bounds,
6212            alpha,
6213            load_op,
6214            scissor,
6215            blend_mode,
6216            sample_mode,
6217        )
6218    }
6219
6220    fn is_render_effect_supported(&self, effect: &RenderEffect) -> bool {
6221        self.renderer.supports_render_effect(effect)
6222    }
6223
6224    fn warn_unsupported_effect_once(&self) {
6225        self.renderer.warning_state.warn_unsupported_effect_once();
6226    }
6227
6228    fn record_layer_cache_miss(&self, width: u32, height: u32) {
6229        self.renderer
6230            .frame_stats
6231            .record_layer_cache_miss(width, height);
6232    }
6233
6234    fn record_isolated_layer_render(
6235        &self,
6236        width: u32,
6237        height: u32,
6238        node_id: Option<NodeId>,
6239        logical_rect: Rect,
6240        requirements: SurfaceRequirementSet,
6241    ) {
6242        self.renderer.frame_stats.record_isolated_layer_render(
6243            width,
6244            height,
6245            node_id,
6246            logical_rect,
6247            requirements.into(),
6248        );
6249    }
6250}
6251
6252impl GpuRenderer {
6253    pub fn render(
6254        &mut self,
6255        view: &wgpu::TextureView,
6256        width: u32,
6257        height: u32,
6258        packet: FramePacket,
6259        surface_epoch: u64,
6260        returns: &mut RenderReturns,
6261    ) -> Result<(), String> {
6262        // Packet validity gate — BEFORE consume_replay_ops and any
6263        // encoding. A packet built against another renderer instance,
6264        // another surface configuration, or another viewport is cancelled
6265        // whole: its buffers travel back through `returns` for re-queue
6266        // and recycling, and nothing of it reaches the GPU.
6267        let cancel_reason = if packet.renderer_epoch != self.renderer_epoch {
6268            Some(CancelReason::RendererEpoch)
6269        } else if packet.surface_epoch != surface_epoch {
6270            Some(CancelReason::SurfaceEpoch)
6271        } else if packet.viewport != (width, height) {
6272            Some(CancelReason::Viewport)
6273        } else {
6274            None
6275        };
6276        if let Some(reason) = cancel_reason {
6277            return Self::cancel_packet(packet, reason, returns);
6278        }
6279        returns.frame_id = packet.frame_id;
6280        log::trace!("🎨 Rendering graph to {}x{}", width, height);
6281        let render_start = Instant::now();
6282
6283        #[cfg(target_arch = "wasm32")]
6284        {
6285            self.wasm_uniform_batch_cursor = 0;
6286            self.wasm_shape_batch_cursor = 0;
6287            self.wasm_image_batch_cursor = 0;
6288        }
6289        #[cfg(not(target_arch = "wasm32"))]
6290        {
6291            self.retained_glyph_uniform_cursor = 0;
6292        }
6293
6294        // Producer-side text layout cache size, carried by the packet — the
6295        // present call tree holds no text layout state, and no layout runs
6296        // between packet build and the stats block below.
6297        let text_cache_len = packet.text_cache_len;
6298        let result = self.render_graph(view, packet, returns);
6299        let after_graph = Instant::now();
6300        self.flush_deferred_offscreen_releases();
6301
6302        #[cfg(target_arch = "wasm32")]
6303        {
6304            const WASM_BATCH_POOL_MARGIN: usize = 4;
6305            self.wasm_uniform_batches.truncate(
6306                self.wasm_uniform_batch_cursor
6307                    .saturating_add(WASM_BATCH_POOL_MARGIN),
6308            );
6309            self.wasm_shape_batches.truncate(
6310                self.wasm_shape_batch_cursor
6311                    .saturating_add(WASM_BATCH_POOL_MARGIN),
6312            );
6313            self.wasm_image_batches.truncate(
6314                self.wasm_image_batch_cursor
6315                    .saturating_add(WASM_BATCH_POOL_MARGIN),
6316            );
6317        }
6318        self.staged_uploads
6319            .shrink_retained_capacity(RETAINED_STAGED_UPLOAD_BYTES, RETAINED_STAGED_UPLOAD_COPIES);
6320
6321        self.layer_surface_cache.finish_frame(&self.frame_stats);
6322        #[cfg(not(target_arch = "wasm32"))]
6323        self.retained_bundle_cache.end_frame();
6324
6325        self.frame_stats.offscreen_pool_size.set(
6326            self.effect_renderer
6327                .retained_offscreen_count()
6328                .saturating_add(self.frame_graph_executor.retained_texture_count())
6329                as u32,
6330        );
6331        self.frame_stats.offscreen_pool_bytes.set(
6332            (self.effect_renderer.retained_offscreen_bytes() as u64)
6333                .saturating_add(self.frame_graph_executor.retained_texture_bytes()),
6334        );
6335        self.frame_stats
6336            .text_pool_size
6337            .set(self.text_image_cache.len() as u32);
6338        self.frame_stats
6339            .image_cache_size
6340            .set(self.image_texture_cache.len() as u32);
6341        self.frame_stats.text_cache_size.set(text_cache_len as u32);
6342        self.effect_renderer
6343            .merge_and_reset_debug_counters(&self.frame_stats);
6344        self.frame_graph_executor.reset_upload_allocators();
6345        let snapshot = self.frame_stats.snapshot();
6346        self.last_frame_stats = Some(snapshot);
6347        PRESENTED_FRAMES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6348        update_frame_warmup_budget(&mut self.pending_frame_warmup_frames, &snapshot);
6349        self.frame_stats.maybe_print_snapshot(
6350            snapshot,
6351            &mut self.frame_count,
6352            self.gpu_stats_enabled,
6353        );
6354        if self.gpu_stats_enabled && self.frame_count.is_multiple_of(60) {
6355            gpu_stats::print_gpu_memory_report(&self.device, self.frame_count);
6356        }
6357        self.frame_stats.reset();
6358        let after_stats = Instant::now();
6359        if let Some(total_ms) = should_log_wgpu_render_stage(render_start, after_stats) {
6360            log::warn!(
6361                "[wgpu-render-stage:render] total_ms={total_ms:.2} graph_ms={:.2} cleanup_stats_ms={:.2}",
6362                instant_ms(render_start, after_graph),
6363                instant_ms(after_graph, after_stats),
6364            );
6365        }
6366        if result.is_ok() {
6367            // Only a draw that actually ran may report `Presented`; an
6368            // errored draw leaves the default `NotRun`.
6369            returns.outcome = PresentOutcome::Presented;
6370        }
6371        result
6372    }
6373
6374    /// Refuses a packet whole, before any encoding: every buffer it
6375    /// carries travels back through `returns` — the direct scene for the
6376    /// producer pool, the unconsumed replay plan for the planner to
6377    /// re-queue (its releases name still-live store slots; dropping them
6378    /// would leak pool ids forever). A cancel is a protocol outcome, not a
6379    /// draw error, so the render call returns `Ok(())`.
6380    fn cancel_packet(
6381        packet: FramePacket,
6382        reason: CancelReason,
6383        returns: &mut RenderReturns,
6384    ) -> Result<(), String> {
6385        let FramePacket {
6386            frame_id,
6387            viewport: _,
6388            renderer_epoch: _,
6389            surface_epoch: _,
6390            root_scale: _,
6391            root,
6392            overlay: _,
6393            replay,
6394            text_cache_len: _,
6395        } = packet;
6396        match root {
6397            PacketRoot::Direct(root) => {
6398                // Destructure: the scene buffers return to the producer
6399                // pool; the rest of the collected layer drops. A Direct
6400                // packet's replay plan came from the planner and must go
6401                // back to it unconsumed — a Surface packet only ever
6402                // carries the empty default plan, which has nothing to
6403                // reclaim.
6404                returns.scene = Some(root.scene);
6405                #[cfg(not(target_arch = "wasm32"))]
6406                {
6407                    returns.cancelled_replay = Some(replay);
6408                }
6409            }
6410            PacketRoot::Surface(_) => {}
6411        }
6412        #[cfg(target_arch = "wasm32")]
6413        let _ = replay;
6414        returns.ack = None;
6415        returns.frame_id = frame_id;
6416        returns.outcome = PresentOutcome::Cancelled(reason);
6417        Ok(())
6418    }
6419
6420    pub fn last_frame_stats(&self) -> Option<gpu_stats::FrameStatsSnapshot> {
6421        self.last_frame_stats
6422    }
6423
6424    pub fn needs_frame_warmup(&self) -> bool {
6425        self.pending_frame_warmup_frames > 0
6426    }
6427
6428    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
6429        let layer_surface_cache_stats = self.layer_surface_cache.debug_stats();
6430        DebugCpuAllocationStats {
6431            scene_graph_node_count: 0,
6432            scene_graph_heap_bytes: 0,
6433            scene_hits_len: 0,
6434            scene_hits_cap: 0,
6435            scene_node_index_len: 0,
6436            scene_node_index_cap: 0,
6437            text_renderer_pool_len: self.text_image_cache.len(),
6438            text_renderer_pool_cap: self.text_image_cache.cap().get(),
6439            swash_image_cache_len: 0,
6440            swash_image_cache_cap: 0,
6441            swash_outline_cache_len: 0,
6442            swash_outline_cache_cap: 0,
6443            image_texture_cache_len: self.image_texture_cache.len(),
6444            image_texture_cache_cap: self.image_texture_cache.cap().get(),
6445            scratch_shape_data_cap: self.scratch_shape_data.capacity(),
6446            scratch_gradients_cap: self.scratch_gradients.capacity(),
6447            scratch_image_vertices_cap: self.scratch_image_vertices.capacity(),
6448            scratch_image_indices_cap: self.scratch_image_indices.capacity(),
6449            scratch_image_cmds_cap: self.scratch_image_cmds.capacity(),
6450            scratch_segment_items_cap: self.scratch_segment_items.capacity(),
6451            scratch_effect_ranges_cap: self.scratch_effect_ranges.capacity(),
6452            scratch_layer_events_cap: self.scratch_layer_events.capacity(),
6453            staged_upload_bytes_cap: self.staged_uploads.bytes.capacity(),
6454            staged_upload_copies_cap: self.staged_uploads.copies.capacity(),
6455            layer_surface_cache_len: layer_surface_cache_stats.entries_len,
6456            layer_surface_cache_cap: layer_surface_cache_stats.entries_cap,
6457            layer_surface_cache_identity_len: layer_surface_cache_stats.identity_len,
6458            layer_surface_cache_identity_cap: layer_surface_cache_stats.identity_cap,
6459            // The producer frontend owns the only lowering-memo pair since
6460            // step 6b; the present backend contributes nothing.
6461            layer_surface_rect_cache_len: 0,
6462            layer_surface_rect_cache_cap: 0,
6463            layer_surface_requirements_cache_len: 0,
6464            layer_surface_requirements_cache_cap: 0,
6465            layer_cache_seen_this_frame_len: layer_surface_cache_stats.seen_this_frame_len,
6466            layer_cache_seen_this_frame_cap: layer_surface_cache_stats.seen_this_frame_cap,
6467        }
6468    }
6469
6470    pub fn render_to_rgba_pixels(
6471        &mut self,
6472        width: u32,
6473        height: u32,
6474        packet: FramePacket,
6475        surface_epoch: u64,
6476        returns: &mut RenderReturns,
6477    ) -> Result<Vec<u8>, String> {
6478        if width == 0 || height == 0 {
6479            return Err("Screenshot size must be non-zero".to_string());
6480        }
6481
6482        let output_texture = self.device.create_texture(&wgpu::TextureDescriptor {
6483            label: Some("Screenshot Output Texture"),
6484            size: wgpu::Extent3d {
6485                width,
6486                height,
6487                depth_or_array_layers: 1,
6488            },
6489            mip_level_count: 1,
6490            sample_count: 1,
6491            dimension: wgpu::TextureDimension::D2,
6492            format: self.surface_format,
6493            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
6494            view_formats: &[],
6495        });
6496        let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
6497
6498        self.render(&output_view, width, height, packet, surface_epoch, returns)?;
6499
6500        let bytes_per_pixel = 4u32;
6501        let unpadded_bytes_per_row = width
6502            .checked_mul(bytes_per_pixel)
6503            .ok_or_else(|| "Screenshot row byte size overflow".to_string())?;
6504        let padded_bytes_per_row =
6505            align_to(unpadded_bytes_per_row, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
6506        let output_buffer_size = padded_bytes_per_row as u64 * height as u64;
6507
6508        let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
6509            label: Some("Screenshot Readback Buffer"),
6510            size: output_buffer_size,
6511            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
6512            mapped_at_creation: false,
6513        });
6514
6515        let device = self.device.clone();
6516        let queue = self.queue.clone();
6517        let mut graph = WgpuFrameGraph::new(Some("Screenshot Copy Encoder"));
6518        let source = graph.import_surface("screenshot-copy-source");
6519        graph.add_fallible_command_pass(Some("Screenshot Copy Pass"), &[source], &[], |context| {
6520            context.encoder.copy_texture_to_buffer(
6521                wgpu::TexelCopyTextureInfo {
6522                    texture: &output_texture,
6523                    mip_level: 0,
6524                    origin: wgpu::Origin3d::ZERO,
6525                    aspect: wgpu::TextureAspect::All,
6526                },
6527                wgpu::TexelCopyBufferInfo {
6528                    buffer: &output_buffer,
6529                    layout: wgpu::TexelCopyBufferLayout {
6530                        offset: 0,
6531                        bytes_per_row: Some(padded_bytes_per_row),
6532                        rows_per_image: Some(height),
6533                    },
6534                },
6535                wgpu::Extent3d {
6536                    width,
6537                    height,
6538                    depth_or_array_layers: 1,
6539                },
6540            );
6541            Ok(())
6542        });
6543        let mut executor = std::mem::take(&mut self.frame_graph_executor);
6544        let execution = executor.execute_recorded_graph(&device, &queue, graph);
6545        self.frame_graph_executor = executor;
6546        let execution = execution.map_err(|error| error.to_string())?;
6547        let submission_index = execution.submission;
6548        let copy_stats = execution.stats;
6549        self.last_frame_stats = self
6550            .last_frame_stats
6551            .map(|snapshot| snapshot.with_command_stats_added(copy_stats));
6552
6553        let buffer_slice = output_buffer.slice(..);
6554        let (tx, rx) = mpsc::channel();
6555        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
6556            let _ = tx.send(result);
6557        });
6558        let _ = self.device.poll(wgpu::PollType::Wait {
6559            submission_index: Some(submission_index),
6560            timeout: None,
6561        });
6562
6563        match rx.recv_timeout(Duration::from_secs(3)) {
6564            Ok(Ok(())) => {}
6565            Ok(Err(err)) => return Err(format!("Screenshot map_async failed: {err:?}")),
6566            Err(err) => return Err(format!("Screenshot readback timed out: {err}")),
6567        }
6568
6569        let mapped = buffer_slice.get_mapped_range();
6570        let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4];
6571
6572        let src_row_len = padded_bytes_per_row as usize;
6573        let dst_row_len = unpadded_bytes_per_row as usize;
6574        for row in 0..height as usize {
6575            let src_offset = row * src_row_len;
6576            let dst_offset = row * dst_row_len;
6577            pixels[dst_offset..dst_offset + dst_row_len]
6578                .copy_from_slice(&mapped[src_offset..src_offset + dst_row_len]);
6579        }
6580        drop(mapped);
6581        output_buffer.unmap();
6582
6583        self.convert_surface_pixels_to_rgba(&mut pixels)?;
6584        Ok(pixels)
6585    }
6586
6587    fn render_graph(
6588        &mut self,
6589        surface_view: &wgpu::TextureView,
6590        packet: FramePacket,
6591        returns: &mut RenderReturns,
6592    ) -> Result<(), String> {
6593        let device = self.device.clone();
6594        let queue = self.queue.clone();
6595        let graph_start = Instant::now();
6596
6597        #[cfg(not(target_arch = "wasm32"))]
6598        {
6599            let mut executor = std::mem::take(&mut self.frame_graph_executor);
6600            let mut frame_graph = WgpuFrameGraph::new(Some("Renderer Frame Graph"));
6601            let surface = frame_graph.import_surface("renderer-surface");
6602            frame_graph.add_fallible_recorded_command_pass(
6603                Some("Renderer Frame Pass"),
6604                &[],
6605                &[surface],
6606                |frame_encoder| {
6607                    self.render_graph_recorded(surface_view, packet, returns, frame_encoder)
6608                },
6609            );
6610            let after_build = Instant::now();
6611            let execution = executor.execute_recorded_graph(&device, &queue, frame_graph);
6612            let after_execute = Instant::now();
6613            self.frame_graph_executor = executor;
6614            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
6615                log::warn!(
6616                    "[wgpu-render-stage:graph] total_ms={total_ms:.2} build_ms={:.2} execute_ms={:.2}",
6617                    instant_ms(graph_start, after_build),
6618                    instant_ms(after_build, after_execute),
6619                );
6620            }
6621
6622            match execution {
6623                Ok(execution) => {
6624                    if execution.stats.pass_count > 0 {
6625                        self.frame_stats.record_command_stats(execution.stats);
6626                    }
6627                    Ok(())
6628                }
6629                Err(crate::frame_graph::FrameGraphError::NoDeclaredPasses) => Ok(()),
6630                Err(error) => Err(error.to_string()),
6631            }
6632        }
6633
6634        #[cfg(target_arch = "wasm32")]
6635        {
6636            let mut executor = std::mem::take(&mut self.frame_graph_executor);
6637            let (result, execution) = {
6638                let mut frame_encoder =
6639                    executor.begin(&device, &queue, Some("Renderer Frame Encoder"));
6640                let initial_pass_count = frame_encoder.recorded_pass_count();
6641                let result =
6642                    self.render_graph_recorded(surface_view, packet, returns, &mut frame_encoder);
6643                let execution =
6644                    if result.is_ok() && frame_encoder.recorded_pass_count() > initial_pass_count {
6645                        Some(frame_encoder.finish())
6646                    } else {
6647                        None
6648                    };
6649                (result, execution)
6650            };
6651            let after_execute = Instant::now();
6652            self.frame_graph_executor = executor;
6653            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
6654                log::warn!("[wgpu-render-stage:graph] total_ms={total_ms:.2}",);
6655            }
6656            if let Some(execution) = execution {
6657                self.frame_stats.record_command_stats(execution.stats);
6658            }
6659            result
6660        }
6661    }
6662
6663    fn render_graph_recorded<C: FrameCommandRecorder>(
6664        &mut self,
6665        surface_view: &wgpu::TextureView,
6666        packet: FramePacket,
6667        returns: &mut RenderReturns,
6668        frame_encoder: &mut C,
6669    ) -> Result<(), String> {
6670        let recorded_start = Instant::now();
6671
6672        // Present-side consumption of the packet's replay plan, adjacent to
6673        // packet consumption: the store honors the ops just before the
6674        // packet renders. Gated on a Direct root — a Surface packet never
6675        // touched the planner and carries the empty default plan
6676        // (generation 0), which the store must not consume: it would count
6677        // a false generation drop. The ack travels back through `returns`
6678        // and the producer applies it right after this render call —
6679        // equivalent to the in-store drain this replaces, because both
6680        // application points sit after this frame's graph build and before
6681        // the next collect, which is where the bypass gate and `feed_slots`
6682        // are read.
6683        #[cfg(not(target_arch = "wasm32"))]
6684        let mut packet = packet;
6685        #[cfg(not(target_arch = "wasm32"))]
6686        if let PacketRoot::Direct(root) = &packet.root {
6687            let ops = std::mem::take(&mut packet.replay);
6688            let (ack, recycled) =
6689                self.consume_replay_ops(ops, &root.scene.shapes, packet.root_scale);
6690            returns.ack = Some((ack, recycled));
6691        }
6692
6693        let FramePacket {
6694            frame_id,
6695            viewport: (width, height),
6696            renderer_epoch: _,
6697            surface_epoch: _,
6698            root_scale,
6699            root,
6700            overlay,
6701            replay: _,
6702            text_cache_len: _,
6703        } = packet;
6704
6705        let mut backend = RecordingSurfaceBackend {
6706            renderer: self,
6707            recorder: frame_encoder,
6708        };
6709
6710        let surface_packet = match root {
6711            PacketRoot::Direct(root) => {
6712                let direct_render_start = Instant::now();
6713                let result = match execute_render_root_direct(
6714                    &mut backend,
6715                    surface_view,
6716                    *root,
6717                    width,
6718                    height,
6719                    root_scale,
6720                    wgpu::LoadOp::Clear(CLEAR_COLOR),
6721                ) {
6722                    // Return the packet's scene buffers to the producer pool
6723                    // in BOTH arms — for a heavy animated frame they are
6724                    // megabytes of Vec, and an errored draw must not leak
6725                    // them.
6726                    Ok(scene) => {
6727                        returns.scene = Some(scene);
6728                        Ok(())
6729                    }
6730                    Err((error, scene)) => {
6731                        returns.scene = Some(scene);
6732                        Err(error)
6733                    }
6734                };
6735                if result.is_ok() {
6736                    if let Some(overlay) = overlay {
6737                        Self::render_overlay_packet(
6738                            &mut backend,
6739                            surface_view,
6740                            overlay,
6741                            width,
6742                            height,
6743                            root_scale,
6744                        )?;
6745                    }
6746                }
6747                let after_direct_render = Instant::now();
6748                if let Some(total_ms) =
6749                    should_log_wgpu_render_stage(recorded_start, after_direct_render)
6750                {
6751                    log::warn!(
6752                        "[wgpu-render-stage:recorded-direct-root] frame={frame_id} total_ms={total_ms:.2} render_ms={:.2}",
6753                        instant_ms(direct_render_start, after_direct_render),
6754                    );
6755                }
6756                return result;
6757            }
6758            PacketRoot::Surface(surface_packet) => surface_packet,
6759        };
6760        let after_root_collect = Instant::now();
6761
6762        let RootSurfacePacket {
6763            lowered,
6764            source,
6765            transform_to_parent,
6766            node_id,
6767            backdrop,
6768            graphics_layer,
6769            local_bounds,
6770            clip_rect,
6771            shadow_clip,
6772        } = *surface_packet;
6773        let mut lowered = lowered;
6774        lowered.source = source;
6775
6776        // The root layer's visible area is always the viewport — content
6777        // outside the screen is invisible regardless of scroll offsets or
6778        // inflated scene bounds.  Pass the viewport rect as an explicit
6779        // surface rect to prevent offscreen inflation on constrained GPUs.
6780        let viewport_rect = Rect {
6781            x: 0.0,
6782            y: 0.0,
6783            width: width as f32 / root_scale,
6784            height: height as f32 / root_scale,
6785        };
6786        let root_surface = execute_render_layer_surface(
6787            &mut backend,
6788            &mut lowered,
6789            LayerSurfaceRequest {
6790                root_scale,
6791                backdrop_underlay: None,
6792                allow_runtime_cache: false,
6793                logical_rect_override: Some(viewport_rect),
6794                capture_clip_override: None,
6795                activates_nested_capture: false,
6796                translation_context: TranslationRenderContext::default(),
6797            },
6798        )?;
6799        let root_quad = transform_to_parent.map_rect(root_surface.logical_rect);
6800        let root_dest_quad = scaled_quad(root_quad, root_scale);
6801
6802        let needs_root_composite_target =
6803            backdrop.is_some() || graphics_layer.shadow_elevation > 0.0;
6804
6805        if needs_root_composite_target {
6806            let composite_target = backend.acquire_frame_surface(width, height);
6807            backend.clear_target_view_with_load_op(
6808                &composite_target.view,
6809                wgpu::LoadOp::Clear(CLEAR_COLOR),
6810            );
6811
6812            if let Some(backdrop) = &backdrop {
6813                execute_apply_backdrop_layer_to_target(
6814                    &mut backend,
6815                    &composite_target,
6816                    &BackdropLayer {
6817                        node_id,
6818                        rect: quad_bounds(transform_to_parent.map_rect(local_bounds)),
6819                        clip: clip_rect.map(|clip| quad_bounds(transform_to_parent.map_rect(clip))),
6820                        snap_anchor: None,
6821                        effect: backdrop.clone(),
6822                        z_index: 0,
6823                    },
6824                    None,
6825                    width,
6826                    height,
6827                    root_scale,
6828                    None,
6829                )?;
6830            }
6831
6832            let mut root_shadow_scene = CompositorScene::new();
6833            let root_shadow_clip =
6834                shadow_clip.map(|clip| quad_bounds(transform_to_parent.map_rect(clip)));
6835            push_layer_shadow(
6836                &mut root_shadow_scene,
6837                &graphics_layer,
6838                local_bounds,
6839                quad_bounds(transform_to_parent.map_rect(local_bounds)),
6840                root_shadow_clip,
6841            );
6842            for shadow in &root_shadow_scene.shadow_draws {
6843                backend.render_shadow_draw(
6844                    &composite_target.view,
6845                    shadow,
6846                    width,
6847                    height,
6848                    root_scale,
6849                );
6850            }
6851
6852            let composite_dest_quad =
6853                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
6854            execute_composite_surface_to_view(
6855                &mut backend,
6856                root_surface.target.target(),
6857                &composite_target.view,
6858                (width, height),
6859                composite_dest_quad,
6860                root_surface.composite_alpha,
6861                wgpu::LoadOp::Load,
6862                None,
6863                root_surface.blend_mode,
6864                root_surface.sample_mode,
6865            )?;
6866            backend.composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
6867                &composite_target,
6868                surface_view,
6869                1.0,
6870                wgpu::LoadOp::Clear(CLEAR_COLOR),
6871                None,
6872                None,
6873                BlendMode::SrcOver,
6874                None,
6875                CompositeSampleMode::Linear,
6876            );
6877            backend.release_frame_surface(composite_target);
6878        } else {
6879            let composite_dest_quad =
6880                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
6881            execute_composite_surface_to_view(
6882                &mut backend,
6883                root_surface.target.target(),
6884                surface_view,
6885                (width, height),
6886                composite_dest_quad,
6887                root_surface.composite_alpha,
6888                wgpu::LoadOp::Clear(CLEAR_COLOR),
6889                None,
6890                root_surface.blend_mode,
6891                root_surface.sample_mode,
6892            )?;
6893        }
6894        backend.release_layer_surface_target(root_surface.target);
6895        if let Some(overlay) = overlay {
6896            Self::render_overlay_packet(
6897                &mut backend,
6898                surface_view,
6899                overlay,
6900                width,
6901                height,
6902                root_scale,
6903            )?;
6904        }
6905        let after_layer_render = Instant::now();
6906        if let Some(total_ms) = should_log_wgpu_render_stage(recorded_start, after_layer_render) {
6907            log::warn!(
6908                "[wgpu-render-stage:recorded-layer-root] total_ms={total_ms:.2} collect_ms={:.2} render_ms={:.2}",
6909                instant_ms(recorded_start, after_root_collect),
6910                instant_ms(after_root_collect, after_layer_render),
6911            );
6912        }
6913        Ok(())
6914    }
6915
6916    /// Renders the producer-lowered dev overlay on top of the frame. The
6917    /// packet carries the collected overlay; the backend only validates
6918    /// that it stayed directly renderable and draws it.
6919    fn render_overlay_packet<C: FrameCommandRecorder>(
6920        backend: &mut RecordingSurfaceBackend<'_, '_, C>,
6921        surface_view: &wgpu::TextureView,
6922        overlay: CollectedLayer,
6923        width: u32,
6924        height: u32,
6925        root_scale: f32,
6926    ) -> Result<(), String> {
6927        if !overlay.child_layers.is_empty()
6928            || !root_direct_scene_events_are_supported(&overlay.scene)
6929            || !direct_root_child_underlays_are_supported(&overlay)
6930        {
6931            return Err("dev overlay graph must stay directly renderable".to_string());
6932        }
6933        execute_render_root_direct(
6934            backend,
6935            surface_view,
6936            overlay,
6937            width,
6938            height,
6939            root_scale,
6940            wgpu::LoadOp::Load,
6941        )
6942        .map(|_overlay_scene| ())
6943        .map_err(|(error, _overlay_scene)| error)
6944    }
6945
6946    #[allow(clippy::too_many_arguments)]
6947    fn encode_non_effect_segment_commands<C: FrameCommandRecorder>(
6948        &mut self,
6949        frame_encoder: &mut C,
6950        target_view: &wgpu::TextureView,
6951        ordered_items: &[(usize, SegmentDrawItem)],
6952        composites: &[(usize, CompositeBatchItem<'_>)],
6953        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
6954        shapes: &[DrawShape],
6955        images: &[ImageDraw],
6956        texts: &[TextDraw],
6957        shadow_draws: &[ShadowDraw],
6958        retained_draws: &[RetainedDraw],
6959        initial_load_op: wgpu::LoadOp<wgpu::Color>,
6960        width: u32,
6961        height: u32,
6962        root_scale: f32,
6963    ) -> Result<SegmentCommandEncodeOutcome, String> {
6964        let mut first_batch = true;
6965        for command in
6966            SegmentCommandIter::new(ordered_items, shapes, images, self.shape_batch_limits)
6967        {
6968            match command {
6969                SegmentRenderCommand::DrawChunk(chunk) => {
6970                    let load_op = if first_batch {
6971                        initial_load_op
6972                    } else {
6973                        wgpu::LoadOp::Load
6974                    };
6975                    let outcome = self.render_segment_draw_chunk(
6976                        frame_encoder,
6977                        target_view,
6978                        ordered_items,
6979                        composites,
6980                        shader_composites,
6981                        shapes,
6982                        images,
6983                        texts,
6984                        retained_draws,
6985                        chunk,
6986                        width,
6987                        height,
6988                        root_scale,
6989                        load_op,
6990                    )?;
6991                    if outcome.rendered_any {
6992                        frame_encoder.record_passes(outcome.pass_count);
6993                        first_batch = false;
6994                    }
6995                }
6996                SegmentRenderCommand::Shadow(index) => {
6997                    if first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
6998                        {
6999                            let _clear = frame_encoder.encoder().begin_render_pass(
7000                                &wgpu::RenderPassDescriptor {
7001                                    label: Some("Shadow Pre-Clear"),
7002                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7003                                        view: target_view,
7004                                        resolve_target: None,
7005                                        depth_slice: None,
7006                                        ops: wgpu::Operations {
7007                                            load: initial_load_op,
7008                                            store: wgpu::StoreOp::Store,
7009                                        },
7010                                    })],
7011                                    depth_stencil_attachment: None,
7012                                    timestamp_writes: None,
7013                                    occlusion_query_set: None,
7014                                    multiview_mask: None,
7015                                },
7016                            );
7017                        }
7018                        frame_encoder.record_pass();
7019                        first_batch = false;
7020                    }
7021                    let pass_count_before = frame_encoder.recorded_pass_count();
7022                    self.encode_shadow_draw(
7023                        frame_encoder,
7024                        target_view,
7025                        &shadow_draws[index],
7026                        width,
7027                        height,
7028                        root_scale,
7029                    );
7030                    if frame_encoder.recorded_pass_count() > pass_count_before {
7031                        first_batch = false;
7032                    }
7033                }
7034            }
7035        }
7036        Ok(SegmentCommandEncodeOutcome { first_batch })
7037    }
7038
7039    #[cfg(not(target_arch = "wasm32"))]
7040    #[allow(clippy::too_many_arguments)]
7041    fn render_segment_draw_chunk_fused_native<C: FrameCommandRecorder>(
7042        &mut self,
7043        frame_encoder: &mut C,
7044        target_view: &wgpu::TextureView,
7045        ordered_items: &[(usize, SegmentDrawItem)],
7046        composites: &[(usize, CompositeBatchItem<'_>)],
7047        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
7048        shapes: &[DrawShape],
7049        images: &[ImageDraw],
7050        texts: &[TextDraw],
7051        retained_draws: &[RetainedDraw],
7052        chunk: &SegmentDrawChunkPlan,
7053        width: u32,
7054        height: u32,
7055        root_scale: f32,
7056        load_op: wgpu::LoadOp<wgpu::Color>,
7057    ) -> Result<Option<SegmentRenderOutcome>, String> {
7058        let Some(partitions) = native_segment_fusion_partitions(
7059            ordered_items,
7060            shapes,
7061            chunk,
7062            self.shape_batch_limits,
7063        )?
7064        else {
7065            return Ok(None);
7066        };
7067
7068        let mut rendered_any = false;
7069        let mut pass_count = 0_u32;
7070        let mut next_load_op = load_op;
7071        for partition in partitions {
7072            let outcome = self.render_segment_draw_chunk_fused_native_partition(
7073                frame_encoder,
7074                target_view,
7075                ordered_items,
7076                composites,
7077                shader_composites,
7078                shapes,
7079                images,
7080                texts,
7081                retained_draws,
7082                &partition.chunk,
7083                partition.budget,
7084                width,
7085                height,
7086                root_scale,
7087                next_load_op,
7088            )?;
7089            if outcome.rendered_any {
7090                rendered_any = true;
7091                pass_count = pass_count.saturating_add(outcome.pass_count);
7092                next_load_op = wgpu::LoadOp::Load;
7093            }
7094        }
7095
7096        Ok(Some(SegmentRenderOutcome {
7097            rendered_any,
7098            pass_count,
7099        }))
7100    }
7101
7102    #[cfg(not(target_arch = "wasm32"))]
7103    #[allow(clippy::too_many_arguments)]
7104    fn render_segment_draw_chunk_fused_native_partition<C: FrameCommandRecorder>(
7105        &mut self,
7106        frame_encoder: &mut C,
7107        target_view: &wgpu::TextureView,
7108        ordered_items: &[(usize, SegmentDrawItem)],
7109        composites: &[(usize, CompositeBatchItem<'_>)],
7110        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
7111        shapes: &[DrawShape],
7112        images: &[ImageDraw],
7113        texts: &[TextDraw],
7114        retained_draws: &[RetainedDraw],
7115        chunk: &SegmentDrawChunkPlan,
7116        budget: NativeSegmentFusionBudget,
7117        width: u32,
7118        height: u32,
7119        root_scale: f32,
7120        load_op: wgpu::LoadOp<wgpu::Color>,
7121    ) -> Result<SegmentRenderOutcome, String> {
7122        let partition_start = Instant::now();
7123        let mut staged_uploads = self.take_staged_uploads();
7124        staged_uploads.clear();
7125        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
7126        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
7127        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
7128        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
7129
7130        image_vertices.clear();
7131        image_indices.clear();
7132        image_cmds.clear();
7133        glyph_cmds.clear();
7134
7135        let result = (|| {
7136            let viewport = ViewportUniformParams {
7137                width,
7138                height,
7139                offset: [0.0, 0.0],
7140            };
7141            self.prewarm_offscreen_text_glyph_draws_in_chunk(
7142                ordered_items,
7143                texts,
7144                chunk,
7145                viewport,
7146                root_scale,
7147                &mut staged_uploads,
7148                &mut image_vertices,
7149                &mut image_indices,
7150                &mut glyph_cmds,
7151            )?;
7152            let mut shape_refs = Vec::with_capacity(budget.shape_count);
7153            for batch in chunk.iter() {
7154                let SegmentBatchPlan::Shape { start, end, .. } = batch else {
7155                    continue;
7156                };
7157                for (_, item) in &ordered_items[start..end] {
7158                    let SegmentDrawItem::Shape(shape_index) = item else {
7159                        return Err(format!(
7160                            "shape batch contains non-shape draw item: {item:?}"
7161                        ));
7162                    };
7163                    shape_refs.push(&shapes[*shape_index]);
7164                }
7165            }
7166            let after_shape_refs = Instant::now();
7167
7168            let mut direct_shape_uploads = StagedBufferUploads::default();
7169            let mut shape_upload_base = 0u64;
7170            if !shape_refs.is_empty() {
7171                let Some((_, upload_base)) = self.prepare_shapes_batch_direct(
7172                    frame_encoder,
7173                    shape_refs.iter().copied(),
7174                    root_scale,
7175                    viewport,
7176                    &mut direct_shape_uploads,
7177                ) else {
7178                    return Err(
7179                        "native fused segment shape preparation produced no draw batch".to_string(),
7180                    );
7181                };
7182                shape_upload_base = upload_base;
7183            }
7184            let after_shape_prepare = Instant::now();
7185
7186            let mut fused_batches = Vec::with_capacity(chunk.batches.len());
7187            let mut shape_cursor = 0_u32;
7188            let mut composite_cursor = 0usize;
7189            let mut shader_composite_cursor = 0usize;
7190            for batch in chunk.iter() {
7191                match batch {
7192                    SegmentBatchPlan::Shape {
7193                        start,
7194                        end,
7195                        blend_mode,
7196                    } => {
7197                        let mut has_gradient = false;
7198                        for (_, item) in &ordered_items[start..end] {
7199                            let SegmentDrawItem::Shape(shape_index) = item else {
7200                                return Err(format!(
7201                                    "shape batch contains non-shape draw item: {item:?}"
7202                                ));
7203                            };
7204                            has_gradient |= shape_gradient_stop_count(&shapes[*shape_index]) > 0;
7205                        }
7206                        let shape_count = end - start;
7207                        if shape_count > 0 {
7208                            fused_batches.push(FusedSegmentBatch::Shape {
7209                                batch: PreparedShapeBatch {
7210                                    vertex_start: shape_cursor * 6,
7211                                    vertex_count: shape_count as u32 * 6,
7212                                    has_gradient,
7213                                },
7214                                blend_mode,
7215                            });
7216                            shape_cursor += shape_count as u32;
7217                        }
7218                    }
7219                    SegmentBatchPlan::Image {
7220                        start,
7221                        end,
7222                        blend_mode,
7223                    } => {
7224                        let cmd_start = image_cmds.len();
7225                        for (_, item) in &ordered_items[start..end] {
7226                            let SegmentDrawItem::Image(image_index) = item else {
7227                                return Err(format!(
7228                                    "image batch contains non-image draw item: {item:?}"
7229                                ));
7230                            };
7231                            self.append_image_draw_cmd(
7232                                &images[*image_index],
7233                                viewport,
7234                                root_scale,
7235                                &mut image_vertices,
7236                                &mut image_indices,
7237                                &mut image_cmds,
7238                            )?;
7239                        }
7240                        let cmd_end = image_cmds.len();
7241                        if cmd_start < cmd_end {
7242                            fused_batches.push(FusedSegmentBatch::Image {
7243                                cmd_range: cmd_start..cmd_end,
7244                                blend_mode,
7245                            });
7246                        }
7247                    }
7248                    SegmentBatchPlan::Text { start, end } => {
7249                        let glyph_cmd_start = glyph_cmds.len();
7250                        let image_cmd_start = image_cmds.len();
7251                        let text_draws =
7252                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7253                        if !self.append_text_glyph_draws(
7254                            text_draws,
7255                            viewport,
7256                            root_scale,
7257                            false,
7258                            &mut staged_uploads,
7259                            &mut image_vertices,
7260                            &mut image_indices,
7261                            &mut glyph_cmds,
7262                        )? {
7263                            let text_draws =
7264                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7265                            self.append_text_image_draw_cmds(
7266                                text_draws,
7267                                viewport,
7268                                root_scale,
7269                                &mut image_vertices,
7270                                &mut image_indices,
7271                                &mut image_cmds,
7272                            )?;
7273                        }
7274                        let image_cmd_end = image_cmds.len();
7275                        let glyph_cmd_end = glyph_cmds.len();
7276                        if image_cmd_start < image_cmd_end || glyph_cmd_start < glyph_cmd_end {
7277                            fused_batches.push(FusedSegmentBatch::Text {
7278                                image_cmd_range: image_cmd_start..image_cmd_end,
7279                                glyph_cmd_range: glyph_cmd_start..glyph_cmd_end,
7280                            });
7281                        }
7282                    }
7283                    SegmentBatchPlan::Composite { start, end } => {
7284                        for (_, item) in &ordered_items[start..end] {
7285                            if !matches!(item, SegmentDrawItem::Composite(_)) {
7286                                return Err(format!(
7287                                    "composite batch contains non-composite draw item: {item:?}"
7288                                ));
7289                            }
7290                        }
7291                        let draw_count = end - start;
7292                        if draw_count > 0 {
7293                            let draw_start = composite_cursor;
7294                            composite_cursor += draw_count;
7295                            fused_batches.push(FusedSegmentBatch::Composite {
7296                                draw_range: draw_start..composite_cursor,
7297                            });
7298                        }
7299                    }
7300                    SegmentBatchPlan::ShaderComposite { start, end } => {
7301                        for (_, item) in &ordered_items[start..end] {
7302                            if !matches!(item, SegmentDrawItem::ShaderComposite(_)) {
7303                                return Err(format!(
7304                                    "shader composite batch contains non-shader-composite draw item: {item:?}"
7305                                ));
7306                            }
7307                        }
7308                        let draw_count = end - start;
7309                        if draw_count > 0 {
7310                            let draw_start = shader_composite_cursor;
7311                            shader_composite_cursor += draw_count;
7312                            fused_batches.push(FusedSegmentBatch::ShaderComposite {
7313                                draw_range: draw_start..shader_composite_cursor,
7314                            });
7315                        }
7316                    }
7317                    SegmentBatchPlan::Retained { start, end } => {
7318                        self.stage_replay_patches(&mut staged_uploads);
7319                        for (_, item) in &ordered_items[start..end] {
7320                            let SegmentDrawItem::Retained(index) = item else {
7321                                return Err(format!(
7322                                    "retained batch contains non-retained draw item: {item:?}"
7323                                ));
7324                            };
7325                            let retained = retained_draws.get(*index).ok_or_else(|| {
7326                                format!("retained draw index {index} out of bounds")
7327                            })?;
7328                            if (*index as u32) < MAX_REPLAY_SLOTS
7329                                && self.replay_slots.slots.contains_key(&retained.slot)
7330                            {
7331                                let transform = retained.transform.with_retained_paint();
7332                                staged_uploads.stage_at(
7333                                    UploadTarget::ReplayTransform,
7334                                    *index as u64 * REPLAY_TRANSFORM_STRIDE,
7335                                    bytemuck::bytes_of(&transform),
7336                                );
7337                            }
7338                        }
7339                        if end > start {
7340                            fused_batches.push(FusedSegmentBatch::Retained {
7341                                item_range: start..end,
7342                            });
7343                        }
7344                    }
7345                }
7346            }
7347            let after_batch_prepare = Instant::now();
7348
7349            if !image_indices.is_empty() {
7350                self.stage_native_image_buffers(
7351                    &mut staged_uploads,
7352                    viewport,
7353                    &image_vertices,
7354                    &image_indices,
7355                );
7356            }
7357
7358            let device = self.device.clone();
7359            let composite_items: Vec<_> = chunk
7360                .iter()
7361                .filter_map(|batch| match batch {
7362                    SegmentBatchPlan::Composite { start, end } => Some((start, end)),
7363                    _ => None,
7364                })
7365                .flat_map(|(start, end)| {
7366                    ordered_items[start..end].iter().filter_map(|(_, item)| {
7367                        let SegmentDrawItem::Composite(composite_index) = item else {
7368                            return None;
7369                        };
7370                        composites
7371                            .get(*composite_index)
7372                            .map(|(_, composite)| *composite)
7373                    })
7374                })
7375                .collect();
7376            let prepared_composites = self.effect_renderer.prepare_composite_batch_draws(
7377                frame_encoder,
7378                &device,
7379                load_op,
7380                &composite_items,
7381            );
7382            let shader_items: Vec<_> = chunk
7383                .iter()
7384                .filter_map(|batch| match batch {
7385                    SegmentBatchPlan::ShaderComposite { start, end } => Some((start, end)),
7386                    _ => None,
7387                })
7388                .flat_map(|(start, end)| {
7389                    ordered_items[start..end].iter().filter_map(|(_, item)| {
7390                        let SegmentDrawItem::ShaderComposite(composite_index) = item else {
7391                            return None;
7392                        };
7393                        shader_composites
7394                            .get(*composite_index)
7395                            .map(|(_, composite)| *composite)
7396                    })
7397                })
7398                .collect();
7399            let prepared_shaders = self
7400                .effect_renderer
7401                .prepare_shader_batch_draws(frame_encoder, &device, &shader_items)
7402                .ok_or_else(|| "shader composite batch preparation failed".to_string())?;
7403            if !shader_items.is_empty() {
7404                self.effect_renderer.record_composite_pass();
7405                self.effect_renderer
7406                    .debug_effects
7407                    .set(self.effect_renderer.debug_effects.get() + shader_items.len() as u32);
7408            }
7409            let after_composite_prepare = Instant::now();
7410
7411            if fused_batches.is_empty() {
7412                return Ok(SegmentRenderOutcome {
7413                    rendered_any: false,
7414                    pass_count: 0,
7415                });
7416            }
7417
7418            // The direct shape copies must be recorded before the staged
7419            // flush: its capacity check may replace `upload_buffer`, and the
7420            // shape payload was written into the buffer that existed at
7421            // prepare time. Recording first binds the copies to that buffer.
7422            self.flush_staged_uploads_at(
7423                frame_encoder.encoder(),
7424                &direct_shape_uploads,
7425                shape_upload_base,
7426            );
7427            let upload_offset =
7428                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7429            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
7430            let after_upload = Instant::now();
7431
7432            let use_retained_bundles = retained_bundles_enabled();
7433            let mut retained_encode_ms = 0.0_f64;
7434            {
7435                let mut render_pass =
7436                    frame_encoder
7437                        .encoder()
7438                        .begin_render_pass(&wgpu::RenderPassDescriptor {
7439                            label: Some("Fused Segment Draw Pass"),
7440                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7441                                view: target_view,
7442                                resolve_target: None,
7443                                depth_slice: None,
7444                                ops: wgpu::Operations {
7445                                    load: load_op,
7446                                    store: wgpu::StoreOp::Store,
7447                                },
7448                            })],
7449                            depth_stencil_attachment: None,
7450                            timestamp_writes: None,
7451                            occlusion_query_set: None,
7452                            multiview_mask: None,
7453                        });
7454
7455                for batch in &fused_batches {
7456                    match batch {
7457                        FusedSegmentBatch::Shape { batch, blend_mode } => {
7458                            self.draw_prepared_shapes(
7459                                &mut render_pass,
7460                                *blend_mode,
7461                                *batch,
7462                                width,
7463                                height,
7464                            );
7465                        }
7466                        FusedSegmentBatch::Image {
7467                            cmd_range,
7468                            blend_mode,
7469                        } => {
7470                            self.draw_native_prepared_image_cmd_range(
7471                                &mut render_pass,
7472                                &image_cmds,
7473                                cmd_range.clone(),
7474                                *blend_mode,
7475                            )?;
7476                        }
7477                        FusedSegmentBatch::Text {
7478                            image_cmd_range,
7479                            glyph_cmd_range,
7480                        } => {
7481                            if !image_cmd_range.is_empty() {
7482                                self.draw_native_prepared_image_cmd_range(
7483                                    &mut render_pass,
7484                                    &image_cmds,
7485                                    image_cmd_range.clone(),
7486                                    BlendMode::SrcOver,
7487                                )?;
7488                                self.frame_stats.bump_text();
7489                            }
7490                            if !glyph_cmd_range.is_empty() {
7491                                self.draw_native_prepared_glyph_cmd_range(
7492                                    &mut render_pass,
7493                                    &glyph_cmds,
7494                                    glyph_cmd_range.clone(),
7495                                )?;
7496                            }
7497                        }
7498                        FusedSegmentBatch::Composite { draw_range } => {
7499                            for draw in
7500                                prepared_composites.get(draw_range.clone()).ok_or_else(|| {
7501                                    "composite draw range is outside the prepared command buffer"
7502                                        .to_string()
7503                                })?
7504                            {
7505                                self.effect_renderer.draw_prepared_composite(
7506                                    &mut render_pass,
7507                                    (width, height),
7508                                    draw,
7509                                );
7510                            }
7511                        }
7512                        FusedSegmentBatch::ShaderComposite { draw_range } => {
7513                            for draw in prepared_shaders.get(draw_range.clone()).ok_or_else(|| {
7514                                "shader composite draw range is outside the prepared command buffer"
7515                                    .to_string()
7516                            })? {
7517                                self.effect_renderer.draw_prepared_shader_src_over(
7518                                    &device,
7519                                    &mut render_pass,
7520                                    (width, height),
7521                                    draw,
7522                                );
7523                            }
7524                        }
7525                        FusedSegmentBatch::Retained { item_range } => {
7526                            // Each Retained arm is one MAXIMAL consecutive
7527                            // retained stretch — the planner groups adjacent
7528                            // retained items into a single batch — so caching
7529                            // per arm never flattens across the dynamic
7530                            // batches interleaved at their z positions.
7531                            let retained_start = Instant::now();
7532                            if use_retained_bundles {
7533                                self.draw_retained_stretch_bundled(
7534                                    &mut render_pass,
7535                                    ordered_items,
7536                                    retained_draws,
7537                                    item_range.clone(),
7538                                    width,
7539                                    height,
7540                                );
7541                            } else {
7542                                for (_, item) in &ordered_items[item_range.clone()] {
7543                                    if let SegmentDrawItem::Retained(index) = item {
7544                                        if let Some(retained) = retained_draws.get(*index) {
7545                                            self.draw_retained_batch(
7546                                                &mut render_pass,
7547                                                retained,
7548                                                *index,
7549                                                width,
7550                                                height,
7551                                            );
7552                                        }
7553                                    }
7554                                }
7555                            }
7556                            retained_encode_ms += instant_ms(retained_start, Instant::now());
7557                        }
7558                    }
7559                }
7560            }
7561            let after_pass = Instant::now();
7562            if let Some(total_ms) = should_log_wgpu_render_stage(partition_start, after_pass) {
7563                log::warn!(
7564                    "[wgpu-render-stage:fused-segment] total_ms={total_ms:.2} shape_refs_ms={:.2} shape_prepare_ms={:.2} batch_prepare_ms={:.2} composite_prepare_ms={:.2} upload_ms={:.2} pass_ms={:.2} retained_encode_ms={retained_encode_ms:.3} batches={} shapes={} image_cmds={} glyph_cmds={} staged_bytes={}",
7565                    instant_ms(partition_start, after_shape_refs),
7566                    instant_ms(after_shape_refs, after_shape_prepare),
7567                    instant_ms(after_shape_prepare, after_batch_prepare),
7568                    instant_ms(after_batch_prepare, after_composite_prepare),
7569                    instant_ms(after_composite_prepare, after_upload),
7570                    instant_ms(after_upload, after_pass),
7571                    fused_batches.len(),
7572                    budget.shape_count,
7573                    image_cmds.len(),
7574                    glyph_cmds.len(),
7575                    staged_uploads.bytes.len(),
7576                );
7577            }
7578
7579            Ok(SegmentRenderOutcome {
7580                rendered_any: true,
7581                pass_count: 1,
7582            })
7583        })();
7584
7585        self.scratch_image_vertices = image_vertices;
7586        self.scratch_image_indices = image_indices;
7587        self.scratch_image_cmds = image_cmds;
7588        self.scratch_glyph_cmds = glyph_cmds;
7589        self.restore_staged_uploads(staged_uploads);
7590        result
7591    }
7592
7593    #[allow(clippy::too_many_arguments)]
7594    fn render_segment_draw_chunk<C: FrameCommandRecorder>(
7595        &mut self,
7596        frame_encoder: &mut C,
7597        target_view: &wgpu::TextureView,
7598        ordered_items: &[(usize, SegmentDrawItem)],
7599        composites: &[(usize, CompositeBatchItem<'_>)],
7600        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
7601        shapes: &[DrawShape],
7602        images: &[ImageDraw],
7603        texts: &[TextDraw],
7604        retained_draws: &[RetainedDraw],
7605        chunk: SegmentDrawChunkPlan,
7606        width: u32,
7607        height: u32,
7608        root_scale: f32,
7609        load_op: wgpu::LoadOp<wgpu::Color>,
7610    ) -> Result<SegmentRenderOutcome, String> {
7611        #[cfg(target_arch = "wasm32")]
7612        let _ = retained_draws;
7613        #[cfg(not(target_arch = "wasm32"))]
7614        if let Some(outcome) = self.render_segment_draw_chunk_fused_native(
7615            frame_encoder,
7616            target_view,
7617            ordered_items,
7618            composites,
7619            shader_composites,
7620            shapes,
7621            images,
7622            texts,
7623            retained_draws,
7624            &chunk,
7625            width,
7626            height,
7627            root_scale,
7628            load_op,
7629        )? {
7630            return Ok(outcome);
7631        }
7632
7633        let mut staged_uploads = self.take_staged_uploads();
7634        let result = (|| {
7635            let mut rendered_any = false;
7636            let mut pass_count = 0_u32;
7637            let mut next_load_op = load_op;
7638            for batch in chunk.iter() {
7639                staged_uploads.clear();
7640                match batch {
7641                    SegmentBatchPlan::Shape {
7642                        start,
7643                        end,
7644                        blend_mode,
7645                    } => {
7646                        let slice = &ordered_items[start..end];
7647                        if slice.len() > self.shape_batch_limits.max_shapes_per_batch {
7648                            return Err(format!(
7649                                "shape batch contains {} shapes, exceeding the renderer limit of {}",
7650                                slice.len(),
7651                                self.shape_batch_limits.max_shapes_per_batch
7652                            ));
7653                        }
7654                        let viewport = ViewportUniformParams {
7655                            width,
7656                            height,
7657                            offset: [0.0, 0.0],
7658                        };
7659                        for (_, item) in slice {
7660                            if !matches!(item, SegmentDrawItem::Shape(_)) {
7661                                return Err(format!(
7662                                    "shape batch contains non-shape draw item: {item:?}"
7663                                ));
7664                            }
7665                        }
7666                        let Some(prepared) = self.prepare_shapes_batch(
7667                            slice.iter().filter_map(|(_, item)| match item {
7668                                SegmentDrawItem::Shape(shape_index) => Some(&shapes[*shape_index]),
7669                                _ => None,
7670                            }),
7671                            root_scale,
7672                            viewport,
7673                            &mut staged_uploads,
7674                        ) else {
7675                            continue;
7676                        };
7677                        let upload_offset = frame_encoder
7678                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7679                        self.flush_staged_uploads_at(
7680                            frame_encoder.encoder(),
7681                            &staged_uploads,
7682                            upload_offset,
7683                        );
7684                        {
7685                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
7686                                &wgpu::RenderPassDescriptor {
7687                                    label: Some("Segment Shape Pass"),
7688                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7689                                        view: target_view,
7690                                        resolve_target: None,
7691                                        depth_slice: None,
7692                                        ops: wgpu::Operations {
7693                                            load: next_load_op,
7694                                            store: wgpu::StoreOp::Store,
7695                                        },
7696                                    })],
7697                                    depth_stencil_attachment: None,
7698                                    timestamp_writes: None,
7699                                    occlusion_query_set: None,
7700                                    multiview_mask: None,
7701                                },
7702                            );
7703                            self.draw_prepared_shapes(
7704                                &mut render_pass,
7705                                blend_mode,
7706                                prepared,
7707                                width,
7708                                height,
7709                            );
7710                        }
7711                        pass_count = pass_count.saturating_add(1);
7712                        rendered_any = true;
7713                        next_load_op = wgpu::LoadOp::Load;
7714                    }
7715                    SegmentBatchPlan::Image {
7716                        start,
7717                        end,
7718                        blend_mode,
7719                    } => {
7720                        let viewport = ViewportUniformParams {
7721                            width,
7722                            height,
7723                            offset: [0.0, 0.0],
7724                        };
7725                        for (_, item) in &ordered_items[start..end] {
7726                            if !matches!(item, SegmentDrawItem::Image(_)) {
7727                                return Err(format!(
7728                                    "image batch contains non-image draw item: {item:?}"
7729                                ));
7730                            }
7731                        }
7732                        let prepared_images = self.prepare_image_draw_cmds(
7733                            ordered_items[start..end]
7734                                .iter()
7735                                .filter_map(|(_, item)| match item {
7736                                    SegmentDrawItem::Image(image_index) => {
7737                                        Some(&images[*image_index])
7738                                    }
7739                                    _ => None,
7740                                }),
7741                            viewport,
7742                            root_scale,
7743                            &mut staged_uploads,
7744                        )?;
7745                        if prepared_images.is_empty() {
7746                            self.scratch_image_cmds = prepared_images.into_cmds();
7747                            continue;
7748                        }
7749                        let upload_offset = frame_encoder
7750                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7751                        self.flush_staged_uploads_at(
7752                            frame_encoder.encoder(),
7753                            &staged_uploads,
7754                            upload_offset,
7755                        );
7756                        let draw_result = {
7757                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
7758                                &wgpu::RenderPassDescriptor {
7759                                    label: Some("Segment Image Pass"),
7760                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7761                                        view: target_view,
7762                                        resolve_target: None,
7763                                        depth_slice: None,
7764                                        ops: wgpu::Operations {
7765                                            load: next_load_op,
7766                                            store: wgpu::StoreOp::Store,
7767                                        },
7768                                    })],
7769                                    depth_stencil_attachment: None,
7770                                    timestamp_writes: None,
7771                                    occlusion_query_set: None,
7772                                    multiview_mask: None,
7773                                },
7774                            );
7775                            self.draw_prepared_images(
7776                                &mut render_pass,
7777                                &prepared_images,
7778                                blend_mode,
7779                            )
7780                        };
7781                        pass_count = pass_count.saturating_add(1);
7782                        self.scratch_image_cmds = prepared_images.into_cmds();
7783                        draw_result?;
7784                        rendered_any = true;
7785                        next_load_op = wgpu::LoadOp::Load;
7786                    }
7787                    SegmentBatchPlan::Text { start, end } => {
7788                        let viewport = ViewportUniformParams {
7789                            width,
7790                            height,
7791                            offset: [0.0, 0.0],
7792                        };
7793                        let text_draws =
7794                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7795                        if let Some(prepared_glyphs) = self.prepare_text_glyph_draw_cmds(
7796                            text_draws,
7797                            viewport,
7798                            root_scale,
7799                            &mut staged_uploads,
7800                        )? {
7801                            if prepared_glyphs.is_empty() {
7802                                self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
7803                                continue;
7804                            }
7805                            let upload_offset = frame_encoder
7806                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7807                            self.flush_staged_uploads_at(
7808                                frame_encoder.encoder(),
7809                                &staged_uploads,
7810                                upload_offset,
7811                            );
7812                            {
7813                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
7814                                    &wgpu::RenderPassDescriptor {
7815                                        label: Some("Segment Text Glyph Atlas Pass"),
7816                                        color_attachments: &[Some(
7817                                            wgpu::RenderPassColorAttachment {
7818                                                view: target_view,
7819                                                resolve_target: None,
7820                                                depth_slice: None,
7821                                                ops: wgpu::Operations {
7822                                                    load: next_load_op,
7823                                                    store: wgpu::StoreOp::Store,
7824                                                },
7825                                            },
7826                                        )],
7827                                        depth_stencil_attachment: None,
7828                                        timestamp_writes: None,
7829                                        occlusion_query_set: None,
7830                                        multiview_mask: None,
7831                                    },
7832                                );
7833                                self.draw_prepared_glyphs(&mut render_pass, &prepared_glyphs)?;
7834                            }
7835                            pass_count = pass_count.saturating_add(1);
7836                            self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
7837                            rendered_any = true;
7838                            next_load_op = wgpu::LoadOp::Load;
7839                        } else {
7840                            let text_draws =
7841                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7842                            let prepared_images = self.prepare_text_image_draw_cmds(
7843                                text_draws,
7844                                viewport,
7845                                root_scale,
7846                                &mut staged_uploads,
7847                            )?;
7848                            if prepared_images.is_empty() {
7849                                self.scratch_image_cmds = prepared_images.into_cmds();
7850                                continue;
7851                            }
7852                            let upload_offset = frame_encoder
7853                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7854                            self.flush_staged_uploads_at(
7855                                frame_encoder.encoder(),
7856                                &staged_uploads,
7857                                upload_offset,
7858                            );
7859                            {
7860                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
7861                                    &wgpu::RenderPassDescriptor {
7862                                        label: Some("Segment Text Pass"),
7863                                        color_attachments: &[Some(
7864                                            wgpu::RenderPassColorAttachment {
7865                                                view: target_view,
7866                                                resolve_target: None,
7867                                                depth_slice: None,
7868                                                ops: wgpu::Operations {
7869                                                    load: next_load_op,
7870                                                    store: wgpu::StoreOp::Store,
7871                                                },
7872                                            },
7873                                        )],
7874                                        depth_stencil_attachment: None,
7875                                        timestamp_writes: None,
7876                                        occlusion_query_set: None,
7877                                        multiview_mask: None,
7878                                    },
7879                                );
7880                                self.draw_prepared_images(
7881                                    &mut render_pass,
7882                                    &prepared_images,
7883                                    BlendMode::SrcOver,
7884                                )?;
7885                            }
7886                            self.frame_stats.bump_text();
7887                            pass_count = pass_count.saturating_add(1);
7888                            self.scratch_image_cmds = prepared_images.into_cmds();
7889                            rendered_any = true;
7890                            next_load_op = wgpu::LoadOp::Load;
7891                        }
7892                    }
7893                    SegmentBatchPlan::Composite { start, end } => {
7894                        let batch_items: Vec<_> = ordered_items[start..end]
7895                            .iter()
7896                            .map(|(_, item)| match item {
7897                                SegmentDrawItem::Composite(composite_index) => composites
7898                                    .get(*composite_index)
7899                                    .map(|(_, composite)| *composite)
7900                                    .ok_or_else(|| {
7901                                        "composite item index is outside the composite buffer"
7902                                            .to_string()
7903                                    }),
7904                                other => Err(format!(
7905                                    "composite batch contains non-composite draw item: {other:?}"
7906                                )),
7907                            })
7908                            .collect::<Result<_, _>>()?;
7909                        let device = self.device.clone();
7910                        self.effect_renderer.encode_composite_batch_to_view_pass(
7911                            frame_encoder,
7912                            &device,
7913                            target_view,
7914                            (width, height),
7915                            next_load_op,
7916                            &batch_items,
7917                        );
7918                        self.effect_renderer.record_composite_pass();
7919                        pass_count = pass_count.saturating_add(1);
7920                        rendered_any = true;
7921                        next_load_op = wgpu::LoadOp::Load;
7922                    }
7923                    SegmentBatchPlan::ShaderComposite { start, end } => {
7924                        let batch_items: Vec<_> = ordered_items[start..end]
7925                            .iter()
7926                            .map(|(_, item)| match item {
7927                                SegmentDrawItem::ShaderComposite(composite_index) => {
7928                                    shader_composites
7929                                        .get(*composite_index)
7930                                        .map(|(_, composite)| *composite)
7931                                        .ok_or_else(|| {
7932                                            "shader composite item index is outside the shader composite buffer"
7933                                                .to_string()
7934                                        })
7935                                }
7936                                other => Err(format!(
7937                                    "shader composite batch contains non-shader-composite draw item: {other:?}"
7938                                )),
7939                            })
7940                            .collect::<Result<Vec<_>, _>>()?;
7941                        let device = self.device.clone();
7942                        let encoded = self.effect_renderer.encode_shader_batch_src_over_to_view(
7943                            frame_encoder,
7944                            &device,
7945                            target_view,
7946                            (width, height),
7947                            next_load_op,
7948                            &batch_items,
7949                        );
7950                        if !encoded {
7951                            return Err("shader composite batch failed to encode".to_string());
7952                        }
7953                        self.effect_renderer.record_composite_pass();
7954                        self.effect_renderer.debug_effects.set(
7955                            self.effect_renderer.debug_effects.get() + batch_items.len() as u32,
7956                        );
7957                        pass_count = pass_count.saturating_add(1);
7958                        rendered_any = true;
7959                        next_load_op = wgpu::LoadOp::Load;
7960                    }
7961                    SegmentBatchPlan::Retained { start, end } => {
7962                        // Reached only when native fusion declined the chunk;
7963                        // retained batches exist on storage-mode native
7964                        // devices, where fusion always accepts, but the arm
7965                        // stays a real draw so that assumption is not load-
7966                        // bearing for correctness. Deliberately direct encode
7967                        // — retained bundle caching lives in the fused path
7968                        // only; this fallback stays the simple reference.
7969                        #[cfg(target_arch = "wasm32")]
7970                        {
7971                            let _ = (start, end);
7972                            return Err("retained shape batches are native-only".to_string());
7973                        }
7974                        #[cfg(not(target_arch = "wasm32"))]
7975                        {
7976                            self.stage_replay_patches(&mut staged_uploads);
7977                            for (_, item) in &ordered_items[start..end] {
7978                                let SegmentDrawItem::Retained(index) = item else {
7979                                    return Err(format!(
7980                                        "retained batch contains non-retained draw item: {item:?}"
7981                                    ));
7982                                };
7983                                let retained = retained_draws.get(*index).ok_or_else(|| {
7984                                    format!("retained draw index {index} out of bounds")
7985                                })?;
7986                                if (*index as u32) < MAX_REPLAY_SLOTS
7987                                    && self.replay_slots.slots.contains_key(&retained.slot)
7988                                {
7989                                    let transform = retained.transform.with_retained_paint();
7990                                    staged_uploads.stage_at(
7991                                        UploadTarget::ReplayTransform,
7992                                        *index as u64 * REPLAY_TRANSFORM_STRIDE,
7993                                        bytemuck::bytes_of(&transform),
7994                                    );
7995                                }
7996                            }
7997                            let upload_offset = frame_encoder
7998                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7999                            self.flush_staged_uploads_at(
8000                                frame_encoder.encoder(),
8001                                &staged_uploads,
8002                                upload_offset,
8003                            );
8004                            {
8005                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
8006                                    &wgpu::RenderPassDescriptor {
8007                                        label: Some("Segment Retained Pass"),
8008                                        color_attachments: &[Some(
8009                                            wgpu::RenderPassColorAttachment {
8010                                                view: target_view,
8011                                                resolve_target: None,
8012                                                depth_slice: None,
8013                                                ops: wgpu::Operations {
8014                                                    load: next_load_op,
8015                                                    store: wgpu::StoreOp::Store,
8016                                                },
8017                                            },
8018                                        )],
8019                                        depth_stencil_attachment: None,
8020                                        timestamp_writes: None,
8021                                        occlusion_query_set: None,
8022                                        multiview_mask: None,
8023                                    },
8024                                );
8025                                for (_, item) in &ordered_items[start..end] {
8026                                    if let SegmentDrawItem::Retained(index) = item {
8027                                        if let Some(retained) = retained_draws.get(*index) {
8028                                            self.draw_retained_batch(
8029                                                &mut render_pass,
8030                                                retained,
8031                                                *index,
8032                                                width,
8033                                                height,
8034                                            );
8035                                        }
8036                                    }
8037                                }
8038                            }
8039                            pass_count = pass_count.saturating_add(1);
8040                            rendered_any = true;
8041                            next_load_op = wgpu::LoadOp::Load;
8042                        }
8043                    }
8044                }
8045            }
8046            Ok(SegmentRenderOutcome {
8047                rendered_any,
8048                pass_count,
8049            })
8050        })();
8051        self.restore_staged_uploads(staged_uploads);
8052        result
8053    }
8054
8055    fn viewport_uniforms(params: ViewportUniformParams) -> Uniforms {
8056        Uniforms {
8057            viewport: [params.width as f32, params.height as f32],
8058            viewport_offset: params.offset,
8059        }
8060    }
8061
8062    #[cfg(not(target_arch = "wasm32"))]
8063    fn stage_viewport_uniforms(
8064        &self,
8065        staged_uploads: &mut StagedBufferUploads,
8066        params: ViewportUniformParams,
8067    ) {
8068        let uniforms = Self::viewport_uniforms(params);
8069        staged_uploads.stage(UploadTarget::Uniform, bytemuck::bytes_of(&uniforms));
8070    }
8071
8072    #[cfg(not(target_arch = "wasm32"))]
8073    fn stage_retained_glyph_viewport_uniforms(
8074        &mut self,
8075        staged_uploads: &mut StagedBufferUploads,
8076        params: ViewportUniformParams,
8077    ) -> usize {
8078        let slot = self.claim_retained_glyph_uniform_slot();
8079        let uniforms = Self::viewport_uniforms(params);
8080        staged_uploads.stage_at(
8081            UploadTarget::RetainedGlyphUniform,
8082            self.retained_glyph_uniform_offset(slot),
8083            bytemuck::bytes_of(&uniforms),
8084        );
8085        slot
8086    }
8087
8088    #[cfg(not(target_arch = "wasm32"))]
8089    fn claim_retained_glyph_uniform_slot(&mut self) -> usize {
8090        let slot = self.retained_glyph_uniform_cursor;
8091        self.retained_glyph_uniform_cursor = self.retained_glyph_uniform_cursor.saturating_add(1);
8092        self.ensure_retained_glyph_uniform_capacity(slot.saturating_add(1));
8093        slot
8094    }
8095
8096    #[cfg(not(target_arch = "wasm32"))]
8097    fn retained_glyph_uniform_offset(&self, slot: usize) -> u64 {
8098        self.retained_glyph_uniform_stride * slot as u64
8099    }
8100
8101    #[cfg(not(target_arch = "wasm32"))]
8102    fn retained_glyph_uniform_dynamic_offset(&self, slot: usize) -> Result<u32, String> {
8103        let offset = self.retained_glyph_uniform_offset(slot);
8104        u32::try_from(offset).map_err(|_| {
8105            "retained glyph uniform offset exceeded WGPU dynamic offset range".to_string()
8106        })
8107    }
8108
8109    #[cfg(not(target_arch = "wasm32"))]
8110    fn ensure_retained_glyph_uniform_capacity(&mut self, required_slots: usize) {
8111        if required_slots <= self.retained_glyph_uniform_capacity {
8112            return;
8113        }
8114        let new_capacity = required_slots
8115            .next_power_of_two()
8116            .max(INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS);
8117        self.retained_glyph_uniform_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
8118            label: Some("Retained Glyph Uniform Buffer"),
8119            size: self.retained_glyph_uniform_stride * new_capacity as u64,
8120            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
8121            mapped_at_creation: false,
8122        });
8123        self.retained_glyph_uniform_bind_group =
8124            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
8125                label: Some("Retained Glyph Uniform Bind Group"),
8126                layout: &self.retained_glyph_uniform_bind_group_layout,
8127                entries: &[wgpu::BindGroupEntry {
8128                    binding: 0,
8129                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
8130                        buffer: &self.retained_glyph_uniform_buffer,
8131                        offset: 0,
8132                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
8133                    }),
8134                }],
8135            });
8136        self.retained_glyph_uniform_capacity = new_capacity;
8137    }
8138
8139    #[cfg(target_arch = "wasm32")]
8140    fn prepare_wasm_viewport_uniforms(&mut self, params: ViewportUniformParams) -> usize {
8141        let slot = self.claim_wasm_uniform_batch();
8142        let uniforms = Self::viewport_uniforms(params);
8143        let bytes = bytemuck::bytes_of(&uniforms);
8144        let upload_stats = self.frame_graph_executor.upload_buffer(
8145            &self.queue,
8146            &self.wasm_uniform_batches[slot].buffer,
8147            0,
8148            bytes,
8149        );
8150        self.frame_stats.record_command_stats(upload_stats);
8151        slot
8152    }
8153
8154    #[cfg(target_arch = "wasm32")]
8155    fn claim_wasm_uniform_batch(&mut self) -> usize {
8156        let slot = self.wasm_uniform_batch_cursor;
8157        self.wasm_uniform_batch_cursor += 1;
8158        while self.wasm_uniform_batches.len() <= slot {
8159            self.wasm_uniform_batches.push(UniformBatchBuffer::new(
8160                &self.device,
8161                &self.uniform_bind_group_layout,
8162            ));
8163        }
8164        slot
8165    }
8166
8167    #[cfg(target_arch = "wasm32")]
8168    fn claim_wasm_shape_batch(&mut self) -> usize {
8169        let slot = self.wasm_shape_batch_cursor;
8170        self.wasm_shape_batch_cursor += 1;
8171        while self.wasm_shape_batches.len() <= slot {
8172            self.wasm_shape_batches.push(ShapeBatchBuffers::new(
8173                &self.device,
8174                &self.shape_bind_group_layout,
8175                &self.identity_similarity_buffer,
8176                self.dummy_paint_buffer.as_ref(),
8177                self.shape_batch_limits,
8178            ));
8179        }
8180        slot
8181    }
8182
8183    #[cfg(target_arch = "wasm32")]
8184    fn claim_wasm_image_batch(&mut self) -> usize {
8185        let slot = self.wasm_image_batch_cursor;
8186        self.wasm_image_batch_cursor += 1;
8187        while self.wasm_image_batches.len() <= slot {
8188            self.wasm_image_batches
8189                .push(ImageBatchBuffers::new(&self.device));
8190        }
8191        slot
8192    }
8193
8194    #[cfg(target_arch = "wasm32")]
8195    fn write_wasm_buffer(&self, buffer: &wgpu::Buffer, bytes: &[u8]) {
8196        let upload_stats = self
8197            .frame_graph_executor
8198            .upload_buffer(&self.queue, buffer, 0, bytes);
8199        self.frame_stats.record_command_stats(upload_stats);
8200    }
8201
8202    fn take_staged_uploads(&mut self) -> StagedBufferUploads {
8203        let mut staged_uploads = std::mem::take(&mut self.staged_uploads);
8204        debug_assert!(
8205            staged_uploads.is_empty(),
8206            "renderer-owned staged uploads should be restored as empty scratch storage"
8207        );
8208        staged_uploads.clear();
8209        staged_uploads
8210    }
8211
8212    fn restore_staged_uploads(&mut self, mut staged_uploads: StagedBufferUploads) {
8213        staged_uploads.clear();
8214        self.staged_uploads = staged_uploads;
8215    }
8216
8217    #[cfg(not(target_arch = "wasm32"))]
8218    fn ensure_upload_buffer_capacity(&mut self, required_bytes: u64) {
8219        if required_bytes <= self.upload_buffer.size() {
8220            return;
8221        }
8222
8223        let new_size = required_bytes
8224            .next_power_of_two()
8225            .max(INITIAL_UPLOAD_BUFFER_BYTES);
8226        self.upload_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
8227            label: Some("Frame Upload Buffer"),
8228            size: new_size,
8229            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
8230            mapped_at_creation: false,
8231        });
8232    }
8233
8234    fn flush_staged_uploads_at(
8235        &mut self,
8236        encoder: &mut wgpu::CommandEncoder,
8237        staged_uploads: &StagedBufferUploads,
8238        upload_buffer_offset: u64,
8239    ) {
8240        if staged_uploads.is_empty() {
8241            return;
8242        }
8243        debug_assert_eq!(
8244            upload_buffer_offset % wgpu::COPY_BUFFER_ALIGNMENT,
8245            0,
8246            "upload-buffer base offset must satisfy copy alignment"
8247        );
8248
8249        #[cfg(target_arch = "wasm32")]
8250        {
8251            let _ = upload_buffer_offset;
8252            let _ = encoder;
8253            debug_assert!(
8254                staged_uploads.is_empty(),
8255                "wasm draw uploads use retained per-batch resource slots"
8256            );
8257            return;
8258        }
8259
8260        #[cfg(not(target_arch = "wasm32"))]
8261        {
8262            self.ensure_upload_buffer_capacity(
8263                upload_buffer_offset + staged_uploads.bytes.len() as u64,
8264            );
8265            let upload_stats = self.frame_graph_executor.upload_buffer(
8266                &self.queue,
8267                &self.upload_buffer,
8268                upload_buffer_offset,
8269                &staged_uploads.bytes,
8270            );
8271            self.frame_stats.record_command_stats(upload_stats);
8272
8273            for copy in &staged_uploads.copies {
8274                let target_buffer = match copy.target {
8275                    UploadTarget::Uniform => &self.uniform_buffer,
8276                    UploadTarget::ShapeData => &self.shape_buffers.shape_buffer,
8277                    UploadTarget::ShapeGradient => &self.shape_buffers.gradient_buffer,
8278                    UploadTarget::ImageVertex => &self.image_vertex_buffer,
8279                    UploadTarget::ImageIndex => &self.image_index_buffer,
8280                    UploadTarget::RetainedGlyphUniform => &self.retained_glyph_uniform_buffer,
8281                    UploadTarget::ReplayTransform => &self.replay_slots.transform_buffer,
8282                    UploadTarget::ReplayPaintData(slot) => {
8283                        // A slot released between staging and flush has
8284                        // nothing left to patch.
8285                        let Some(entry) = self.replay_slots.slots.get(&slot) else {
8286                            continue;
8287                        };
8288                        &entry.paint_buffer
8289                    }
8290                };
8291                encoder.copy_buffer_to_buffer(
8292                    &self.upload_buffer,
8293                    upload_buffer_offset + copy.source_offset,
8294                    target_buffer,
8295                    copy.target_offset,
8296                    copy.size,
8297                );
8298            }
8299        }
8300    }
8301
8302    #[allow(clippy::too_many_arguments)]
8303    fn encode_shadow_draw<C: FrameCommandRecorder>(
8304        &mut self,
8305        frame_encoder: &mut C,
8306        target_view: &wgpu::TextureView,
8307        shadow: &ShadowDraw,
8308        width: u32,
8309        height: u32,
8310        root_scale: f32,
8311    ) {
8312        if shadow.shapes.is_empty() && shadow.texts.is_empty() {
8313            return;
8314        }
8315
8316        let shape_bounds_opt = shadow
8317            .shapes
8318            .iter()
8319            .map(|(shape, _)| shape.rect)
8320            .reduce(|a, b| Rect {
8321                x: a.x.min(b.x),
8322                y: a.y.min(b.y),
8323                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
8324                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
8325            });
8326
8327        let text_bounds_opt = shadow
8328            .texts
8329            .iter()
8330            .map(|text| text.rect)
8331            .reduce(|a, b| Rect {
8332                x: a.x.min(b.x),
8333                y: a.y.min(b.y),
8334                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
8335                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
8336            });
8337
8338        let combined_bounds = match (shape_bounds_opt, text_bounds_opt) {
8339            (Some(s), Some(t)) => Some(Rect {
8340                x: s.x.min(t.x),
8341                y: s.y.min(t.y),
8342                width: (s.x + s.width).max(t.x + t.width) - s.x.min(t.x),
8343                height: (s.y + s.height).max(t.y + t.height) - s.y.min(t.y),
8344            }),
8345            (Some(s), None) => Some(s),
8346            (None, Some(t)) => Some(t),
8347            (None, None) => None,
8348        };
8349
8350        let Some(shape_bounds) = combined_bounds else {
8351            return;
8352        };
8353
8354        let blur_margin = blur_extent_margin(shadow.blur_radius);
8355        let source_blur_bounds = Rect {
8356            x: shape_bounds.x - blur_margin,
8357            y: shape_bounds.y - blur_margin,
8358            width: shape_bounds.width + blur_margin * 2.0,
8359            height: shape_bounds.height + blur_margin * 2.0,
8360        };
8361        let mut visible_blur_bounds = source_blur_bounds;
8362        if let Some(clip) = shadow.clip {
8363            let clip_expanded = Rect {
8364                x: clip.x - blur_margin,
8365                y: clip.y - blur_margin,
8366                width: clip.width + blur_margin * 2.0,
8367                height: clip.height + blur_margin * 2.0,
8368            };
8369            let Some(intersection) = visible_blur_bounds.intersect(clip_expanded) else {
8370                return;
8371            };
8372            visible_blur_bounds = intersection;
8373        }
8374        let processing_scissor =
8375            scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
8376        if processing_scissor.is_none() {
8377            return;
8378        }
8379
8380        // Zero blur: render shapes directly to target (fast path).
8381        if shadow.blur_radius <= 0.0 {
8382            for (shape, blend_mode) in &shadow.shapes {
8383                self.encode_shapes_pass(
8384                    frame_encoder,
8385                    target_view,
8386                    std::iter::once(shape),
8387                    *blend_mode,
8388                    width,
8389                    height,
8390                    root_scale,
8391                    wgpu::LoadOp::Load,
8392                    [0.0, 0.0],
8393                );
8394                frame_encoder.record_pass();
8395            }
8396            if !shadow.texts.is_empty() {
8397                let mut staged_uploads = self.take_staged_uploads();
8398                let viewport = ViewportUniformParams {
8399                    width,
8400                    height,
8401                    offset: [0.0, 0.0],
8402                };
8403                match self.prepare_text_image_draw_cmds(
8404                    shadow.texts.iter(),
8405                    viewport,
8406                    root_scale,
8407                    &mut staged_uploads,
8408                ) {
8409                    Ok(prepared_images) if !prepared_images.is_empty() => {
8410                        let upload_offset = frame_encoder
8411                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
8412                        self.flush_staged_uploads_at(
8413                            frame_encoder.encoder(),
8414                            &staged_uploads,
8415                            upload_offset,
8416                        );
8417                        let draw_result = {
8418                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
8419                                &wgpu::RenderPassDescriptor {
8420                                    label: Some("Zero Blur Shadow Text Image Pass"),
8421                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8422                                        view: target_view,
8423                                        resolve_target: None,
8424                                        depth_slice: None,
8425                                        ops: wgpu::Operations {
8426                                            load: wgpu::LoadOp::Load,
8427                                            store: wgpu::StoreOp::Store,
8428                                        },
8429                                    })],
8430                                    depth_stencil_attachment: None,
8431                                    timestamp_writes: None,
8432                                    occlusion_query_set: None,
8433                                    multiview_mask: None,
8434                                },
8435                            );
8436                            self.draw_prepared_images(
8437                                &mut render_pass,
8438                                &prepared_images,
8439                                BlendMode::SrcOver,
8440                            )
8441                        };
8442                        self.scratch_image_cmds = prepared_images.into_cmds();
8443                        if let Err(e) = draw_result {
8444                            eprintln!("Failed to draw text for zero-blur shadow: {}", e);
8445                        } else {
8446                            self.frame_stats.bump_text();
8447                            frame_encoder.record_pass();
8448                        }
8449                    }
8450                    Ok(prepared_images) => {
8451                        self.scratch_image_cmds = prepared_images.into_cmds();
8452                    }
8453                    Err(e) => {
8454                        eprintln!("Failed to prepare text image for zero-blur shadow: {}", e);
8455                    }
8456                }
8457                self.restore_staged_uploads(staged_uploads);
8458            }
8459            return;
8460        }
8461
8462        // Compute pixel-space bounds for the offscreen textures, clamped to viewport.
8463        let Some(device_bounds) =
8464            device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)
8465        else {
8466            return;
8467        };
8468        let bounds_x = device_bounds.x;
8469        let bounds_y = device_bounds.y;
8470        let bounds_w = device_bounds.width;
8471        let bounds_h = device_bounds.height;
8472        let pixel_radius = shadow.blur_radius * root_scale;
8473
8474        if shadow.texts.is_empty() && !shadow.shapes.is_empty() {
8475            if let Some(plan) = shape_shadow_surface_plan(
8476                &shadow.shapes,
8477                shadow.clip,
8478                shadow.blur_radius,
8479                width,
8480                height,
8481                root_scale,
8482                self.max_texture_dim(),
8483            ) {
8484                if self.encode_shape_only_blurred_shadow_draw(
8485                    frame_encoder,
8486                    target_view,
8487                    shadow,
8488                    plan.source_device_bounds,
8489                    plan.pixel_radius,
8490                    plan.processing_scissor,
8491                    width,
8492                    height,
8493                    root_scale,
8494                ) {
8495                    return;
8496                }
8497            }
8498        }
8499
8500        if !shadow.texts.is_empty() {
8501            self.frame_stats.record_shadow_text_blur_fallback();
8502        }
8503
8504        let device = self.device.clone();
8505        let source_descriptor =
8506            self.transient_offscreen_descriptor("Shadow Source", bounds_w, bounds_h);
8507        let source = frame_encoder.acquire_transient_offscreen(&device, source_descriptor);
8508        let viewport_offset = [bounds_x, bounds_y];
8509        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
8510        let source_outcome = self.encode_shadow_shape_source_passes(
8511            frame_encoder,
8512            &source.view,
8513            &shadow.shapes,
8514            bounds_w,
8515            bounds_h,
8516            viewport_offset,
8517            root_scale,
8518            &mut next_load_op,
8519        );
8520        frame_encoder.record_passes(source_outcome.pass_count);
8521        let mut rendered_any = source_outcome.rendered_any;
8522
8523        if !shadow.texts.is_empty() {
8524            let mut shifted_texts = shadow.texts.clone();
8525            for text in &mut shifted_texts {
8526                text.rect.x -= viewport_offset[0] / root_scale;
8527                text.rect.y -= viewport_offset[1] / root_scale;
8528                if let Some(clip) = text.clip.as_mut() {
8529                    clip.x -= viewport_offset[0] / root_scale;
8530                    clip.y -= viewport_offset[1] / root_scale;
8531                }
8532            }
8533
8534            let mut staged_uploads = self.take_staged_uploads();
8535            let viewport = ViewportUniformParams {
8536                width: bounds_w,
8537                height: bounds_h,
8538                offset: [0.0, 0.0],
8539            };
8540            match self.prepare_text_image_draw_cmds(
8541                shifted_texts.iter(),
8542                viewport,
8543                root_scale,
8544                &mut staged_uploads,
8545            ) {
8546                Ok(prepared_images) if !prepared_images.is_empty() => {
8547                    let upload_offset = frame_encoder
8548                        .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
8549                    self.flush_staged_uploads_at(
8550                        frame_encoder.encoder(),
8551                        &staged_uploads,
8552                        upload_offset,
8553                    );
8554                    let draw_result = {
8555                        let mut render_pass = frame_encoder.encoder().begin_render_pass(
8556                            &wgpu::RenderPassDescriptor {
8557                                label: Some("Shadow Source Text Image Pass"),
8558                                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8559                                    view: &source.view,
8560                                    resolve_target: None,
8561                                    depth_slice: None,
8562                                    ops: wgpu::Operations {
8563                                        load: next_load_op,
8564                                        store: wgpu::StoreOp::Store,
8565                                    },
8566                                })],
8567                                depth_stencil_attachment: None,
8568                                timestamp_writes: None,
8569                                occlusion_query_set: None,
8570                                multiview_mask: None,
8571                            },
8572                        );
8573                        self.draw_prepared_images(
8574                            &mut render_pass,
8575                            &prepared_images,
8576                            BlendMode::SrcOver,
8577                        )
8578                    };
8579                    self.scratch_image_cmds = prepared_images.into_cmds();
8580                    if let Err(e) = draw_result {
8581                        eprintln!("Failed to draw text for shadow: {}", e);
8582                    } else {
8583                        self.frame_stats.bump_text();
8584                        frame_encoder.record_pass();
8585                        rendered_any = true;
8586                    }
8587                }
8588                Ok(prepared_images) => {
8589                    self.scratch_image_cmds = prepared_images.into_cmds();
8590                }
8591                Err(e) => {
8592                    eprintln!("Failed to prepare text image for shadow: {}", e);
8593                }
8594            }
8595            self.restore_staged_uploads(staged_uploads);
8596        }
8597
8598        if !rendered_any {
8599            frame_encoder.release_transient_offscreen(source_descriptor, source);
8600            return;
8601        }
8602
8603        let scratch_descriptor =
8604            self.transient_offscreen_descriptor("Shadow Blur Scratch", bounds_w, bounds_h);
8605        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
8606        {
8607            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
8608                frame_encoder,
8609                &device,
8610                &source,
8611                &scratch,
8612                &source.view,
8613                pixel_radius,
8614                pixel_radius,
8615                TileMode::Decal,
8616                None, // No scissor needed — the texture is already bounds-sized
8617            );
8618        }
8619        frame_encoder.record_passes(2);
8620
8621        let clip_scissor = shadow
8622            .clip
8623            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
8624        let scissor = clip_scissor.or(processing_scissor);
8625        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
8626            // Adjust mask coordinates from viewport-space to texture-local space,
8627            // since the blit shader computes world_pos = uv * tex_size.
8628            mask.rect[0] -= viewport_offset[0];
8629            mask.rect[1] -= viewport_offset[1];
8630            mask
8631        });
8632        let dest_viewport = Some((
8633            viewport_offset[0],
8634            viewport_offset[1],
8635            bounds_w as f32,
8636            bounds_h as f32,
8637        ));
8638        {
8639            self.effect_renderer
8640                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8641                    frame_encoder,
8642                    &device,
8643                    &source,
8644                    target_view,
8645                    1.0,
8646                    wgpu::LoadOp::Load,
8647                    scissor,
8648                    rounded_mask,
8649                    BlendMode::SrcOver,
8650                    dest_viewport,
8651                    CompositeSampleMode::Linear,
8652                );
8653        }
8654        frame_encoder.record_pass();
8655        self.effect_renderer.record_blur_pass();
8656        self.effect_renderer.record_composite_pass();
8657        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
8658        frame_encoder.release_transient_offscreen(source_descriptor, source);
8659    }
8660
8661    #[allow(clippy::too_many_arguments)]
8662    fn encode_shadow_shape_source_passes<C: FrameCommandRecorder>(
8663        &mut self,
8664        frame_encoder: &mut C,
8665        source_view: &wgpu::TextureView,
8666        shapes: &[(DrawShape, BlendMode)],
8667        width: u32,
8668        height: u32,
8669        viewport_offset: [f32; 2],
8670        root_scale: f32,
8671        next_load_op: &mut wgpu::LoadOp<wgpu::Color>,
8672    ) -> ShadowSourceRenderOutcome {
8673        if shapes.is_empty() {
8674            return ShadowSourceRenderOutcome {
8675                rendered_any: false,
8676                pass_count: 0,
8677            };
8678        }
8679
8680        let mut staged_uploads = self.take_staged_uploads();
8681        let mut rendered_any = false;
8682        let mut pass_count = 0_u32;
8683        let mut start = 0usize;
8684        while start < shapes.len() {
8685            let blend_mode = supported_blend_mode(shapes[start].1);
8686            let mut end = start + 1;
8687            while end < shapes.len()
8688                && end - start < self.shape_batch_limits.max_shapes_per_batch
8689                && supported_blend_mode(shapes[end].1) == blend_mode
8690            {
8691                end += 1;
8692            }
8693
8694            staged_uploads.clear();
8695            let viewport = ViewportUniformParams {
8696                width,
8697                height,
8698                offset: viewport_offset,
8699            };
8700            let Some(prepared_shape) = self.prepare_shapes_batch(
8701                shapes[start..end]
8702                    .iter()
8703                    .map(|(shape, _blend_mode)| shape)
8704                    .filter(|shape| shape_draw_is_visible_in_viewport(shape, viewport, root_scale)),
8705                root_scale,
8706                viewport,
8707                &mut staged_uploads,
8708            ) else {
8709                start = end;
8710                continue;
8711            };
8712
8713            let upload_offset =
8714                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
8715            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
8716
8717            {
8718                let mut render_pass =
8719                    frame_encoder
8720                        .encoder()
8721                        .begin_render_pass(&wgpu::RenderPassDescriptor {
8722                            label: Some("Shadow Source Shape Pass"),
8723                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8724                                view: source_view,
8725                                resolve_target: None,
8726                                depth_slice: None,
8727                                ops: wgpu::Operations {
8728                                    load: *next_load_op,
8729                                    store: wgpu::StoreOp::Store,
8730                                },
8731                            })],
8732                            depth_stencil_attachment: None,
8733                            timestamp_writes: None,
8734                            occlusion_query_set: None,
8735                            multiview_mask: None,
8736                        });
8737                self.draw_prepared_shapes(
8738                    &mut render_pass,
8739                    blend_mode,
8740                    prepared_shape,
8741                    width,
8742                    height,
8743                );
8744            }
8745
8746            pass_count = pass_count.saturating_add(1);
8747            rendered_any = true;
8748            *next_load_op = wgpu::LoadOp::Load;
8749            start = end;
8750        }
8751
8752        self.restore_staged_uploads(staged_uploads);
8753        ShadowSourceRenderOutcome {
8754            rendered_any,
8755            pass_count,
8756        }
8757    }
8758
8759    #[allow(clippy::too_many_arguments)]
8760    fn encode_shape_only_blurred_shadow_draw<C: FrameCommandRecorder>(
8761        &mut self,
8762        frame_encoder: &mut C,
8763        target_view: &wgpu::TextureView,
8764        shadow: &ShadowDraw,
8765        device_bounds: DevicePixelBounds,
8766        pixel_radius: f32,
8767        processing_scissor: Option<(u32, u32, u32, u32)>,
8768        width: u32,
8769        height: u32,
8770        root_scale: f32,
8771    ) -> bool {
8772        let bounds_w = device_bounds.width;
8773        let bounds_h = device_bounds.height;
8774        let viewport_offset = [device_bounds.x, device_bounds.y];
8775        let cache_key =
8776            shape_shadow_surface_cache_key(&shadow.shapes, device_bounds, pixel_radius, root_scale);
8777
8778        if let Some(key) = cache_key {
8779            if let Some(cached) = self.cached_shadow_surface(&key) {
8780                self.frame_stats
8781                    .record_shadow_shape_cache_hit(bounds_w, bounds_h);
8782                let clip_scissor = shadow
8783                    .clip
8784                    .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
8785                let scissor = clip_scissor.or(processing_scissor);
8786                let rounded_mask =
8787                    inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
8788                        mask.rect[0] -= viewport_offset[0];
8789                        mask.rect[1] -= viewport_offset[1];
8790                        mask
8791                    });
8792                let dest_viewport = Some((
8793                    viewport_offset[0],
8794                    viewport_offset[1],
8795                    bounds_w as f32,
8796                    bounds_h as f32,
8797                ));
8798                {
8799                    self.effect_renderer
8800                        .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8801                            frame_encoder,
8802                            &self.device,
8803                            &cached,
8804                            target_view,
8805                            1.0,
8806                            wgpu::LoadOp::Load,
8807                            scissor,
8808                            rounded_mask,
8809                            BlendMode::SrcOver,
8810                            dest_viewport,
8811                            CompositeSampleMode::Nearest,
8812                        );
8813                }
8814                frame_encoder.record_pass();
8815                self.effect_renderer.record_composite_pass();
8816                return true;
8817            }
8818            self.frame_stats
8819                .record_shadow_shape_cache_miss(bounds_w, bounds_h);
8820            self.frame_stats.maybe_print_shadow_shape_cache_miss(
8821                bounds_w,
8822                bounds_h,
8823                key.content_hash,
8824                pixel_radius,
8825                viewport_offset,
8826                shadow.shapes.len(),
8827                shadow.clip,
8828            );
8829        }
8830
8831        let device = self.device.clone();
8832        let source_descriptor =
8833            self.transient_offscreen_descriptor("Shape Shadow Source", bounds_w, bounds_h);
8834        let source_is_cacheable = cache_key.is_some();
8835        let source = if source_is_cacheable {
8836            self.acquire_retained_surface(bounds_w, bounds_h)
8837        } else {
8838            frame_encoder.acquire_transient_offscreen(&device, source_descriptor)
8839        };
8840        let scratch_descriptor =
8841            self.transient_offscreen_descriptor("Shape Shadow Blur Scratch", bounds_w, bounds_h);
8842        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
8843        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
8844        let source_outcome = self.encode_shadow_shape_source_passes(
8845            frame_encoder,
8846            &source.view,
8847            &shadow.shapes,
8848            bounds_w,
8849            bounds_h,
8850            viewport_offset,
8851            root_scale,
8852            &mut next_load_op,
8853        );
8854        frame_encoder.record_passes(source_outcome.pass_count);
8855
8856        if !source_outcome.rendered_any {
8857            frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
8858            if source_is_cacheable {
8859                self.defer_offscreen_release(source);
8860            } else {
8861                frame_encoder.release_transient_offscreen(source_descriptor, source);
8862            }
8863            return true;
8864        }
8865
8866        {
8867            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
8868                frame_encoder,
8869                &device,
8870                &source,
8871                &scratch,
8872                &source.view,
8873                pixel_radius,
8874                pixel_radius,
8875                TileMode::Decal,
8876                None,
8877            );
8878        }
8879        frame_encoder.record_passes(2);
8880
8881        let clip_scissor = shadow
8882            .clip
8883            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
8884        let scissor = clip_scissor.or(processing_scissor);
8885        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
8886            mask.rect[0] -= viewport_offset[0];
8887            mask.rect[1] -= viewport_offset[1];
8888            mask
8889        });
8890        let dest_viewport = Some((
8891            viewport_offset[0],
8892            viewport_offset[1],
8893            bounds_w as f32,
8894            bounds_h as f32,
8895        ));
8896        {
8897            self.effect_renderer
8898                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8899                    frame_encoder,
8900                    &device,
8901                    &source,
8902                    target_view,
8903                    1.0,
8904                    wgpu::LoadOp::Load,
8905                    scissor,
8906                    rounded_mask,
8907                    BlendMode::SrcOver,
8908                    dest_viewport,
8909                    CompositeSampleMode::Nearest,
8910                );
8911        }
8912        frame_encoder.record_pass();
8913
8914        self.effect_renderer.record_blur_pass();
8915        self.effect_renderer.record_composite_pass();
8916        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
8917        if let Some(key) = cache_key {
8918            self.insert_cached_shadow_surface(key, source);
8919        } else {
8920            frame_encoder.release_transient_offscreen(source_descriptor, source);
8921        }
8922        true
8923    }
8924
8925    fn prepare_shapes_batch<'a, I>(
8926        &mut self,
8927        layer_shapes: I,
8928        root_scale: f32,
8929        viewport: ViewportUniformParams,
8930        staged_uploads: &mut StagedBufferUploads,
8931    ) -> Option<PreparedShapeBatch>
8932    where
8933        I: Iterator<Item = &'a DrawShape>,
8934    {
8935        #[cfg(target_arch = "wasm32")]
8936        let _ = staged_uploads;
8937
8938        // Build shape data for this subset. Callers hand in only shapes visible in
8939        // `viewport`: the segment paths culled at collect time, and the layer and
8940        // shadow-source paths filter at the call site. Re-checking here would run
8941        // the same quad math a second time on every shape of every frame.
8942        let shape_refs: Vec<&DrawShape> = layer_shapes
8943            .take(self.shape_batch_limits.max_shapes_per_batch)
8944            .collect();
8945        let shape_count = shape_refs.len();
8946        if shape_count == 0 {
8947            return None;
8948        }
8949
8950        // Per-shape gradient spans as a prefix sum, so every output slot is
8951        // known before conversion starts and the shapes can convert in
8952        // parallel into disjoint sub-slices.
8953        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
8954        let mut total_gradient_stops = 0u32;
8955        gradient_offsets.push(0);
8956        for shape in &shape_refs {
8957            total_gradient_stops += shape_gradient_stop_count(shape) as u32;
8958            gradient_offsets.push(total_gradient_stops);
8959        }
8960
8961        self.scratch_shape_data.clear();
8962        self.scratch_shape_data
8963            .resize(shape_count, ShapeData::zeroed());
8964        self.scratch_gradients.clear();
8965        self.scratch_gradients
8966            .resize(total_gradient_stops as usize, GradientStop::zeroed());
8967
8968        convert_shapes_into_outputs(
8969            &shape_refs,
8970            &gradient_offsets,
8971            root_scale,
8972            &mut self.scratch_shape_data,
8973            &mut self.scratch_gradients,
8974        );
8975
8976        #[cfg(not(target_arch = "wasm32"))]
8977        {
8978            self.shape_buffers.ensure_capacity(
8979                &self.device,
8980                &self.shape_bind_group_layout,
8981                &self.identity_similarity_buffer,
8982                self.dummy_paint_buffer.as_ref(),
8983                shape_count,
8984                self.scratch_gradients.len().max(1),
8985            );
8986            self.stage_viewport_uniforms(staged_uploads, viewport);
8987            staged_uploads.stage(
8988                UploadTarget::ShapeData,
8989                bytemuck::cast_slice(&self.scratch_shape_data),
8990            );
8991            if !self.scratch_gradients.is_empty() {
8992                staged_uploads.stage(
8993                    UploadTarget::ShapeGradient,
8994                    bytemuck::cast_slice(&self.scratch_gradients),
8995                );
8996            }
8997        }
8998
8999        #[cfg(target_arch = "wasm32")]
9000        let shape_slot = {
9001            let slot = self.claim_wasm_shape_batch();
9002            {
9003                let buffers = &mut self.wasm_shape_batches[slot];
9004                buffers.ensure_capacity(
9005                    &self.device,
9006                    &self.shape_bind_group_layout,
9007                    &self.identity_similarity_buffer,
9008                    self.dummy_paint_buffer.as_ref(),
9009                    shape_count,
9010                    self.scratch_gradients.len().max(1),
9011                );
9012            }
9013            let buffers = &self.wasm_shape_batches[slot];
9014            self.write_wasm_buffer(
9015                &buffers.shape_buffer,
9016                bytemuck::cast_slice(&self.scratch_shape_data),
9017            );
9018            if !self.scratch_gradients.is_empty() {
9019                self.write_wasm_buffer(
9020                    &buffers.gradient_buffer,
9021                    bytemuck::cast_slice(&self.scratch_gradients),
9022                );
9023            }
9024            slot
9025        };
9026
9027        #[cfg(target_arch = "wasm32")]
9028        let uniform_slot = self.prepare_wasm_viewport_uniforms(viewport);
9029
9030        Some(PreparedShapeBatch {
9031            vertex_start: 0,
9032            vertex_count: shape_count as u32 * 6,
9033            has_gradient: total_gradient_stops > 0,
9034            #[cfg(target_arch = "wasm32")]
9035            shape_slot,
9036            #[cfg(target_arch = "wasm32")]
9037            uniform_slot,
9038        })
9039    }
9040
9041    /// Like [`Self::prepare_shapes_batch`], but converts shapes straight into
9042    /// mapped regions of the frame upload buffer instead of scratch vectors —
9043    /// one CPU pass over the data instead of three (convert, stage, upload).
9044    /// Returns the prepared batch and the upload-buffer base offset to pass
9045    /// to `flush_staged_uploads_at`; the GPU copies are recorded into
9046    /// `staged_uploads` while its byte blob stays empty.
9047    #[cfg(not(target_arch = "wasm32"))]
9048    fn prepare_shapes_batch_direct<'a, I, C: FrameCommandRecorder>(
9049        &mut self,
9050        frame_encoder: &mut C,
9051        layer_shapes: I,
9052        root_scale: f32,
9053        viewport: ViewportUniformParams,
9054        staged_uploads: &mut StagedBufferUploads,
9055    ) -> Option<(PreparedShapeBatch, u64)>
9056    where
9057        I: Iterator<Item = &'a DrawShape>,
9058    {
9059        let shape_refs: Vec<&DrawShape> = layer_shapes
9060            .take(self.shape_batch_limits.max_shapes_per_batch)
9061            .collect();
9062        let shape_count = shape_refs.len();
9063        if shape_count == 0 {
9064            return None;
9065        }
9066
9067        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
9068        let mut total_gradient_stops = 0u32;
9069        gradient_offsets.push(0);
9070        for shape in &shape_refs {
9071            total_gradient_stops += shape_gradient_stop_count(shape) as u32;
9072            gradient_offsets.push(total_gradient_stops);
9073        }
9074
9075        self.shape_buffers.ensure_capacity(
9076            &self.device,
9077            &self.shape_bind_group_layout,
9078            &self.identity_similarity_buffer,
9079            self.dummy_paint_buffer.as_ref(),
9080            shape_count,
9081            (total_gradient_stops as usize).max(1),
9082        );
9083
9084        self.scratch_shape_data.clear();
9085        self.scratch_shape_data
9086            .resize(shape_count, ShapeData::zeroed());
9087        self.scratch_gradients.clear();
9088        self.scratch_gradients
9089            .resize(total_gradient_stops as usize, GradientStop::zeroed());
9090        convert_shapes_into_outputs(
9091            &shape_refs,
9092            &gradient_offsets,
9093            root_scale,
9094            &mut self.scratch_shape_data,
9095            &mut self.scratch_gradients,
9096        );
9097
9098        // Region layout inside the frame upload buffer. Every element type is
9099        // f32/u32-based, so all lengths are multiples of
9100        // `COPY_BUFFER_ALIGNMENT` and back-to-back packing keeps each offset
9101        // copy-aligned. Writing each scratch slice straight into the upload
9102        // buffer skips the intermediate staged-bytes blob (one fewer CPU pass
9103        // over the batch payload).
9104        let uniform_len = std::mem::size_of::<Uniforms>() as u64;
9105        let shape_len = (shape_count * std::mem::size_of::<ShapeData>()) as u64;
9106        let gradient_len = total_gradient_stops as u64 * std::mem::size_of::<GradientStop>() as u64;
9107        let total_len = uniform_len + shape_len + gradient_len;
9108        let upload_base = frame_encoder.allocate_staged_upload_bytes(total_len);
9109        self.ensure_upload_buffer_capacity(upload_base + total_len);
9110
9111        let shape_off = uniform_len;
9112        let gradient_off = shape_off + shape_len;
9113
9114        let uniforms = Self::viewport_uniforms(viewport);
9115        let mut upload_stats = self.frame_graph_executor.upload_buffer(
9116            &self.queue,
9117            &self.upload_buffer,
9118            upload_base,
9119            bytemuck::bytes_of(&uniforms),
9120        );
9121        upload_stats.upload_bytes += self
9122            .frame_graph_executor
9123            .upload_buffer(
9124                &self.queue,
9125                &self.upload_buffer,
9126                upload_base + shape_off,
9127                bytemuck::cast_slice(&self.scratch_shape_data),
9128            )
9129            .upload_bytes;
9130        if !self.scratch_gradients.is_empty() {
9131            upload_stats.upload_bytes += self
9132                .frame_graph_executor
9133                .upload_buffer(
9134                    &self.queue,
9135                    &self.upload_buffer,
9136                    upload_base + gradient_off,
9137                    bytemuck::cast_slice(&self.scratch_gradients),
9138                )
9139                .upload_bytes;
9140        }
9141        self.frame_stats.record_command_stats(upload_stats);
9142
9143        staged_uploads.record_upload_copy(UploadTarget::Uniform, 0, 0, uniform_len);
9144        staged_uploads.record_upload_copy(UploadTarget::ShapeData, shape_off, 0, shape_len);
9145        staged_uploads.record_upload_copy(
9146            UploadTarget::ShapeGradient,
9147            gradient_off,
9148            0,
9149            gradient_len,
9150        );
9151
9152        Some((
9153            PreparedShapeBatch {
9154                vertex_start: 0,
9155                vertex_count: shape_count as u32 * 6,
9156                has_gradient: total_gradient_stops > 0,
9157            },
9158            upload_base,
9159        ))
9160    }
9161
9162    /// Whether retained replay batches can exist on this device: they bind
9163    /// unsized buffers, so they ride the storage-buffer batch mode only.
9164    /// Always `false` on wasm, which has no retained replay path — the
9165    /// method exists on both arches so the packet producer has one
9166    /// architecture.
9167    pub(crate) fn replay_supported(&self) -> bool {
9168        // Deliberately not conditioned on free slot ids: an exhausted pool
9169        // only means new captures fail (handled per capture), while flipping
9170        // this bit would retire every live feed slot.
9171        #[cfg(target_arch = "wasm32")]
9172        {
9173            false
9174        }
9175        #[cfg(not(target_arch = "wasm32"))]
9176        {
9177            self.shape_batch_limits.storage
9178        }
9179    }
9180
9181    /// Return the planner-drained ack confirmations buffer (capacity
9182    /// intact) to the store after the producer applied a frame's
9183    /// [`crate::frame_packet::ReplayAck`] — the ack channel's half of the
9184    /// P4b no-allocation contract, closed by the caller now that ack
9185    /// application lives producer-side. No-op on wasm.
9186    pub(crate) fn restore_replay_ack_confirmations(
9187        &mut self,
9188        confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
9189    ) {
9190        #[cfg(not(target_arch = "wasm32"))]
9191        {
9192            self.replay_ack_confirmations = confirmations;
9193        }
9194        #[cfg(target_arch = "wasm32")]
9195        let _ = confirmations;
9196    }
9197
9198    /// Present-side consumption of one frame's [`ReplayFrameOps`]: frees
9199    /// the plan's releases, then honors its capture requests against the
9200    /// scene they were recorded for, answering with a [`ReplayAck`] of
9201    /// (identity, gpu slot) confirmations plus the batch's emptied buffers
9202    /// for recycling. This is the store half of the split — it touches NO
9203    /// planner state: `feed_slots`, confirmation stamping, displaced-slot
9204    /// release, and age eviction all live in the planner
9205    /// (`take_frame_ops`/`apply_ack`).
9206    ///
9207    /// Ordering is what makes slot release safe: a slot the plan releases
9208    /// is never referenced by a retained op of the same frame (misses
9209    /// release before their op would have been pushed, and rebuild frames
9210    /// release at flush start), so freeing it here — before any encoding —
9211    /// cannot orphan a draw.
9212    #[cfg(not(target_arch = "wasm32"))]
9213    fn consume_replay_ops(
9214        &mut self,
9215        mut ops: crate::frame_packet::ReplayFrameOps,
9216        shapes: &[DrawShape],
9217        root_scale: f32,
9218    ) -> (
9219        crate::frame_packet::ReplayAck,
9220        crate::frame_packet::ReplayFrameOps,
9221    ) {
9222        if ops.generation < self.store_feed_generation {
9223            // Fail-closed: ops planned under an OLDER slot universe name
9224            // slots this store does not hold. Drop the batch whole —
9225            // captures unconfirmed self-heal (the planner never serves
9226            // them), and stale releases must not free live ids.
9227            // Synchronously impossible today; structural for the split.
9228            self.replay_generation_drops += 1;
9229            log::warn!(
9230                "[command-feed] dropping replay ops of generation {} against store \
9231                 generation {} ({} captures, {} patches, {} releases; lifetime drops {})",
9232                ops.generation,
9233                self.store_feed_generation,
9234                ops.captures.len(),
9235                ops.color_patches.len(),
9236                ops.releases.len(),
9237                self.replay_generation_drops,
9238            );
9239            ops.captures.clear();
9240            ops.color_patches.clear();
9241            ops.releases.clear();
9242            return (
9243                crate::frame_packet::ReplayAck {
9244                    generation: self.store_feed_generation,
9245                    confirmations: Vec::new(),
9246                },
9247                ops,
9248            );
9249        }
9250        if ops.generation > self.store_feed_generation {
9251            // Adopt forward: a producer-side bump (scale change,
9252            // `retire_feed`) delivers its whole retirement — the releases
9253            // for every retired slot — THROUGH this very batch, so a
9254            // higher generation is the new universe arriving, not a stale
9255            // one. The store follows the producer's authority; it never
9256            // reads the producer's thread-local.
9257            self.store_feed_generation = ops.generation;
9258        }
9259        let generation = ops.generation;
9260        // Queued releases free first, so their buffers are available before
9261        // this frame's captures ask.
9262        for slot in ops.releases.drain(..) {
9263            self.release_replay_slot(slot);
9264        }
9265        // `take` leaves `Vec::new()` behind (no allocation); the render
9266        // loop restores the vec after the planner drains the ack.
9267        let mut confirmations = std::mem::take(&mut self.replay_ack_confirmations);
9268        debug_assert!(confirmations.is_empty());
9269        for capture in ops.captures.drain(..) {
9270            if capture.frame != ops.frame {
9271                // Defensive: a capture that outlived its frame references
9272                // shape indices of a scene that never rendered; honoring it
9273                // against THIS frame's shapes would retain wrong content
9274                // under a confirmed identity. Categorically drop it. Should
9275                // never fire now that ops travel inside the frame's own
9276                // packet.
9277                log::warn!(
9278                    "[command-feed] dropping stale capture for slot {} of {:?} \
9279                     (queued frame {}, ops frame {})",
9280                    capture.key.1,
9281                    capture.key.0,
9282                    capture.frame,
9283                    ops.frame,
9284                );
9285                continue;
9286            }
9287            let end = capture.shape_start + capture.shape_count;
9288            let Some(slice) = shapes.get(capture.shape_start..end) else {
9289                continue;
9290            };
9291            let refs: Vec<&DrawShape> = slice.iter().collect();
9292            let Some(gpu_slot) = self.capture_replay_slot(&refs, root_scale) else {
9293                continue;
9294            };
9295            confirmations.push((capture.key, gpu_slot));
9296        }
9297        // Park the frame's recolor patches for the retained prepare arms
9298        // (`stage_replay_patches`); the vec swapped out is last frame's,
9299        // already drained empty, and returns to the producer with the ack.
9300        // The defensive clear only bites when no prepare arm ran last
9301        // frame (aborted render): those patches targeted a frame that
9302        // never encoded, and their spans re-queue fresh recolors each
9303        // served frame.
9304        self.replay_color_patches.clear();
9305        std::mem::swap(&mut self.replay_color_patches, &mut ops.color_patches);
9306        (
9307            crate::frame_packet::ReplayAck {
9308                generation,
9309                confirmations,
9310            },
9311            ops,
9312        )
9313    }
9314
9315    /// Test/diagnostic view of the store's lifetime count of replay-ops
9316    /// batches dropped whole by the generation check — the consume gate's
9317    /// proof that Surface frames (default plans, generation 0) are never
9318    /// fed to the store.
9319    #[cfg(not(target_arch = "wasm32"))]
9320    pub(crate) fn replay_generation_drops(&self) -> u64 {
9321        self.replay_generation_drops
9322    }
9323
9324    /// Test hook for the message protocol: runs one planner→store→planner
9325    /// replay cycle outside a frame, with the batch stamped
9326    /// `store_feed_generation + generation_skew`, and returns how many
9327    /// captures the store confirmed. A skew that lands BELOW the store's
9328    /// generation manufactures the fail-closed drop; a skew above it
9329    /// exercises adopt-forward. Both are synchronously impossible through
9330    /// the public render path today.
9331    #[cfg(not(target_arch = "wasm32"))]
9332    pub(crate) fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
9333        let generation = self.store_feed_generation.wrapping_add(generation_skew);
9334        let ops = crate::shape_replay::SHAPE_REPLAY
9335            .with(|state| state.borrow_mut().take_frame_ops(generation));
9336        let (ack, recycled) = self.consume_replay_ops(ops, &[], 1.0);
9337        let confirmed = ack.confirmations.len();
9338        self.replay_ack_confirmations = crate::shape_replay::SHAPE_REPLAY
9339            .with(|state| state.borrow_mut().apply_ack(ack, recycled));
9340        confirmed
9341    }
9342
9343    /// Stages every queued replay recolor patch. Feed recolors are always
9344    /// solid, so every patch rewrites the shape's 16-byte record in the
9345    /// slot's paint buffer; the captured `ShapeData` itself is immutable, so
9346    /// a recolored frame uploads colors, not geometry. Runs in the retained
9347    /// prepare arms so the writes land in the same staged-upload flush that
9348    /// carries the frame's transforms; draining is idempotent across arms.
9349    #[cfg(not(target_arch = "wasm32"))]
9350    fn stage_replay_patches(&mut self, staged_uploads: &mut StagedBufferUploads) {
9351        // Capacity-retaining drain: swap the frame's parked patch buffer
9352        // (see `consume_replay_ops`) against the scratch arena instead of
9353        // `mem::take`, so both keep their high-water capacity across
9354        // frames. The scratch is cleared before every return, which
9355        // preserves drain idempotence across the retained prepare arms: a
9356        // later drain in the same frame swaps one empty-with-capacity
9357        // arena for another and stages nothing.
9358        std::mem::swap(
9359            &mut self.replay_color_patches,
9360            &mut self.color_patch_scratch,
9361        );
9362        let total_patches = self.color_patch_scratch.len();
9363        if total_patches == 0 {
9364            self.replay_upload_stats.note_frame(0, 0, 0, 0, 0);
9365            return;
9366        }
9367
9368        // Patches land in the slot's CPU mirror and upload as one contiguous
9369        // span per slot. Uploading each patch individually would record one
9370        // copy command per patch, and MEGA's twinkle field recolors ~1.7k
9371        // dots a frame — that many commands stall a mobile GPU for longer
9372        // than the spans' untouched bytes ever cost.
9373        #[derive(Clone, Copy)]
9374        struct DirtySpan {
9375            paint_min: u32,
9376            paint_max: u32,
9377        }
9378        const CLEAN: DirtySpan = DirtySpan {
9379            paint_min: u32::MAX,
9380            paint_max: 0,
9381        };
9382        let mut dirty: std::collections::HashMap<
9383            u32,
9384            DirtySpan,
9385            cranpose_ui_graphics::FxBuildHasher,
9386        > = std::collections::HashMap::default();
9387
9388        // One bare 16-byte write into the slot's paint mirror per patch.
9389        for patch in &self.color_patch_scratch {
9390            let Some(slot) = self.replay_slots.slots.get_mut(&patch.slot) else {
9391                continue;
9392            };
9393            let Some(paint) = slot.paint_mirror.get_mut(patch.shape_index as usize) else {
9394                continue;
9395            };
9396            *paint = patch.color;
9397            let span = dirty.entry(patch.slot).or_insert(CLEAN);
9398            span.paint_min = span.paint_min.min(patch.shape_index);
9399            span.paint_max = span.paint_max.max(patch.shape_index);
9400        }
9401
9402        let mut uploaded_records = 0u64;
9403        let mut uploaded_bytes = 0u64;
9404        let slots_touched = dirty.len() as u64;
9405        for (slot_id, span) in dirty {
9406            let Some(slot) = self.replay_slots.slots.get(&slot_id) else {
9407                continue;
9408            };
9409            if span.paint_min <= span.paint_max {
9410                let range = span.paint_min as usize..span.paint_max as usize + 1;
9411                uploaded_records += range.len() as u64;
9412                uploaded_bytes += (range.len() * std::mem::size_of::<[f32; 4]>()) as u64;
9413                staged_uploads.stage_at(
9414                    UploadTarget::ReplayPaintData(slot_id),
9415                    range.start as u64 * std::mem::size_of::<[f32; 4]>() as u64,
9416                    bytemuck::cast_slice(&slot.paint_mirror[range]),
9417                );
9418            }
9419        }
9420        // A patched color is one 16-byte vec4; the staged bytes exceed this
9421        // only by the untouched records inside each coalesced span.
9422        let ideal_bytes = total_patches as u64 * 16;
9423        self.replay_upload_stats.note_frame(
9424            total_patches as u64,
9425            slots_touched,
9426            uploaded_records,
9427            uploaded_bytes,
9428            ideal_bytes,
9429        );
9430        if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
9431            log::warn!(
9432                "[replay-upload] frame: {} patches -> {} records / {:.1} KB staged \
9433                 across {} slots (color-only {:.1} KB)",
9434                total_patches,
9435                uploaded_records,
9436                uploaded_bytes as f64 / 1024.0,
9437                slots_touched,
9438                ideal_bytes as f64 / 1024.0,
9439            );
9440        }
9441        self.color_patch_scratch.clear();
9442    }
9443
9444    /// Converts `shape_refs` once and retains the result on the GPU as a
9445    /// replay slot. Returns the slot id the scene's retained draws reference.
9446    #[cfg(not(target_arch = "wasm32"))]
9447    pub(crate) fn capture_replay_slot(
9448        &mut self,
9449        shape_refs: &[&DrawShape],
9450        root_scale: f32,
9451    ) -> Option<u32> {
9452        if !self.shape_batch_limits.storage || shape_refs.is_empty() {
9453            return None;
9454        }
9455        let id = self.replay_slots.free_ids.pop()?;
9456        let shape_count = shape_refs.len();
9457
9458        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
9459        let mut total_gradient_stops = 0u32;
9460        gradient_offsets.push(0);
9461        for shape in shape_refs {
9462            total_gradient_stops += shape_gradient_stop_count(shape) as u32;
9463            gradient_offsets.push(total_gradient_stops);
9464        }
9465
9466        let mut shape_data = vec![ShapeData::zeroed(); shape_count];
9467        let mut gradients = vec![GradientStop::zeroed(); (total_gradient_stops as usize).max(1)];
9468        convert_shapes_into_outputs(
9469            shape_refs,
9470            &gradient_offsets,
9471            root_scale,
9472            &mut shape_data,
9473            &mut gradients,
9474        );
9475
9476        let shape_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9477            label: Some("Replay Shape Buffer"),
9478            size: (std::mem::size_of::<ShapeData>() * shape_count) as u64,
9479            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
9480            mapped_at_creation: true,
9481        });
9482        shape_buffer
9483            .slice(..)
9484            .get_mapped_range_mut()
9485            .copy_from_slice(bytemuck::cast_slice(&shape_data));
9486        shape_buffer.unmap();
9487
9488        let gradient_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9489            label: Some("Replay Gradient Buffer"),
9490            size: (std::mem::size_of::<GradientStop>() * gradients.len()) as u64,
9491            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
9492            mapped_at_creation: true,
9493        });
9494        gradient_buffer
9495            .slice(..)
9496            .get_mapped_range_mut()
9497            .copy_from_slice(bytemuck::cast_slice(&gradients));
9498        gradient_buffer.unmap();
9499
9500        let mesh = if arc_mesh_enabled() {
9501            match build_arc_mesh_vertices(&shape_data) {
9502                Some(build) => {
9503                    let cut = if build.quad_area > 0.0 {
9504                        (1.0 - build.mesh_area / build.quad_area) * 100.0
9505                    } else {
9506                        0.0
9507                    };
9508                    // Always-on warn: `log::info` is invisible on the desktop
9509                    // console, and captures are rare — one line per slot
9510                    // lifetime. The unique-vert/index counts against the
9511                    // six-per-shape quad baseline are the vertex-amplification
9512                    // instrument P1b exists for.
9513                    log::warn!(
9514                        "[arc-mesh] slot {id}: {} arcs meshed ({} segs), {} passthrough; \
9515                         {} unique verts / {} indices (quad path: {} verts); \
9516                         quad_px {:.0} -> mesh_px {:.0} (-{:.1}%)",
9517                        build.meshed_arcs,
9518                        build.meshed_segments,
9519                        build.passthrough,
9520                        build.vertices.len(),
9521                        build.indices.len(),
9522                        shape_count * 6,
9523                        build.quad_area,
9524                        build.mesh_area,
9525                        cut,
9526                    );
9527                    // A slot that meshed nothing gains nothing over the
9528                    // indexless quad path — skip the buffers.
9529                    (build.meshed_arcs > 0).then(|| {
9530                        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9531                            label: Some("Replay Mesh Vertex Buffer"),
9532                            size: (std::mem::size_of::<MeshVertex>() * build.vertices.len()) as u64,
9533                            usage: wgpu::BufferUsages::VERTEX,
9534                            mapped_at_creation: true,
9535                        });
9536                        vertex_buffer
9537                            .slice(..)
9538                            .get_mapped_range_mut()
9539                            .copy_from_slice(bytemuck::cast_slice(&build.vertices));
9540                        vertex_buffer.unmap();
9541                        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9542                            label: Some("Replay Mesh Index Buffer"),
9543                            size: (std::mem::size_of::<u32>() * build.indices.len()) as u64,
9544                            usage: wgpu::BufferUsages::INDEX,
9545                            mapped_at_creation: true,
9546                        });
9547                        index_buffer
9548                            .slice(..)
9549                            .get_mapped_range_mut()
9550                            .copy_from_slice(bytemuck::cast_slice(&build.indices));
9551                        index_buffer.unmap();
9552                        ReplaySlotMesh {
9553                            vertex_buffer,
9554                            index_buffer,
9555                            index_prefix: build.index_prefix,
9556                        }
9557                    })
9558                }
9559                None => {
9560                    log::warn!(
9561                        "[arc-mesh] slot {id}: geometry byte budget overflowed for \
9562                         {shape_count} shapes; whole slot falls back to quad passthrough"
9563                    );
9564                    None
9565                }
9566            }
9567        } else {
9568            None
9569        };
9570
9571        // Seed the mutable paint from the converted colors, so an unpatched
9572        // replay renders bit-identically to the capture frame.
9573        let paint: Vec<[f32; 4]> = shape_data.iter().map(|shape| shape.color).collect();
9574        let paint_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9575            label: Some("Replay Paint Buffer"),
9576            size: (std::mem::size_of::<[f32; 4]>() * shape_count) as u64,
9577            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
9578            mapped_at_creation: true,
9579        });
9580        paint_buffer
9581            .slice(..)
9582            .get_mapped_range_mut()
9583            .copy_from_slice(bytemuck::cast_slice(&paint));
9584        paint_buffer.unmap();
9585
9586        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
9587            label: Some("Replay Shape Bind Group"),
9588            layout: &self.shape_bind_group_layout,
9589            entries: &[
9590                wgpu::BindGroupEntry {
9591                    binding: 0,
9592                    resource: shape_buffer.as_entire_binding(),
9593                },
9594                wgpu::BindGroupEntry {
9595                    binding: 1,
9596                    resource: gradient_buffer.as_entire_binding(),
9597                },
9598                // The transform slot is selected per draw via the dynamic
9599                // offset, so retained draws sharing this capture can each
9600                // move independently.
9601                wgpu::BindGroupEntry {
9602                    binding: 2,
9603                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
9604                        buffer: &self.replay_slots.transform_buffer,
9605                        offset: 0,
9606                        size: Some(
9607                            std::num::NonZeroU64::new(
9608                                std::mem::size_of::<SimilarityTransform>() as u64
9609                            )
9610                            .expect("similarity transform is non-empty"),
9611                        ),
9612                    }),
9613                },
9614                wgpu::BindGroupEntry {
9615                    binding: 3,
9616                    resource: paint_buffer.as_entire_binding(),
9617                },
9618            ],
9619        });
9620
9621        let capture_epoch = self.replay_slots.next_capture_epoch;
9622        self.replay_slots.next_capture_epoch += 1;
9623        self.replay_slots.slots.insert(
9624            id,
9625            ReplaySlot {
9626                paint_buffer,
9627                bind_group,
9628                shape_count: shape_count as u32,
9629                paint_mirror: paint,
9630                mesh,
9631                capture_epoch,
9632                has_gradient: total_gradient_stops > 0,
9633            },
9634        );
9635        Some(id)
9636    }
9637
9638    /// Frees a replay slot's GPU resources and returns its id to the pool.
9639    #[cfg(not(target_arch = "wasm32"))]
9640    pub(crate) fn release_replay_slot(&mut self, id: u32) {
9641        if self.replay_slots.slots.remove(&id).is_some() {
9642            self.replay_slots.free_ids.push(id);
9643            // A cached bundle keeps references on the slot buffers it binds.
9644            // The epoch in each key already makes entries for this capture
9645            // unreachable — releases are rare (churn, retire_feed), so drop
9646            // the whole cache and free those references now rather than one
9647            // frame later through eviction.
9648            self.retained_bundle_cache.clear();
9649        }
9650    }
9651
9652    /// Test/diagnostic view of the latched instanced-quad selection: `true`
9653    /// when this renderer's ordinary shape draws ride `vs_shape_instanced`.
9654    #[cfg(not(target_arch = "wasm32"))]
9655    #[doc(hidden)]
9656    pub fn instanced_quads_active(&self) -> bool {
9657        self.instanced_quads.is_some()
9658    }
9659
9660    /// Test/diagnostic view of retained arc meshes: how many live replay
9661    /// slots hold a mesh, out of all live slots.
9662    #[cfg(not(target_arch = "wasm32"))]
9663    #[doc(hidden)]
9664    pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
9665        let meshed = self
9666            .replay_slots
9667            .slots
9668            .values()
9669            .filter(|slot| slot.mesh.is_some())
9670            .count();
9671        (meshed, self.replay_slots.slots.len())
9672    }
9673
9674    /// Draws one retained replay batch — `retained`'s shape range of its
9675    /// slot's capture, under the transform staged for this draw's index (see
9676    /// the retained arms of the segment paths).
9677    #[cfg(not(target_arch = "wasm32"))]
9678    fn draw_retained_batch(
9679        &self,
9680        render_pass: &mut wgpu::RenderPass<'_>,
9681        retained: &RetainedDraw,
9682        retained_index: usize,
9683        width: u32,
9684        height: u32,
9685    ) {
9686        let Some(slot) = self.replay_slots.slots.get(&retained.slot) else {
9687            return;
9688        };
9689        if retained_index as u32 >= MAX_REPLAY_SLOTS {
9690            return;
9691        }
9692        let first = retained.first_shape.min(slot.shape_count);
9693        let last = retained
9694            .first_shape
9695            .saturating_add(retained.shape_count)
9696            .min(slot.shape_count);
9697        if first >= last {
9698            return;
9699        }
9700        self.frame_stats.bump_shapes();
9701        self.frame_stats.add_draw_calls(1);
9702        render_pass.set_scissor_rect(0, 0, width, height);
9703        // A captured mesh replaces the six-per-shape quad expansion with the
9704        // slot's conservative arc mesh — same bind groups, same SrcOver
9705        // blend, one draw per op over the identical shape range, so z order
9706        // is untouched either way. Slots without a mesh draw through the
9707        // latched instanced-quad path when it exists (four vertex executions
9708        // per shape, shape index from the instance index), else the plain
9709        // six-vertex expansion.
9710        let mesh = slot.mesh.as_ref().map(|mesh| (mesh, self.mesh_pipeline()));
9711        match &mesh {
9712            Some((_, mesh_pipeline)) => render_pass.set_pipeline(mesh_pipeline),
9713            None => match &self.instanced_quads {
9714                Some(instanced) if !slot.has_gradient => {
9715                    render_pass.set_pipeline(self.instanced_pipeline_solid(instanced))
9716                }
9717                Some(instanced) => {
9718                    render_pass.set_pipeline(self.instanced_pipeline(instanced, BlendMode::SrcOver))
9719                }
9720                None if !slot.has_gradient => render_pass.set_pipeline(self.shape_pipeline_solid()),
9721                None => render_pass.set_pipeline(self.shape_pipeline(BlendMode::SrcOver)),
9722            },
9723        }
9724        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
9725        render_pass.set_bind_group(
9726            1,
9727            &slot.bind_group,
9728            &[retained_index as u32 * REPLAY_TRANSFORM_STRIDE as u32],
9729        );
9730        match mesh {
9731            Some((mesh, _)) => {
9732                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
9733                render_pass
9734                    .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
9735                render_pass.draw_indexed(
9736                    mesh.index_prefix[first as usize]..mesh.index_prefix[last as usize],
9737                    0,
9738                    0..1,
9739                );
9740            }
9741            None => match &self.instanced_quads {
9742                Some(instanced) => {
9743                    render_pass.set_index_buffer(
9744                        instanced.index_buffer.slice(..),
9745                        wgpu::IndexFormat::Uint16,
9746                    );
9747                    render_pass.draw_indexed(0..6, 0, first..last);
9748                }
9749                None => render_pass.draw(first * 6..last * 6, 0..1),
9750            },
9751        }
9752    }
9753
9754    /// Key of the retained stretch at `item_range`: one op key per resolved
9755    /// retained item, in draw order, carrying exactly the state that decides
9756    /// the commands [`Self::draw_retained_batch`] would encode for it —
9757    /// clamped range, dynamic-offset index, mesh-vs-quad pipeline choice,
9758    /// and the slot's capture epoch (`None` while the slot is absent, when
9759    /// the op draws nothing on the direct path too).
9760    #[cfg(not(target_arch = "wasm32"))]
9761    fn retained_bundle_key(
9762        &self,
9763        ordered_items: &[(usize, SegmentDrawItem)],
9764        retained_draws: &[RetainedDraw],
9765        item_range: Range<usize>,
9766    ) -> RetainedBundleKey {
9767        let mut ops = Vec::with_capacity(item_range.len());
9768        for (_, item) in &ordered_items[item_range] {
9769            let SegmentDrawItem::Retained(index) = item else {
9770                continue;
9771            };
9772            let Some(retained) = retained_draws.get(*index) else {
9773                continue;
9774            };
9775            let slot = self.replay_slots.slots.get(&retained.slot);
9776            let (first, last) = match slot {
9777                Some(slot) => (
9778                    retained.first_shape.min(slot.shape_count),
9779                    retained
9780                        .first_shape
9781                        .saturating_add(retained.shape_count)
9782                        .min(slot.shape_count),
9783                ),
9784                None => (
9785                    retained.first_shape,
9786                    retained.first_shape.saturating_add(retained.shape_count),
9787                ),
9788            };
9789            ops.push(RetainedBundleOpKey {
9790                slot: retained.slot,
9791                capture_epoch: slot.map(|slot| slot.capture_epoch),
9792                first,
9793                last,
9794                retained_index: *index as u32,
9795                has_mesh: slot.is_some_and(|slot| slot.mesh.is_some())
9796                    && self.shape_batch_limits.storage,
9797            });
9798        }
9799        RetainedBundleKey { ops }
9800    }
9801
9802    /// Encodes `key`'s stretch into a render bundle: the IDENTICAL command
9803    /// sequence [`Self::draw_retained_batch`] issues on the pass, minus the
9804    /// scissor reset (bundles cannot set scissor; the caller sets the same
9805    /// full-target scissor on the pass before executing). Must only be
9806    /// called with a key built this frame, so every op with an epoch still
9807    /// resolves to its slot.
9808    #[cfg(not(target_arch = "wasm32"))]
9809    fn build_retained_bundle(&self, key: &RetainedBundleKey) -> wgpu::RenderBundle {
9810        let mut encoder =
9811            self.device
9812                .create_render_bundle_encoder(&wgpu::RenderBundleEncoderDescriptor {
9813                    label: Some("Retained Stretch Bundle"),
9814                    // Every fused-pass target — the swapchain, screenshot
9815                    // textures, pooled layer surfaces — is created with the
9816                    // renderer's one surface format.
9817                    color_formats: &[Some(self.surface_format)],
9818                    depth_stencil: None,
9819                    sample_count: 1,
9820                    multiview: None,
9821                });
9822        for op in &key.ops {
9823            if op.capture_epoch.is_none()
9824                || op.retained_index >= MAX_REPLAY_SLOTS
9825                || op.first >= op.last
9826            {
9827                continue;
9828            }
9829            let Some(slot) = self.replay_slots.slots.get(&op.slot) else {
9830                continue;
9831            };
9832            let mesh = slot.mesh.as_ref().map(|mesh| (mesh, self.mesh_pipeline()));
9833            match &mesh {
9834                Some((_, mesh_pipeline)) => encoder.set_pipeline(mesh_pipeline),
9835                // The solid-vs-gradient choice is fixed per capture, and the
9836                // op key already carries the capture epoch, so a cached
9837                // bundle can never encode a stale pipeline for a slot id.
9838                None => match &self.instanced_quads {
9839                    Some(instanced) if !slot.has_gradient => {
9840                        encoder.set_pipeline(self.instanced_pipeline_solid(instanced))
9841                    }
9842                    Some(instanced) => {
9843                        encoder.set_pipeline(self.instanced_pipeline(instanced, BlendMode::SrcOver))
9844                    }
9845                    None if !slot.has_gradient => encoder.set_pipeline(self.shape_pipeline_solid()),
9846                    None => encoder.set_pipeline(self.shape_pipeline(BlendMode::SrcOver)),
9847                },
9848            }
9849            encoder.set_bind_group(0, &self.uniform_bind_group, &[]);
9850            encoder.set_bind_group(
9851                1,
9852                &slot.bind_group,
9853                &[op.retained_index * REPLAY_TRANSFORM_STRIDE as u32],
9854            );
9855            match mesh {
9856                Some((mesh, _)) => {
9857                    encoder.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
9858                    encoder
9859                        .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
9860                    encoder.draw_indexed(
9861                        mesh.index_prefix[op.first as usize]..mesh.index_prefix[op.last as usize],
9862                        0,
9863                        0..1,
9864                    );
9865                }
9866                // The latched selection is a per-renderer constant, so it
9867                // needs no place in `RetainedBundleOpKey` — every cached
9868                // bundle in this renderer's lifetime encodes the same choice
9869                // the direct path makes.
9870                None => match &self.instanced_quads {
9871                    Some(instanced) => {
9872                        encoder.set_index_buffer(
9873                            instanced.index_buffer.slice(..),
9874                            wgpu::IndexFormat::Uint16,
9875                        );
9876                        encoder.draw_indexed(0..6, 0, op.first..op.last);
9877                    }
9878                    None => encoder.draw(op.first * 6..op.last * 6, 0..1),
9879                },
9880            }
9881        }
9882        encoder.finish(&wgpu::RenderBundleDescriptor {
9883            label: Some("Retained Stretch Bundle"),
9884        })
9885    }
9886
9887    /// Draws one maximal consecutive retained stretch through the bundle
9888    /// cache: key the stretch, rebuild on any mismatch (recapture, reorder,
9889    /// range or count change, slot release), then execute the cached bundle.
9890    /// Replays byte-identical commands to the per-op direct path.
9891    /// `stage_replay_patches` and the per-frame transform staging stay in
9892    /// the prepare arms, untouched — bundles bind buffers whose contents are
9893    /// read at execution.
9894    #[cfg(not(target_arch = "wasm32"))]
9895    fn draw_retained_stretch_bundled(
9896        &mut self,
9897        render_pass: &mut wgpu::RenderPass<'_>,
9898        ordered_items: &[(usize, SegmentDrawItem)],
9899        retained_draws: &[RetainedDraw],
9900        item_range: Range<usize>,
9901        width: u32,
9902        height: u32,
9903    ) {
9904        let key = self.retained_bundle_key(ordered_items, retained_draws, item_range);
9905        if !self.retained_bundle_cache.hit(&key) {
9906            let bundle = self.build_retained_bundle(&key);
9907            self.retained_bundle_cache.insert(key.clone(), bundle);
9908        }
9909        // Mirror the direct path's per-op stats for every op the bundle
9910        // draws, so bundling is invisible to the frame counters.
9911        for op in &key.ops {
9912            if op.capture_epoch.is_some()
9913                && op.retained_index < MAX_REPLAY_SLOTS
9914                && op.first < op.last
9915            {
9916                self.frame_stats.bump_shapes();
9917                self.frame_stats.add_draw_calls(1);
9918            }
9919        }
9920        // Bundles inherit the pass scissor: set the same full-target rect
9921        // the direct path sets before every retained draw. Executing the
9922        // bundle then resets pipeline/bind/vertex state, which is harmless —
9923        // every following fused arm re-binds its own.
9924        render_pass.set_scissor_rect(0, 0, width, height);
9925        if let Some(bundle) = self.retained_bundle_cache.get(&key) {
9926            render_pass.execute_bundles(std::iter::once(bundle));
9927        }
9928    }
9929
9930    /// Test/diagnostic view of the retained bundle cache: lifetime
9931    /// (rebuilds, cached executes).
9932    #[cfg(not(target_arch = "wasm32"))]
9933    #[doc(hidden)]
9934    pub fn retained_bundle_stats(&self) -> (u64, u64) {
9935        self.retained_bundle_cache.stats()
9936    }
9937
9938    fn draw_prepared_shapes(
9939        &self,
9940        render_pass: &mut wgpu::RenderPass<'_>,
9941        blend_mode: BlendMode,
9942        batch: PreparedShapeBatch,
9943        width: u32,
9944        height: u32,
9945    ) {
9946        self.frame_stats.bump_shapes();
9947        self.frame_stats.add_draw_calls(1);
9948        render_pass.set_scissor_rect(0, 0, width, height);
9949        #[cfg(not(target_arch = "wasm32"))]
9950        let (uniform_bind_group, shape_buffers) = (&self.uniform_bind_group, &self.shape_buffers);
9951        #[cfg(target_arch = "wasm32")]
9952        let (uniform_bind_group, shape_buffers) = (
9953            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
9954            &self.wasm_shape_batches[batch.shape_slot],
9955        );
9956        // Latched instanced path (storage mode only): one instance per
9957        // shape, four vertices through the static quad index buffer —
9958        // identical triangles, identical bind groups, still one draw call.
9959        // The uniform/WebGL path never latches it and stays on `vs_main`.
9960        #[cfg(not(target_arch = "wasm32"))]
9961        if let Some(instanced) = &self.instanced_quads {
9962            assert!(
9963                batch.vertex_start.is_multiple_of(6) && batch.vertex_count.is_multiple_of(6),
9964                "shape batches are whole shapes: vertex range {}..+{} must be \
9965                 six-aligned to convert to an instance range",
9966                batch.vertex_start,
9967                batch.vertex_count,
9968            );
9969            if blend_mode == BlendMode::SrcOver && !batch.has_gradient {
9970                render_pass.set_pipeline(self.instanced_pipeline_solid(instanced));
9971            } else {
9972                render_pass.set_pipeline(self.instanced_pipeline(instanced, blend_mode));
9973            }
9974            render_pass.set_bind_group(0, uniform_bind_group, &[]);
9975            // Dynamic offset 0: ordinary batches read the identity
9976            // similarity transform.
9977            render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
9978            let first_shape = batch.vertex_start / 6;
9979            let shape_count = batch.vertex_count / 6;
9980            render_pass
9981                .set_index_buffer(instanced.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
9982            render_pass.draw_indexed(0..6, 0, first_shape..first_shape + shape_count);
9983            return;
9984        }
9985        if blend_mode == BlendMode::SrcOver && !batch.has_gradient {
9986            render_pass.set_pipeline(self.shape_pipeline_solid());
9987        } else {
9988            render_pass.set_pipeline(self.shape_pipeline(blend_mode));
9989        }
9990        render_pass.set_bind_group(0, uniform_bind_group, &[]);
9991        // Dynamic offset 0: ordinary batches read the identity similarity
9992        // transform.
9993        render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
9994        // Six unindexed vertices per shape; `vs_main` derives the corner from
9995        // `vertex_index` and pulls the quad out of `ShapeData`.
9996        render_pass.draw(
9997            batch.vertex_start..batch.vertex_start + batch.vertex_count,
9998            0..1,
9999        );
10000    }
10001
10002    /// Stage shape buffer writes and record a shape render pass onto the
10003    /// provided encoder. The caller is responsible for submitting.
10004    #[allow(clippy::too_many_arguments)]
10005    fn encode_shapes_pass<'a, I, C: FrameCommandRecorder>(
10006        &mut self,
10007        frame_encoder: &mut C,
10008        target_view: &wgpu::TextureView,
10009        layer_shapes: I,
10010        blend_mode: BlendMode,
10011        width: u32,
10012        height: u32,
10013        root_scale: f32,
10014        load_op: wgpu::LoadOp<wgpu::Color>,
10015        viewport_offset: [f32; 2],
10016    ) where
10017        I: Iterator<Item = &'a DrawShape>,
10018    {
10019        let mut staged_uploads = self.take_staged_uploads();
10020        let viewport = ViewportUniformParams {
10021            width,
10022            height,
10023            offset: viewport_offset,
10024        };
10025        let Some(batch) = self.prepare_shapes_batch(
10026            layer_shapes
10027                .filter(|shape| shape_draw_is_visible_in_viewport(shape, viewport, root_scale)),
10028            root_scale,
10029            viewport,
10030            &mut staged_uploads,
10031        ) else {
10032            self.restore_staged_uploads(staged_uploads);
10033            return;
10034        };
10035        let upload_offset =
10036            frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
10037        self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
10038        self.restore_staged_uploads(staged_uploads);
10039        let mut render_pass =
10040            frame_encoder
10041                .encoder()
10042                .begin_render_pass(&wgpu::RenderPassDescriptor {
10043                    label: Some("Shape Pass"),
10044                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
10045                        view: target_view,
10046                        resolve_target: None,
10047                        depth_slice: None,
10048                        ops: wgpu::Operations {
10049                            load: load_op,
10050                            store: wgpu::StoreOp::Store,
10051                        },
10052                    })],
10053                    depth_stencil_attachment: None,
10054                    timestamp_writes: None,
10055                    occlusion_query_set: None,
10056                    multiview_mask: None,
10057                });
10058        self.draw_prepared_shapes(&mut render_pass, blend_mode, batch, width, height);
10059    }
10060
10061    fn draw_prepared_images(
10062        &mut self,
10063        render_pass: &mut wgpu::RenderPass<'_>,
10064        batch: &PreparedImageBatch,
10065        blend_mode: BlendMode,
10066    ) -> Result<(), String> {
10067        if batch.cmds.is_empty() {
10068            return Ok(());
10069        }
10070        self.frame_stats.bump_images();
10071        self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
10072        render_pass.set_pipeline(self.image_pipeline(blend_mode));
10073        #[cfg(not(target_arch = "wasm32"))]
10074        let (uniform_bind_group, vertex_buffer, index_buffer) = (
10075            &self.uniform_bind_group,
10076            &self.image_vertex_buffer,
10077            &self.image_index_buffer,
10078        );
10079        #[cfg(target_arch = "wasm32")]
10080        let (uniform_bind_group, vertex_buffer, index_buffer) = (
10081            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
10082            &self.wasm_image_batches[batch.image_slot].vertex_buffer,
10083            &self.wasm_image_batches[batch.image_slot].index_buffer,
10084        );
10085        render_pass.set_bind_group(0, uniform_bind_group, &[]);
10086        render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
10087        render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
10088
10089        for cmd in &batch.cmds {
10090            let (sx, sy, sw, sh) = cmd.scissor;
10091            render_pass.set_scissor_rect(sx, sy, sw, sh);
10092
10093            let cached = self
10094                .image_texture_cache
10095                .get(&cmd.image_id)
10096                .ok_or_else(|| "image texture missing from cache".to_string())?;
10097            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
10098            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
10099        }
10100        Ok(())
10101    }
10102
10103    fn draw_prepared_glyphs(
10104        &mut self,
10105        render_pass: &mut wgpu::RenderPass<'_>,
10106        batch: &PreparedGlyphBatch,
10107    ) -> Result<(), String> {
10108        if batch.cmds.is_empty() {
10109            return Ok(());
10110        }
10111        #[cfg(not(target_arch = "wasm32"))]
10112        {
10113            self.draw_native_prepared_glyph_cmd_range(
10114                render_pass,
10115                &batch.cmds,
10116                0..batch.cmds.len(),
10117            )?;
10118        }
10119        #[cfg(target_arch = "wasm32")]
10120        {
10121            self.frame_stats.bump_text();
10122            self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
10123            render_pass.set_pipeline(self.glyph_atlas_pipeline());
10124            let (uniform_bind_group, vertex_buffer, index_buffer) = (
10125                &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
10126                &self.wasm_image_batches[batch.image_slot].vertex_buffer,
10127                &self.wasm_image_batches[batch.image_slot].index_buffer,
10128            );
10129            render_pass.set_bind_group(0, uniform_bind_group, &[]);
10130            render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
10131            render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
10132            render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
10133
10134            for cmd in &batch.cmds {
10135                let (sx, sy, sw, sh) = cmd.scissor;
10136                render_pass.set_scissor_rect(sx, sy, sw, sh);
10137                let GlyphDrawSource::Shared {
10138                    index_start,
10139                    index_count,
10140                } = cmd.source;
10141                render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
10142            }
10143        }
10144        Ok(())
10145    }
10146
10147    #[cfg(not(target_arch = "wasm32"))]
10148    fn draw_native_prepared_image_cmd_range(
10149        &mut self,
10150        render_pass: &mut wgpu::RenderPass<'_>,
10151        cmds: &[ImageDrawCmd],
10152        cmd_range: Range<usize>,
10153        blend_mode: BlendMode,
10154    ) -> Result<(), String> {
10155        let Some(cmds) = cmds.get(cmd_range) else {
10156            return Err("image command range is outside the prepared command buffer".to_string());
10157        };
10158        if cmds.is_empty() {
10159            return Ok(());
10160        }
10161
10162        self.frame_stats.bump_images();
10163        self.frame_stats.add_draw_calls(cmds.len() as u32);
10164        render_pass.set_pipeline(self.image_pipeline(blend_mode));
10165        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
10166        render_pass.set_index_buffer(self.image_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
10167        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
10168
10169        for cmd in cmds {
10170            let (sx, sy, sw, sh) = cmd.scissor;
10171            render_pass.set_scissor_rect(sx, sy, sw, sh);
10172
10173            let cached = self
10174                .image_texture_cache
10175                .get(&cmd.image_id)
10176                .ok_or_else(|| "image texture missing from cache".to_string())?;
10177            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
10178            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
10179        }
10180        Ok(())
10181    }
10182
10183    #[cfg(not(target_arch = "wasm32"))]
10184    fn draw_native_prepared_glyph_cmd_range(
10185        &mut self,
10186        render_pass: &mut wgpu::RenderPass<'_>,
10187        cmds: &[GlyphDrawCmd],
10188        cmd_range: Range<usize>,
10189    ) -> Result<(), String> {
10190        let Some(cmds) = cmds.get(cmd_range) else {
10191            return Err("glyph command range is outside the prepared command buffer".to_string());
10192        };
10193        if cmds.is_empty() {
10194            return Ok(());
10195        }
10196
10197        self.frame_stats.bump_text();
10198        self.frame_stats.add_draw_calls(cmds.len() as u32);
10199
10200        let mut shared_buffers_bound = false;
10201        let mut retained_pipeline_bound = false;
10202        for cmd in cmds {
10203            let (sx, sy, sw, sh) = cmd.scissor;
10204            render_pass.set_scissor_rect(sx, sy, sw, sh);
10205            match cmd.source {
10206                GlyphDrawSource::Shared {
10207                    index_start,
10208                    index_count,
10209                } => {
10210                    if retained_pipeline_bound || !shared_buffers_bound {
10211                        render_pass.set_pipeline(self.glyph_atlas_pipeline());
10212                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
10213                        retained_pipeline_bound = false;
10214                    }
10215                    if !shared_buffers_bound {
10216                        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
10217                        render_pass.set_index_buffer(
10218                            self.image_index_buffer.slice(..),
10219                            wgpu::IndexFormat::Uint32,
10220                        );
10221                        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
10222                        shared_buffers_bound = true;
10223                    }
10224                    render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
10225                }
10226                GlyphDrawSource::Retained {
10227                    cache_key,
10228                    uniform_slot,
10229                } => {
10230                    shared_buffers_bound = false;
10231                    if !retained_pipeline_bound {
10232                        render_pass.set_pipeline(self.retained_glyph_atlas_pipeline());
10233                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
10234                        retained_pipeline_bound = true;
10235                    }
10236                    let cached = self
10237                        .text_glyph_gpu_run_cache
10238                        .peek(&cache_key)
10239                        .ok_or_else(|| "retained glyph buffer missing from cache".to_string())?;
10240                    let dynamic_offset =
10241                        self.retained_glyph_uniform_dynamic_offset(uniform_slot)?;
10242                    render_pass.set_bind_group(
10243                        0,
10244                        &self.retained_glyph_uniform_bind_group,
10245                        &[dynamic_offset],
10246                    );
10247                    render_pass
10248                        .set_index_buffer(cached.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
10249                    render_pass.set_vertex_buffer(0, cached.vertex_buffer.slice(..));
10250                    render_pass.draw_indexed(0..cached.index_count, 0, 0..1);
10251                }
10252            }
10253        }
10254        Ok(())
10255    }
10256
10257    fn append_image_draw_cmd(
10258        &mut self,
10259        image_draw: &ImageDraw,
10260        viewport: ViewportUniformParams,
10261        root_scale: f32,
10262        image_vertices: &mut Vec<Vertex>,
10263        image_indices: &mut Vec<u32>,
10264        image_cmds: &mut Vec<ImageDrawCmd>,
10265    ) -> Result<(), String> {
10266        let snap_delta = image_draw
10267            .snap_anchor
10268            .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
10269            .unwrap_or_default();
10270        let rect = image_draw.rect.translate(snap_delta.x, snap_delta.y);
10271        if rect.width <= 0.0 || rect.height <= 0.0 || image_draw.alpha <= 0.0 {
10272            return Ok(());
10273        }
10274
10275        let (tint, cpu_filter) = tint_for_image(image_draw.color_filter, image_draw.alpha);
10276        if tint[3] <= 0.0 {
10277            return Ok(());
10278        }
10279
10280        let prepared_image = if let Some(filter) = cpu_filter {
10281            apply_filter_to_bitmap(&image_draw.image, filter)?
10282        } else {
10283            image_draw.image.clone()
10284        };
10285        self.ensure_image_cached(&prepared_image)?;
10286
10287        let mut adjusted_image = ImageDraw {
10288            rect,
10289            local_rect: image_draw.local_rect.translate(snap_delta.x, snap_delta.y),
10290            quad: translate_quad(image_draw.quad, snap_delta),
10291            snap_anchor: image_draw.snap_anchor,
10292            image: image_draw.image.clone(),
10293            alpha: image_draw.alpha,
10294            color_filter: image_draw.color_filter,
10295            sampling: image_draw.sampling,
10296            z_index: image_draw.z_index,
10297            clip: image_draw.clip,
10298            blend_mode: image_draw.blend_mode,
10299            src_rect: image_draw.src_rect,
10300            motion_context_animated: image_draw.motion_context_animated,
10301        };
10302        snap_nearest_image_to_device_pixels(&mut adjusted_image, root_scale);
10303        let Some(scissor) =
10304            scissor_rect_for_image(&adjusted_image, root_scale, viewport.width, viewport.height)
10305        else {
10306            return Ok(());
10307        };
10308
10309        let Some(uv_rect) = image_uv_rect(&image_draw.image, image_draw.src_rect) else {
10310            return Ok(());
10311        };
10312        let device_quad =
10313            nearest_image_device_quad(&adjusted_image, root_scale).unwrap_or_else(|| {
10314                if adjusted_image.snap_anchor.is_some() {
10315                    canonicalized_scaled_quad(adjusted_image.quad, root_scale)
10316                } else {
10317                    scaled_quad(adjusted_image.quad, root_scale)
10318                }
10319            });
10320
10321        let base_vertex = image_vertices.len() as u32;
10322        let index_start = image_indices.len() as u32;
10323        image_indices.extend_from_slice(&[
10324            base_vertex,
10325            base_vertex + 1,
10326            base_vertex + 2,
10327            base_vertex + 2,
10328            base_vertex + 1,
10329            base_vertex + 3,
10330        ]);
10331        image_vertices.extend_from_slice(&[
10332            Vertex {
10333                position: device_quad[0],
10334                color: tint,
10335                uv: [uv_rect.min[0], uv_rect.min[1]],
10336                uv_bounds: uv_rect.sample_bounds,
10337            },
10338            Vertex {
10339                position: device_quad[1],
10340                color: tint,
10341                uv: [uv_rect.max[0], uv_rect.min[1]],
10342                uv_bounds: uv_rect.sample_bounds,
10343            },
10344            Vertex {
10345                position: device_quad[2],
10346                color: tint,
10347                uv: [uv_rect.min[0], uv_rect.max[1]],
10348                uv_bounds: uv_rect.sample_bounds,
10349            },
10350            Vertex {
10351                position: device_quad[3],
10352                color: tint,
10353                uv: [uv_rect.max[0], uv_rect.max[1]],
10354                uv_bounds: uv_rect.sample_bounds,
10355            },
10356        ]);
10357
10358        image_cmds.push(ImageDrawCmd {
10359            index_start,
10360            scissor,
10361            image_id: prepared_image.id(),
10362            sampling: image_draw.sampling,
10363        });
10364        Ok(())
10365    }
10366
10367    #[cfg(not(target_arch = "wasm32"))]
10368    fn stage_native_image_buffers(
10369        &mut self,
10370        staged_uploads: &mut StagedBufferUploads,
10371        viewport: ViewportUniformParams,
10372        image_vertices: &[Vertex],
10373        image_indices: &[u32],
10374    ) {
10375        if image_indices.is_empty() {
10376            return;
10377        }
10378
10379        self.stage_viewport_uniforms(staged_uploads, viewport);
10380        // Grow to a power of two, as the shape batch and frame upload buffers
10381        // do. Sizing these to the exact byte count instead means one more glyph
10382        // quad than the last frame destroys and recreates both buffers, and a
10383        // caption that grows a character at a time does it on every frame.
10384        let needed_bytes = std::mem::size_of_val(image_vertices) as u64;
10385        if needed_bytes > self.image_vertex_buffer.size() {
10386            self.image_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10387                label: Some("Image Vertex Buffer"),
10388                size: needed_bytes.next_power_of_two(),
10389                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
10390                mapped_at_creation: false,
10391            });
10392        }
10393        let needed_index_bytes = std::mem::size_of_val(image_indices) as u64;
10394        if needed_index_bytes > self.image_index_buffer.size() {
10395            self.image_index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10396                label: Some("Image Index Buffer"),
10397                size: needed_index_bytes.next_power_of_two(),
10398                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
10399                mapped_at_creation: false,
10400            });
10401        }
10402
10403        staged_uploads.stage(
10404            UploadTarget::ImageVertex,
10405            bytemuck::cast_slice(image_vertices),
10406        );
10407        staged_uploads.stage(
10408            UploadTarget::ImageIndex,
10409            bytemuck::cast_slice(image_indices),
10410        );
10411    }
10412
10413    /// Prepare image vertices, indices, ensure caching, and write to GPU buffers.
10414    /// Returns the draw commands needed by `encode_images_pass`.
10415    fn prepare_image_draw_cmds<'a, I>(
10416        &mut self,
10417        layer_images: I,
10418        viewport: ViewportUniformParams,
10419        root_scale: f32,
10420        staged_uploads: &mut StagedBufferUploads,
10421    ) -> Result<PreparedImageBatch, String>
10422    where
10423        I: Iterator<Item = &'a ImageDraw>,
10424    {
10425        #[cfg(target_arch = "wasm32")]
10426        let _ = staged_uploads;
10427
10428        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
10429        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
10430        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
10431        image_vertices.clear();
10432        image_indices.clear();
10433        image_cmds.clear();
10434
10435        for image_draw in layer_images {
10436            self.append_image_draw_cmd(
10437                image_draw,
10438                viewport,
10439                root_scale,
10440                &mut image_vertices,
10441                &mut image_indices,
10442                &mut image_cmds,
10443            )?;
10444        }
10445
10446        #[cfg(not(target_arch = "wasm32"))]
10447        if !image_cmds.is_empty() {
10448            self.stage_native_image_buffers(
10449                staged_uploads,
10450                viewport,
10451                &image_vertices,
10452                &image_indices,
10453            );
10454        }
10455
10456        #[cfg(target_arch = "wasm32")]
10457        let image_slot = if image_cmds.is_empty() {
10458            0
10459        } else {
10460            let slot = self.claim_wasm_image_batch();
10461            {
10462                let buffers = &mut self.wasm_image_batches[slot];
10463                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
10464            }
10465            let buffers = &self.wasm_image_batches[slot];
10466            self.write_wasm_buffer(
10467                &buffers.vertex_buffer,
10468                bytemuck::cast_slice(&image_vertices),
10469            );
10470            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
10471            slot
10472        };
10473
10474        #[cfg(target_arch = "wasm32")]
10475        let uniform_slot = if image_cmds.is_empty() {
10476            0
10477        } else {
10478            self.prepare_wasm_viewport_uniforms(viewport)
10479        };
10480
10481        self.scratch_image_vertices = image_vertices;
10482        self.scratch_image_indices = image_indices;
10483        Ok(PreparedImageBatch {
10484            cmds: image_cmds,
10485            #[cfg(target_arch = "wasm32")]
10486            image_slot,
10487            #[cfg(target_arch = "wasm32")]
10488            uniform_slot,
10489        })
10490    }
10491
10492    fn glyph_atlas_entry_for(
10493        &mut self,
10494        glyph: &SoftwareGlyphAtlasGlyph,
10495    ) -> Result<GlyphAtlasEntry, String> {
10496        if let Some(entry) = self.text_glyph_atlas.upload_glyph(
10497            glyph.key,
10498            glyph,
10499            &self.queue,
10500            &mut self.frame_graph_executor,
10501            &mut self.frame_stats,
10502        ) {
10503            return Ok(entry);
10504        }
10505
10506        self.text_glyph_atlas.reset(
10507            &self.device,
10508            &self.image_bind_group_layout,
10509            &self.image_nearest_sampler,
10510        );
10511        Err("text glyph atlas filled and was reset".to_string())
10512    }
10513
10514    fn glyph_atlas_entry_for_cached(
10515        &mut self,
10516        glyph: &SoftwareGlyphAtlasPlacement,
10517    ) -> Option<GlyphAtlasEntry> {
10518        let entry = self.text_glyph_atlas.entry(&glyph.key)?;
10519        self.frame_stats.record_text_glyph_atlas_hit();
10520        Some(entry)
10521    }
10522
10523    fn glyph_atlas_entry_for_placement(
10524        &mut self,
10525        glyph: &SoftwareGlyphAtlasPlacement,
10526    ) -> Result<GlyphAtlasEntry, String> {
10527        if let Some(entry) = self.glyph_atlas_entry_for_cached(glyph) {
10528            return Ok(entry);
10529        }
10530
10531        let Some(upload_glyph) = self.text_glyph_mask_cache.atlas_glyph_for_placement(glyph) else {
10532            return Err("text glyph placement has no retained raster mask".to_string());
10533        };
10534        self.glyph_atlas_entry_for(&upload_glyph)
10535    }
10536
10537    fn prepare_text_glyph_quads(
10538        &mut self,
10539        run_key: TextGlyphRunCacheKey,
10540        atlas_generation: u64,
10541        cached_glyph_run: Option<&[SoftwareGlyphAtlasPlacement]>,
10542        collected_run: &[SoftwareGlyphAtlasRunGlyph],
10543        generated_quads: &mut Vec<CachedTextGlyphQuad>,
10544    ) -> Result<Rc<[CachedTextGlyphQuad]>, String> {
10545        generated_quads.clear();
10546        if let Some(glyph_run) = cached_glyph_run {
10547            for glyph in glyph_run {
10548                if glyph.width == 0 || glyph.height == 0 || glyph.color.3 <= 0.0 {
10549                    continue;
10550                }
10551                let entry = self.glyph_atlas_entry_for_placement(glyph)?;
10552                // Read the size after the entry is in hand: the only path that
10553                // resizes the atlas is the overflow reset, which returns `Err`
10554                // above, so `entry` is always normalised against the atlas it
10555                // was placed in.
10556                generated_quads.push(cached_text_glyph_quad(
10557                    glyph,
10558                    entry,
10559                    self.text_glyph_atlas.size(),
10560                ));
10561            }
10562        } else {
10563            for run_glyph in collected_run {
10564                let placement = run_glyph.placement();
10565                if placement.width == 0 || placement.height == 0 || placement.color.3 <= 0.0 {
10566                    continue;
10567                }
10568                let entry = match run_glyph {
10569                    SoftwareGlyphAtlasRunGlyph::Cached(placement) => {
10570                        self.glyph_atlas_entry_for_placement(placement)?
10571                    }
10572                    SoftwareGlyphAtlasRunGlyph::New(glyph) => self.glyph_atlas_entry_for(glyph)?,
10573                };
10574                generated_quads.push(cached_text_glyph_quad(
10575                    &placement,
10576                    entry,
10577                    self.text_glyph_atlas.size(),
10578                ));
10579            }
10580        }
10581
10582        let quads: Rc<[CachedTextGlyphQuad]> = Rc::from(generated_quads.clone().into_boxed_slice());
10583        if let Some(cached) = self.text_glyph_run_cache.get_mut(&run_key) {
10584            cached.quads = Some(Rc::clone(&quads));
10585            cached.atlas_generation = atlas_generation;
10586        }
10587        Ok(quads)
10588    }
10589
10590    #[allow(clippy::too_many_arguments)]
10591    fn append_text_glyph_quad_run(
10592        &mut self,
10593        source_raster_rect: Rect,
10594        quads: &[CachedTextGlyphQuad],
10595        clip: Option<Rect>,
10596        viewport: ViewportUniformParams,
10597        root_scale: f32,
10598        image_vertices: &mut Vec<Vertex>,
10599        image_indices: &mut Vec<u32>,
10600        record_cached_hits: bool,
10601    ) -> usize {
10602        let mut appended = 0usize;
10603        for quad in quads {
10604            if !cached_text_glyph_quad_is_visible_in_viewport(
10605                source_raster_rect,
10606                quad,
10607                clip,
10608                viewport,
10609                root_scale,
10610            ) {
10611                continue;
10612            }
10613            if append_cached_text_glyph_quad(
10614                source_raster_rect,
10615                quad,
10616                image_vertices,
10617                image_indices,
10618            ) {
10619                if record_cached_hits {
10620                    self.frame_stats.record_text_glyph_atlas_hit();
10621                }
10622                appended = appended.saturating_add(1);
10623            }
10624        }
10625        appended
10626    }
10627
10628    #[cfg(not(target_arch = "wasm32"))]
10629    fn retained_glyph_viewport(
10630        viewport: ViewportUniformParams,
10631        source_raster_rect: Rect,
10632    ) -> ViewportUniformParams {
10633        ViewportUniformParams {
10634            width: viewport.width,
10635            height: viewport.height,
10636            offset: [
10637                viewport.offset[0] - source_raster_rect.x,
10638                viewport.offset[1] - source_raster_rect.y,
10639            ],
10640        }
10641    }
10642
10643    #[cfg(not(target_arch = "wasm32"))]
10644    fn retained_text_glyph_run_ready(&mut self, cache_key: TextGlyphRunCacheKey) -> bool {
10645        let atlas_generation = self.text_glyph_atlas.generation();
10646        self.text_glyph_gpu_run_cache
10647            .peek(&cache_key)
10648            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
10649    }
10650
10651    #[cfg(not(target_arch = "wasm32"))]
10652    #[allow(clippy::too_many_arguments)]
10653    fn emit_retained_text_glyph_run_if_ready(
10654        &mut self,
10655        cache_key: TextGlyphRunCacheKey,
10656        quads: &[CachedTextGlyphQuad],
10657        clip: Option<Rect>,
10658        viewport: ViewportUniformParams,
10659        source_raster_rect: Rect,
10660        scissor: (u32, u32, u32, u32),
10661        staged_uploads: &mut StagedBufferUploads,
10662        glyph_cmds: &mut Vec<GlyphDrawCmd>,
10663    ) -> bool {
10664        if !should_use_retained_text_glyph_run(quads.len(), clip) {
10665            return false;
10666        }
10667        if !self.retained_text_glyph_run_ready(cache_key)
10668            && !self.ensure_retained_text_glyph_run(cache_key, quads)
10669        {
10670            return false;
10671        }
10672
10673        let uniform_slot = self.stage_retained_glyph_viewport_uniforms(
10674            staged_uploads,
10675            Self::retained_glyph_viewport(viewport, source_raster_rect),
10676        );
10677        glyph_cmds.push(GlyphDrawCmd::retained(cache_key, uniform_slot, scissor));
10678        true
10679    }
10680
10681    #[cfg(not(target_arch = "wasm32"))]
10682    fn ensure_retained_text_glyph_run(
10683        &mut self,
10684        cache_key: TextGlyphRunCacheKey,
10685        quads: &[CachedTextGlyphQuad],
10686    ) -> bool {
10687        let atlas_generation = self.text_glyph_atlas.generation();
10688        if self
10689            .text_glyph_gpu_run_cache
10690            .peek(&cache_key)
10691            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
10692        {
10693            return true;
10694        }
10695
10696        let mut vertices = Vec::with_capacity(quads.len().saturating_mul(4));
10697        let mut indices = Vec::with_capacity(quads.len().saturating_mul(6));
10698        let origin = Rect {
10699            x: 0.0,
10700            y: 0.0,
10701            width: 0.0,
10702            height: 0.0,
10703        };
10704        for quad in quads {
10705            append_cached_text_glyph_quad(origin, quad, &mut vertices, &mut indices);
10706        }
10707        if indices.is_empty() {
10708            return false;
10709        }
10710
10711        let vertex_bytes = bytemuck::cast_slice(&vertices);
10712        let index_bytes = bytemuck::cast_slice(&indices);
10713        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10714            label: Some("Retained Text Glyph Vertex Buffer"),
10715            size: vertex_bytes.len() as u64,
10716            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
10717            mapped_at_creation: false,
10718        });
10719        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10720            label: Some("Retained Text Glyph Index Buffer"),
10721            size: index_bytes.len() as u64,
10722            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
10723            mapped_at_creation: false,
10724        });
10725        let vertex_upload =
10726            self.frame_graph_executor
10727                .upload_buffer(&self.queue, &vertex_buffer, 0, vertex_bytes);
10728        self.frame_stats.record_command_stats(vertex_upload);
10729        let index_upload =
10730            self.frame_graph_executor
10731                .upload_buffer(&self.queue, &index_buffer, 0, index_bytes);
10732        self.frame_stats.record_command_stats(index_upload);
10733
10734        self.text_glyph_gpu_run_cache.put(
10735            cache_key,
10736            CachedGpuTextGlyphRun {
10737                vertex_buffer,
10738                index_buffer,
10739                index_count: indices.len() as u32,
10740                atlas_generation,
10741            },
10742        );
10743        true
10744    }
10745
10746    #[allow(clippy::too_many_arguments)]
10747    fn append_text_glyph_draws<'a, I>(
10748        &mut self,
10749        layer_texts: I,
10750        viewport: ViewportUniformParams,
10751        root_scale: f32,
10752        allow_offscreen_prewarm: bool,
10753        staged_uploads: &mut StagedBufferUploads,
10754        image_vertices: &mut Vec<Vertex>,
10755        image_indices: &mut Vec<u32>,
10756        glyph_cmds: &mut Vec<GlyphDrawCmd>,
10757    ) -> Result<bool, String>
10758    where
10759        I: IntoIterator<Item = &'a TextDraw>,
10760    {
10761        let append_start = Instant::now();
10762        let initial_vertex_len = image_vertices.len();
10763        let initial_index_len = image_indices.len();
10764        let initial_cmd_len = glyph_cmds.len();
10765        let initial_staged_bytes_len = staged_uploads.bytes.len();
10766        let initial_staged_copies_len = staged_uploads.copies.len();
10767        let mut collected_run = std::mem::take(&mut self.scratch_text_glyph_run);
10768        let mut collected_placements = std::mem::take(&mut self.scratch_text_glyph_placements);
10769        let mut generated_quads = std::mem::take(&mut self.scratch_text_glyph_quads);
10770        generated_quads.clear();
10771        let mut visited = 0usize;
10772        let mut emitted_glyphs = 0usize;
10773        let mut prewarmed_glyphs = 0usize;
10774        let mut run_hits = 0usize;
10775        let mut run_misses = 0usize;
10776
10777        for text_draw in layer_texts {
10778            visited = visited.saturating_add(1);
10779            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
10780                self.text_raster_geometry(text_draw, root_scale)
10781            else {
10782                continue;
10783            };
10784            if !static_text_motion {
10785                image_vertices.truncate(initial_vertex_len);
10786                image_indices.truncate(initial_index_len);
10787                glyph_cmds.truncate(initial_cmd_len);
10788                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
10789                self.scratch_text_glyph_run = collected_run;
10790                self.scratch_text_glyph_placements = collected_placements;
10791                self.scratch_text_glyph_quads = generated_quads;
10792                return Ok(false);
10793            }
10794            let is_visible =
10795                text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale);
10796            let draw_action = text_glyph_draw_action(
10797                is_visible,
10798                text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale),
10799                allow_offscreen_prewarm,
10800            );
10801            if draw_action == TextGlyphDrawAction::Skip {
10802                continue;
10803            }
10804
10805            let raster_source = text_glyph_raster_source(text_draw, raster_rect);
10806            let source_draw = raster_source.draw.as_ref();
10807            let source_raster_rect = raster_source.raster_rect;
10808
10809            let run_key = Self::text_glyph_run_cache_key(
10810                source_draw,
10811                source_raster_rect,
10812                text_scale,
10813                static_text_motion,
10814            );
10815            let atlas_generation = self.text_glyph_atlas.generation();
10816            let mut cached_quad_run = None;
10817            let mut miss_collect_ms = None;
10818            let mut miss_cached_glyphs = 0usize;
10819            let mut miss_new_glyphs = 0usize;
10820            let cached_glyph_run = if let Some(cached) = self.text_glyph_run_cache.get(&run_key) {
10821                run_hits = run_hits.saturating_add(1);
10822                if cached.atlas_generation == atlas_generation {
10823                    cached_quad_run = cached.quads.as_ref().map(Rc::clone);
10824                }
10825                Some(Rc::clone(&cached.glyphs))
10826            } else {
10827                run_misses = run_misses.saturating_add(1);
10828                collected_run.clear();
10829                let collect_start = Instant::now();
10830                let collect_result = collect_solid_text_atlas_run(
10831                    source_draw.text.as_ref(),
10832                    source_raster_rect,
10833                    &source_draw.text_style,
10834                    source_draw.color,
10835                    source_draw.font_size,
10836                    text_scale,
10837                    &self.text_fonts,
10838                    &mut self.text_glyph_mask_cache,
10839                    &mut collected_run,
10840                );
10841                miss_collect_ms = Some(instant_ms(collect_start, Instant::now()));
10842                if collect_result.is_none() {
10843                    if text_atlas_fallback_diag_enabled() {
10844                        let preview: String = source_draw.text.text.chars().take(96).collect();
10845                        log::warn!(
10846                            "[text-atlas-fallback] node={:?} visible={} prewarm={} spans={} links={} text_len={} preview={:?} span_style={:?} paragraph_style={:?}",
10847                            source_draw.node_id,
10848                            is_visible,
10849                            draw_action == TextGlyphDrawAction::PrewarmOffscreen,
10850                            source_draw.text.span_styles.len(),
10851                            source_draw.text.links.len(),
10852                            source_draw.text.text.len(),
10853                            preview,
10854                            source_draw.text_style.span_style,
10855                            source_draw.text_style.paragraph_style,
10856                        );
10857                    }
10858                    if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
10859                        continue;
10860                    }
10861                    image_vertices.truncate(initial_vertex_len);
10862                    image_indices.truncate(initial_index_len);
10863                    glyph_cmds.truncate(initial_cmd_len);
10864                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
10865                    self.scratch_text_glyph_run = collected_run;
10866                    self.scratch_text_glyph_placements = collected_placements;
10867                    self.scratch_text_glyph_quads = generated_quads;
10868                    return Ok(false);
10869                }
10870                if text_glyph_run_diag_enabled() {
10871                    miss_cached_glyphs = collected_run
10872                        .iter()
10873                        .filter(|glyph| matches!(glyph, SoftwareGlyphAtlasRunGlyph::Cached(_)))
10874                        .count();
10875                    miss_new_glyphs = collected_run.len().saturating_sub(miss_cached_glyphs);
10876                }
10877                collected_placements.clear();
10878                collected_placements.extend(
10879                    collected_run
10880                        .iter()
10881                        .map(SoftwareGlyphAtlasRunGlyph::placement),
10882                );
10883                let glyphs: Rc<[SoftwareGlyphAtlasPlacement]> =
10884                    Rc::from(collected_placements.clone().into_boxed_slice());
10885                self.text_glyph_run_cache.put(
10886                    run_key,
10887                    CachedTextGlyphRun {
10888                        glyphs,
10889                        quads: None,
10890                        atlas_generation: 0,
10891                    },
10892                );
10893                None
10894            };
10895
10896            if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
10897                let prewarm_quads = if let Some(quad_run) = cached_quad_run {
10898                    quad_run
10899                } else {
10900                    let prepare_start = Instant::now();
10901                    match self.prepare_text_glyph_quads(
10902                        run_key,
10903                        atlas_generation,
10904                        cached_glyph_run.as_deref(),
10905                        &collected_run,
10906                        &mut generated_quads,
10907                    ) {
10908                        Ok(quads) => {
10909                            if let Some(collect_ms) = miss_collect_ms {
10910                                if text_glyph_run_diag_enabled() {
10911                                    log::warn!(
10912                                        "[text-glyph-run-diag] visible=false glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
10913                                        quads.len(),
10914                                        miss_cached_glyphs,
10915                                        miss_new_glyphs,
10916                                        collect_ms,
10917                                        instant_ms(prepare_start, Instant::now()),
10918                                    );
10919                                }
10920                            }
10921                            quads
10922                        }
10923                        Err(_) => continue,
10924                    }
10925                };
10926                #[cfg(not(target_arch = "wasm32"))]
10927                if should_use_retained_text_glyph_run(prewarm_quads.len(), source_draw.clip) {
10928                    self.ensure_retained_text_glyph_run(run_key, prewarm_quads.as_ref());
10929                }
10930                prewarmed_glyphs = prewarmed_glyphs.saturating_add(prewarm_quads.len());
10931                continue;
10932            }
10933
10934            let draw_rect = Rect {
10935                x: source_raster_rect.x / root_scale,
10936                y: source_raster_rect.y / root_scale,
10937                width: source_raster_rect.width / root_scale,
10938                height: source_raster_rect.height / root_scale,
10939            };
10940            let Some(scissor) = scissor_rect_for_layer(
10941                draw_rect,
10942                source_draw.clip,
10943                root_scale,
10944                viewport.width,
10945                viewport.height,
10946            ) else {
10947                continue;
10948            };
10949
10950            #[cfg(not(target_arch = "wasm32"))]
10951            if let Some(quad_run) = cached_quad_run.as_ref() {
10952                if should_use_retained_text_glyph_run(quad_run.len(), source_draw.clip)
10953                    && self.emit_retained_text_glyph_run_if_ready(
10954                        run_key,
10955                        quad_run.as_ref(),
10956                        source_draw.clip,
10957                        viewport,
10958                        source_raster_rect,
10959                        scissor,
10960                        staged_uploads,
10961                        glyph_cmds,
10962                    )
10963                {
10964                    emitted_glyphs = emitted_glyphs.saturating_add(quad_run.len());
10965                    continue;
10966                }
10967            }
10968
10969            let index_start = image_indices.len() as u32;
10970            if let Some(quad_run) = cached_quad_run {
10971                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
10972                    source_raster_rect,
10973                    quad_run.as_ref(),
10974                    source_draw.clip,
10975                    viewport,
10976                    root_scale,
10977                    image_vertices,
10978                    image_indices,
10979                    true,
10980                ));
10981            } else {
10982                let prepare_start = Instant::now();
10983                let Ok(quad_run) = self.prepare_text_glyph_quads(
10984                    run_key,
10985                    atlas_generation,
10986                    cached_glyph_run.as_deref(),
10987                    &collected_run,
10988                    &mut generated_quads,
10989                ) else {
10990                    image_vertices.truncate(initial_vertex_len);
10991                    image_indices.truncate(initial_index_len);
10992                    glyph_cmds.truncate(initial_cmd_len);
10993                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
10994                    self.scratch_text_glyph_run = collected_run;
10995                    self.scratch_text_glyph_placements = collected_placements;
10996                    self.scratch_text_glyph_quads = generated_quads;
10997                    return Ok(false);
10998                };
10999                if let Some(collect_ms) = miss_collect_ms {
11000                    if text_glyph_run_diag_enabled() {
11001                        log::warn!(
11002                            "[text-glyph-run-diag] visible=true glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
11003                            quad_run.len(),
11004                            miss_cached_glyphs,
11005                            miss_new_glyphs,
11006                            collect_ms,
11007                            instant_ms(prepare_start, Instant::now()),
11008                        );
11009                    }
11010                }
11011                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
11012                    source_raster_rect,
11013                    quad_run.as_ref(),
11014                    source_draw.clip,
11015                    viewport,
11016                    root_scale,
11017                    image_vertices,
11018                    image_indices,
11019                    false,
11020                ));
11021            }
11022            let index_count = image_indices.len() as u32 - index_start;
11023            if index_count > 0 {
11024                glyph_cmds.push(GlyphDrawCmd::shared(index_start, index_count, scissor));
11025            }
11026        }
11027
11028        self.scratch_text_glyph_run = collected_run;
11029        self.scratch_text_glyph_placements = collected_placements;
11030        self.scratch_text_glyph_quads = generated_quads;
11031        let append_end = Instant::now();
11032        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
11033            log::warn!(
11034                "[wgpu-render-stage:text-glyph-atlas] total_ms={total_ms:.2} visited={} cmds={} glyphs={} prewarmed={} run_hits={} run_misses={}",
11035                visited,
11036                glyph_cmds.len().saturating_sub(initial_cmd_len),
11037                emitted_glyphs,
11038                prewarmed_glyphs,
11039                run_hits,
11040                run_misses,
11041            );
11042        }
11043        Ok(true)
11044    }
11045
11046    #[cfg(not(target_arch = "wasm32"))]
11047    fn text_glyph_prewarm_decision(
11048        &self,
11049        text_draw: &TextDraw,
11050        viewport: ViewportUniformParams,
11051        root_scale: f32,
11052    ) -> TextGlyphPrewarmDecision {
11053        let Some((logical_rect, _, clip, _, static_text_motion)) =
11054            self.text_raster_geometry(text_draw, root_scale)
11055        else {
11056            return TextGlyphPrewarmDecision::MissingGeometry;
11057        };
11058        if !static_text_motion {
11059            return TextGlyphPrewarmDecision::DynamicMotion;
11060        }
11061        if text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
11062            return TextGlyphPrewarmDecision::Visible;
11063        }
11064        if text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale) {
11065            TextGlyphPrewarmDecision::Candidate
11066        } else {
11067            TextGlyphPrewarmDecision::OutsidePrewarmWindow
11068        }
11069    }
11070
11071    #[cfg(not(target_arch = "wasm32"))]
11072    #[allow(clippy::too_many_arguments)]
11073    fn prewarm_offscreen_text_glyph_draws_in_chunk(
11074        &mut self,
11075        ordered_items: &[(usize, SegmentDrawItem)],
11076        texts: &[TextDraw],
11077        chunk: &SegmentDrawChunkPlan,
11078        viewport: ViewportUniformParams,
11079        root_scale: f32,
11080        staged_uploads: &mut StagedBufferUploads,
11081        image_vertices: &mut Vec<Vertex>,
11082        image_indices: &mut Vec<u32>,
11083        glyph_cmds: &mut Vec<GlyphDrawCmd>,
11084    ) -> Result<(), String> {
11085        let prewarm_start = Instant::now();
11086        let diag_enabled = cranpose_core::env_flag!("CRANPOSE_TEXT_PREWARM_DIAG");
11087        let mut text_items = 0usize;
11088        let mut candidates = 0usize;
11089        let mut missing_geometry = 0usize;
11090        let mut dynamic_motion = 0usize;
11091        let mut visible = 0usize;
11092        let mut outside = 0usize;
11093        let mut already_prepared = 0usize;
11094        let mut admitted_candidates = 0usize;
11095        let mut skipped_unbounded = 0usize;
11096        let mut skipped_budget = 0usize;
11097        let initial_vertex_len = image_vertices.len();
11098        let initial_index_len = image_indices.len();
11099        let initial_cmd_len = glyph_cmds.len();
11100        let initial_staged_bytes_len = staged_uploads.bytes.len();
11101        let initial_staged_copies_len = staged_uploads.copies.len();
11102        'batches: for batch in chunk.iter() {
11103            let SegmentBatchPlan::Text { start, end } = batch else {
11104                continue;
11105            };
11106            for (_, item) in &ordered_items[start..end] {
11107                if offscreen_text_glyph_prewarm_budget_exhausted(prewarm_start, admitted_candidates)
11108                {
11109                    skipped_budget = skipped_budget.saturating_add(1);
11110                    break 'batches;
11111                }
11112                let SegmentDrawItem::Text(text_index) = item else {
11113                    return Err(format!(
11114                        "text prewarm batch contains non-text draw item: {item:?}"
11115                    ));
11116                };
11117                let Some(text_draw) = texts.get(*text_index) else {
11118                    continue;
11119                };
11120                text_items = text_items.saturating_add(1);
11121                match self.text_glyph_prewarm_decision(text_draw, viewport, root_scale) {
11122                    TextGlyphPrewarmDecision::Candidate => {}
11123                    TextGlyphPrewarmDecision::MissingGeometry => {
11124                        missing_geometry = missing_geometry.saturating_add(1);
11125                        continue;
11126                    }
11127                    TextGlyphPrewarmDecision::DynamicMotion => {
11128                        dynamic_motion = dynamic_motion.saturating_add(1);
11129                        continue;
11130                    }
11131                    TextGlyphPrewarmDecision::Visible => {
11132                        visible = visible.saturating_add(1);
11133                        continue;
11134                    }
11135                    TextGlyphPrewarmDecision::OutsidePrewarmWindow => {
11136                        outside = outside.saturating_add(1);
11137                        continue;
11138                    }
11139                }
11140
11141                candidates = candidates.saturating_add(1);
11142                let Some((_, raster_rect, _, text_scale, static_text_motion)) =
11143                    self.text_raster_geometry(text_draw, root_scale)
11144                else {
11145                    missing_geometry = missing_geometry.saturating_add(1);
11146                    continue;
11147                };
11148                let raster_source = text_glyph_raster_source(text_draw, raster_rect);
11149                let source_draw = raster_source.draw.as_ref();
11150                let run_key = Self::text_glyph_run_cache_key(
11151                    source_draw,
11152                    raster_source.raster_rect,
11153                    text_scale,
11154                    static_text_motion,
11155                );
11156                let atlas_generation = self.text_glyph_atlas.generation();
11157                let cached_glyphs = if let Some(cached) = self.text_glyph_run_cache.peek(&run_key) {
11158                    if cached.atlas_generation == atlas_generation && cached.quads.is_some() {
11159                        already_prepared = already_prepared.saturating_add(1);
11160                        continue;
11161                    }
11162                    Some(cached.glyphs.len())
11163                } else {
11164                    None
11165                };
11166                if !offscreen_text_glyph_prewarm_work_is_bounded(
11167                    cached_glyphs,
11168                    source_draw.text.text.len(),
11169                ) {
11170                    skipped_unbounded = skipped_unbounded.saturating_add(1);
11171                    continue;
11172                }
11173                admitted_candidates = admitted_candidates.saturating_add(1);
11174                self.append_text_glyph_draws(
11175                    std::iter::once(text_draw),
11176                    viewport,
11177                    root_scale,
11178                    true,
11179                    staged_uploads,
11180                    image_vertices,
11181                    image_indices,
11182                    glyph_cmds,
11183                )?;
11184                image_vertices.truncate(initial_vertex_len);
11185                image_indices.truncate(initial_index_len);
11186                glyph_cmds.truncate(initial_cmd_len);
11187                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
11188            }
11189        }
11190
11191        if diag_enabled && text_items > 0 {
11192            log::warn!(
11193                "[text-glyph-prewarm-diag] texts={text_items} candidates={candidates} admitted={admitted_candidates} cached={already_prepared} skipped_unbounded={skipped_unbounded} skipped_budget={skipped_budget} visible={visible} outside={outside} dynamic={dynamic_motion} missing={missing_geometry}"
11194            );
11195        }
11196        if admitted_candidates > 0 {
11197            if let Some(total_ms) = should_log_wgpu_render_stage(prewarm_start, Instant::now()) {
11198                log::warn!(
11199                    "[wgpu-render-stage:text-glyph-prewarm] total_ms={total_ms:.2} candidates={candidates} admitted={admitted_candidates} cached={already_prepared} skipped_unbounded={skipped_unbounded} skipped_budget={skipped_budget}"
11200                );
11201            }
11202        }
11203        Ok(())
11204    }
11205
11206    fn prepare_text_glyph_draw_cmds<'a, I>(
11207        &mut self,
11208        layer_texts: I,
11209        viewport: ViewportUniformParams,
11210        root_scale: f32,
11211        staged_uploads: &mut StagedBufferUploads,
11212    ) -> Result<Option<PreparedGlyphBatch>, String>
11213    where
11214        I: IntoIterator<Item = &'a TextDraw>,
11215    {
11216        #[cfg(target_arch = "wasm32")]
11217        let _ = staged_uploads;
11218
11219        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
11220        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
11221        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
11222        image_vertices.clear();
11223        image_indices.clear();
11224        glyph_cmds.clear();
11225
11226        if !self.append_text_glyph_draws(
11227            layer_texts,
11228            viewport,
11229            root_scale,
11230            false,
11231            staged_uploads,
11232            &mut image_vertices,
11233            &mut image_indices,
11234            &mut glyph_cmds,
11235        )? {
11236            self.scratch_image_vertices = image_vertices;
11237            self.scratch_image_indices = image_indices;
11238            self.scratch_glyph_cmds = glyph_cmds;
11239            return Ok(None);
11240        }
11241
11242        #[cfg(not(target_arch = "wasm32"))]
11243        if !image_indices.is_empty() {
11244            self.stage_native_image_buffers(
11245                staged_uploads,
11246                viewport,
11247                &image_vertices,
11248                &image_indices,
11249            );
11250        }
11251
11252        #[cfg(target_arch = "wasm32")]
11253        let image_slot = if glyph_cmds.is_empty() {
11254            0
11255        } else {
11256            let slot = self.claim_wasm_image_batch();
11257            {
11258                let buffers = &mut self.wasm_image_batches[slot];
11259                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
11260            }
11261            let buffers = &self.wasm_image_batches[slot];
11262            self.write_wasm_buffer(
11263                &buffers.vertex_buffer,
11264                bytemuck::cast_slice(&image_vertices),
11265            );
11266            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
11267            slot
11268        };
11269
11270        #[cfg(target_arch = "wasm32")]
11271        let uniform_slot = if glyph_cmds.is_empty() {
11272            0
11273        } else {
11274            self.prepare_wasm_viewport_uniforms(viewport)
11275        };
11276
11277        self.scratch_image_vertices = image_vertices;
11278        self.scratch_image_indices = image_indices;
11279        Ok(Some(PreparedGlyphBatch {
11280            cmds: glyph_cmds,
11281            #[cfg(target_arch = "wasm32")]
11282            image_slot,
11283            #[cfg(target_arch = "wasm32")]
11284            uniform_slot,
11285        }))
11286    }
11287
11288    #[allow(clippy::too_many_arguments)]
11289    fn append_image_bitmap_draw_cmd(
11290        &mut self,
11291        image: &ImageBitmap,
11292        rect: Rect,
11293        clip: Option<Rect>,
11294        sampling: ImageSampling,
11295        viewport: ViewportUniformParams,
11296        root_scale: f32,
11297        image_vertices: &mut Vec<Vertex>,
11298        image_indices: &mut Vec<u32>,
11299        image_cmds: &mut Vec<ImageDrawCmd>,
11300    ) -> Result<(), String> {
11301        if rect.width <= 0.0 || rect.height <= 0.0 {
11302            return Ok(());
11303        }
11304
11305        self.ensure_image_cached(image)?;
11306
11307        let (device_quad, scissor_rect) =
11308            if sampling == ImageSampling::Nearest && root_scale.is_finite() && root_scale > 0.0 {
11309                let left_px = (rect.x * root_scale).round();
11310                let top_px = (rect.y * root_scale).round();
11311                let width_px = (rect.width * root_scale).round().max(1.0);
11312                let height_px = (rect.height * root_scale).round().max(1.0);
11313                let snapped_rect = Rect {
11314                    x: left_px / root_scale,
11315                    y: top_px / root_scale,
11316                    width: width_px / root_scale,
11317                    height: height_px / root_scale,
11318                };
11319                let right_px = left_px + width_px;
11320                let bottom_px = top_px + height_px;
11321                (
11322                    [
11323                        [left_px, top_px],
11324                        [right_px, top_px],
11325                        [left_px, bottom_px],
11326                        [right_px, bottom_px],
11327                    ],
11328                    snapped_rect,
11329                )
11330            } else {
11331                (
11332                    rect_to_quad(rect).map(|[x, y]| [x * root_scale, y * root_scale]),
11333                    rect,
11334                )
11335            };
11336
11337        let Some(scissor) = scissor_rect_for_layer(
11338            scissor_rect,
11339            clip,
11340            root_scale,
11341            viewport.width,
11342            viewport.height,
11343        ) else {
11344            return Ok(());
11345        };
11346        let Some(uv_rect) = image_uv_rect(image, None) else {
11347            return Ok(());
11348        };
11349
11350        let base_vertex = image_vertices.len() as u32;
11351        let index_start = image_indices.len() as u32;
11352        image_indices.extend_from_slice(&[
11353            base_vertex,
11354            base_vertex + 1,
11355            base_vertex + 2,
11356            base_vertex + 2,
11357            base_vertex + 1,
11358            base_vertex + 3,
11359        ]);
11360        let color = [1.0, 1.0, 1.0, 1.0];
11361        image_vertices.extend_from_slice(&[
11362            Vertex {
11363                position: device_quad[0],
11364                color,
11365                uv: [uv_rect.min[0], uv_rect.min[1]],
11366                uv_bounds: uv_rect.sample_bounds,
11367            },
11368            Vertex {
11369                position: device_quad[1],
11370                color,
11371                uv: [uv_rect.max[0], uv_rect.min[1]],
11372                uv_bounds: uv_rect.sample_bounds,
11373            },
11374            Vertex {
11375                position: device_quad[2],
11376                color,
11377                uv: [uv_rect.min[0], uv_rect.max[1]],
11378                uv_bounds: uv_rect.sample_bounds,
11379            },
11380            Vertex {
11381                position: device_quad[3],
11382                color,
11383                uv: [uv_rect.max[0], uv_rect.max[1]],
11384                uv_bounds: uv_rect.sample_bounds,
11385            },
11386        ]);
11387        image_cmds.push(ImageDrawCmd {
11388            index_start,
11389            scissor,
11390            image_id: image.id(),
11391            sampling,
11392        });
11393        Ok(())
11394    }
11395
11396    #[allow(clippy::too_many_arguments)]
11397    fn append_text_image_draw_cmds<'a, I>(
11398        &mut self,
11399        layer_texts: I,
11400        viewport: ViewportUniformParams,
11401        root_scale: f32,
11402        image_vertices: &mut Vec<Vertex>,
11403        image_indices: &mut Vec<u32>,
11404        image_cmds: &mut Vec<ImageDrawCmd>,
11405    ) -> Result<(), String>
11406    where
11407        I: Iterator<Item = &'a TextDraw>,
11408    {
11409        let append_start = Instant::now();
11410        let initial_len = image_cmds.len();
11411        let mut visited = 0usize;
11412        let mut hit_count = 0usize;
11413        let mut miss_count = 0usize;
11414        for text_draw in layer_texts {
11415            visited = visited.saturating_add(1);
11416            let _ = text_draw.node_id;
11417            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
11418                self.text_raster_geometry(text_draw, root_scale)
11419            else {
11420                continue;
11421            };
11422            if !text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
11423                continue;
11424            }
11425
11426            let raster_source = self.text_image_raster_source(
11427                text_draw,
11428                logical_rect,
11429                raster_rect,
11430                clip,
11431                root_scale,
11432                static_text_motion,
11433            );
11434            let source_draw = raster_source.draw.as_ref();
11435            let source_raster_rect = raster_source.raster_rect;
11436
11437            let cache_key = Self::text_image_cache_key(
11438                source_draw,
11439                source_raster_rect,
11440                text_scale,
11441                static_text_motion,
11442            );
11443            let image = if let Some(cached) = self.text_image_cache.get(&cache_key) {
11444                self.frame_stats
11445                    .record_text_image_cache_hit(cached.image.width(), cached.image.height());
11446                hit_count = hit_count.saturating_add(1);
11447                cached.image.clone()
11448            } else {
11449                let Some(image) =
11450                    self.rasterize_text_draw_to_image(source_draw, source_raster_rect, text_scale)
11451                else {
11452                    continue;
11453                };
11454                self.frame_stats
11455                    .record_text_image_cache_miss(image.width(), image.height());
11456                miss_count = miss_count.saturating_add(1);
11457                self.text_image_cache.put(
11458                    cache_key,
11459                    CachedTextImage {
11460                        image: image.clone(),
11461                    },
11462                );
11463                image
11464            };
11465
11466            let draw_origin = if static_text_motion {
11467                Point::new(
11468                    source_raster_rect.x / root_scale,
11469                    source_raster_rect.y / root_scale,
11470                )
11471            } else {
11472                Point::new(logical_rect.x, logical_rect.y)
11473            };
11474            let draw_rect = Rect {
11475                x: draw_origin.x,
11476                y: draw_origin.y,
11477                width: image.width() as f32 / root_scale,
11478                height: image.height() as f32 / root_scale,
11479            };
11480            self.append_image_bitmap_draw_cmd(
11481                &image,
11482                draw_rect,
11483                clip,
11484                ImageSampling::Nearest,
11485                viewport,
11486                root_scale,
11487                image_vertices,
11488                image_indices,
11489                image_cmds,
11490            )?;
11491        }
11492        let append_end = Instant::now();
11493        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
11494            log::warn!(
11495                "[wgpu-render-stage:text-images] total_ms={total_ms:.2} visited={} emitted={} hits={} misses={}",
11496                visited,
11497                image_cmds.len().saturating_sub(initial_len),
11498                hit_count,
11499                miss_count,
11500            );
11501        }
11502        Ok(())
11503    }
11504
11505    fn text_image_raster_source<'a>(
11506        &mut self,
11507        text_draw: &'a TextDraw,
11508        logical_rect: Rect,
11509        raster_rect: Rect,
11510        clip: Option<Rect>,
11511        root_scale: f32,
11512        static_text_motion: bool,
11513    ) -> TextRasterSource<'a> {
11514        let Some(clip) = clip else {
11515            return TextRasterSource {
11516                draw: Cow::Borrowed(text_draw),
11517                raster_rect,
11518            };
11519        };
11520        if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
11521            return TextRasterSource {
11522                draw: Cow::Borrowed(text_draw),
11523                raster_rect,
11524            };
11525        }
11526
11527        let line_starts = self.text_line_index_cache.line_starts(&text_draw.text);
11528        clipped_text_raster_source_with_line_starts(
11529            text_draw,
11530            logical_rect,
11531            raster_rect,
11532            clip,
11533            root_scale,
11534            line_starts.as_ref(),
11535        )
11536    }
11537
11538    fn prepare_text_image_draw_cmds<'a, I>(
11539        &mut self,
11540        layer_texts: I,
11541        viewport: ViewportUniformParams,
11542        root_scale: f32,
11543        staged_uploads: &mut StagedBufferUploads,
11544    ) -> Result<PreparedImageBatch, String>
11545    where
11546        I: Iterator<Item = &'a TextDraw>,
11547    {
11548        #[cfg(target_arch = "wasm32")]
11549        let _ = staged_uploads;
11550
11551        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
11552        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
11553        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
11554        image_vertices.clear();
11555        image_indices.clear();
11556        image_cmds.clear();
11557
11558        self.append_text_image_draw_cmds(
11559            layer_texts,
11560            viewport,
11561            root_scale,
11562            &mut image_vertices,
11563            &mut image_indices,
11564            &mut image_cmds,
11565        )?;
11566
11567        #[cfg(not(target_arch = "wasm32"))]
11568        if !image_cmds.is_empty() {
11569            self.stage_native_image_buffers(
11570                staged_uploads,
11571                viewport,
11572                &image_vertices,
11573                &image_indices,
11574            );
11575        }
11576
11577        #[cfg(target_arch = "wasm32")]
11578        let image_slot = if image_cmds.is_empty() {
11579            0
11580        } else {
11581            let slot = self.claim_wasm_image_batch();
11582            {
11583                let buffers = &mut self.wasm_image_batches[slot];
11584                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
11585            }
11586            let buffers = &self.wasm_image_batches[slot];
11587            self.write_wasm_buffer(
11588                &buffers.vertex_buffer,
11589                bytemuck::cast_slice(&image_vertices),
11590            );
11591            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
11592            slot
11593        };
11594
11595        #[cfg(target_arch = "wasm32")]
11596        let uniform_slot = if image_cmds.is_empty() {
11597            0
11598        } else {
11599            self.prepare_wasm_viewport_uniforms(viewport)
11600        };
11601
11602        self.scratch_image_vertices = image_vertices;
11603        self.scratch_image_indices = image_indices;
11604        Ok(PreparedImageBatch {
11605            cmds: image_cmds,
11606            #[cfg(target_arch = "wasm32")]
11607            image_slot,
11608            #[cfg(target_arch = "wasm32")]
11609            uniform_slot,
11610        })
11611    }
11612
11613    fn text_raster_geometry(
11614        &self,
11615        text_draw: &TextDraw,
11616        root_scale: f32,
11617    ) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
11618        text_raster_geometry_for_draw(text_draw, root_scale)
11619    }
11620
11621    fn text_image_cache_key(
11622        text_draw: &TextDraw,
11623        raster_rect: Rect,
11624        text_scale: f32,
11625        static_text_motion: bool,
11626    ) -> TextImageCacheKey {
11627        let mut state = default_hash::new();
11628        text_draw.text.render_hash().hash(&mut state);
11629        text_draw.text_style.render_hash().hash(&mut state);
11630        text_draw.color.render_hash().hash(&mut state);
11631        hash_text_raster_geometry_for_cache(raster_rect, static_text_motion, &mut state);
11632        text_draw.font_size.to_bits().hash(&mut state);
11633        text_scale.to_bits().hash(&mut state);
11634        text_draw.layout_options.hash(&mut state);
11635        TextImageCacheKey(state.finish())
11636    }
11637
11638    fn text_glyph_run_cache_key(
11639        text_draw: &TextDraw,
11640        raster_rect: Rect,
11641        text_scale: f32,
11642        static_text_motion: bool,
11643    ) -> TextGlyphRunCacheKey {
11644        TextGlyphRunCacheKey(
11645            Self::text_image_cache_key(text_draw, raster_rect, text_scale, static_text_motion).0,
11646        )
11647    }
11648
11649    fn rasterize_text_draw_to_image(
11650        &mut self,
11651        text_draw: &TextDraw,
11652        raster_rect: Rect,
11653        text_scale: f32,
11654    ) -> Option<ImageBitmap> {
11655        if text_draw.text.span_styles.is_empty() {
11656            let font = self.text_fonts.resolve(&text_draw.text_style)?;
11657            return rasterize_text_to_image_with_glyph_cache(
11658                text_draw.text.text.as_str(),
11659                raster_rect,
11660                &text_draw.text_style,
11661                text_draw.color,
11662                text_draw.font_size,
11663                text_scale,
11664                font,
11665                &mut self.text_glyph_mask_cache,
11666            );
11667        }
11668
11669        if let Some(image) = rasterize_annotated_text_to_image_with_glyph_cache(
11670            text_draw.text.as_ref(),
11671            raster_rect,
11672            &text_draw.text_style,
11673            text_draw.color,
11674            text_draw.font_size,
11675            text_scale,
11676            &self.text_fonts,
11677            &mut self.text_glyph_mask_cache,
11678        ) {
11679            return Some(image);
11680        }
11681
11682        rasterize_spanned_text_to_image(
11683            text_draw,
11684            raster_rect,
11685            text_scale,
11686            &self.text_fonts,
11687            &mut self.text_glyph_mask_cache,
11688        )
11689    }
11690}
11691
11692fn rasterize_spanned_text_to_image(
11693    text_draw: &TextDraw,
11694    raster_rect: Rect,
11695    text_scale: f32,
11696    fonts: &SoftwareTextFontSet,
11697    glyph_cache: &mut SoftwareGlyphRasterCache,
11698) -> Option<ImageBitmap> {
11699    let width = raster_rect.width.ceil().max(1.0) as u32;
11700    let height = raster_rect.height.ceil().max(1.0) as u32;
11701    let mut canvas = vec![0_u8; (width as usize) * (height as usize) * 4];
11702    let boundaries = text_draw.text.span_boundaries();
11703    let base_line_height = text_draw
11704        .text_style
11705        .resolve_line_height(14.0, text_draw.font_size)
11706        .max(1.0);
11707    let mut current_line_height = base_line_height;
11708    let mut cursor_x = raster_rect.x;
11709    let mut cursor_y = raster_rect.y;
11710
11711    for window in boundaries.windows(2) {
11712        let start = window[0];
11713        let end = window[1];
11714        if start == end {
11715            continue;
11716        }
11717
11718        let chunk = &text_draw.text.text[start..end];
11719        let mut merged_span = text_draw.text_style.span_style.clone();
11720        for span in &text_draw.text.span_styles {
11721            if span.range.start <= start && span.range.end >= end {
11722                merged_span = merged_span.merge(&span.item);
11723            }
11724        }
11725
11726        let mut chunk_style = text_draw.text_style.clone();
11727        chunk_style.span_style = merged_span;
11728
11729        for part in chunk.split_inclusive('\n') {
11730            let has_newline = part.ends_with('\n');
11731            let content = if has_newline {
11732                &part[..part.len().saturating_sub(1)]
11733            } else {
11734                part
11735            };
11736
11737            if !content.is_empty() {
11738                let chunk_font_size = chunk_style.resolve_font_size(text_draw.font_size);
11739                let Some(font) = fonts.resolve(&chunk_style) else {
11740                    continue;
11741                };
11742                let metrics = measure_text_with_font(content, &chunk_style, chunk_font_size, font);
11743                let segment_rect = Rect {
11744                    x: cursor_x,
11745                    y: cursor_y,
11746                    width: (metrics.width * text_scale).ceil().max(1.0),
11747                    height: (metrics.height * text_scale).ceil().max(1.0),
11748                };
11749                if let Some(segment_image) = rasterize_text_to_image_with_glyph_cache(
11750                    content,
11751                    segment_rect,
11752                    &chunk_style,
11753                    chunk_style.resolve_text_color(text_draw.color),
11754                    chunk_font_size,
11755                    text_scale,
11756                    font,
11757                    glyph_cache,
11758                ) {
11759                    composite_text_segment(
11760                        &mut canvas,
11761                        width,
11762                        height,
11763                        raster_rect,
11764                        segment_rect,
11765                        &segment_image,
11766                    );
11767                }
11768                cursor_x += metrics.width * text_scale;
11769                current_line_height = current_line_height.max(metrics.line_height.max(1.0));
11770            }
11771
11772            if has_newline {
11773                cursor_x = raster_rect.x;
11774                cursor_y += current_line_height * text_scale;
11775                current_line_height = base_line_height;
11776            }
11777        }
11778    }
11779
11780    ImageBitmap::from_rgba8(width, height, canvas).ok()
11781}
11782
11783struct TextRasterSource<'a> {
11784    draw: Cow<'a, TextDraw>,
11785    raster_rect: Rect,
11786}
11787
11788fn text_glyph_raster_source(text_draw: &TextDraw, raster_rect: Rect) -> TextRasterSource<'_> {
11789    TextRasterSource {
11790        draw: Cow::Borrowed(text_draw),
11791        raster_rect,
11792    }
11793}
11794
11795#[cfg(test)]
11796fn clipped_text_raster_source<'a>(
11797    text_draw: &'a TextDraw,
11798    logical_rect: Rect,
11799    raster_rect: Rect,
11800    clip: Option<Rect>,
11801    root_scale: f32,
11802    static_text_motion: bool,
11803) -> TextRasterSource<'a> {
11804    let Some(clip) = clip else {
11805        return TextRasterSource {
11806            draw: Cow::Borrowed(text_draw),
11807            raster_rect,
11808        };
11809    };
11810    if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
11811        return TextRasterSource {
11812            draw: Cow::Borrowed(text_draw),
11813            raster_rect,
11814        };
11815    }
11816    let line_starts = line_start_offsets(text_draw.text.text.as_str());
11817    clipped_text_raster_source_with_line_starts(
11818        text_draw,
11819        logical_rect,
11820        raster_rect,
11821        clip,
11822        root_scale,
11823        &line_starts,
11824    )
11825}
11826
11827fn clipped_text_raster_source_with_line_starts<'a>(
11828    text_draw: &'a TextDraw,
11829    logical_rect: Rect,
11830    raster_rect: Rect,
11831    clip: Rect,
11832    root_scale: f32,
11833    line_starts: &[usize],
11834) -> TextRasterSource<'a> {
11835    if line_starts.len() < MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER {
11836        return TextRasterSource {
11837            draw: Cow::Borrowed(text_draw),
11838            raster_rect,
11839        };
11840    }
11841
11842    let Some(visible_rect) = logical_rect.intersect(clip) else {
11843        return TextRasterSource {
11844            draw: Cow::Borrowed(text_draw),
11845            raster_rect,
11846        };
11847    };
11848
11849    let line_count = line_starts.len().max(1);
11850    let line_height = logical_rect.height / line_count as f32;
11851    if !line_height.is_finite() || line_height <= 0.0 {
11852        return TextRasterSource {
11853            draw: Cow::Borrowed(text_draw),
11854            raster_rect,
11855        };
11856    }
11857
11858    let visible_top = ((visible_rect.y - logical_rect.y) / line_height).floor() as isize;
11859    let visible_bottom =
11860        ((visible_rect.y + visible_rect.height - logical_rect.y) / line_height).ceil() as isize;
11861    let start_line = visible_top.saturating_sub(1).max(0) as usize;
11862    let end_line = (visible_bottom + 1).max(start_line as isize + 1) as usize;
11863    let end_line = end_line.min(line_count);
11864    if start_line == 0 && end_line >= line_count {
11865        return TextRasterSource {
11866            draw: Cow::Borrowed(text_draw),
11867            raster_rect,
11868        };
11869    }
11870
11871    let byte_start = line_starts[start_line];
11872    let byte_end = line_end_offset(text_draw.text.text.as_str(), line_starts, end_line - 1);
11873    if byte_start >= byte_end {
11874        return TextRasterSource {
11875            draw: Cow::Borrowed(text_draw),
11876            raster_rect,
11877        };
11878    }
11879
11880    let slice_y = logical_rect.y + start_line as f32 * line_height;
11881    let slice_height = (end_line - start_line) as f32 * line_height;
11882    let mut slice_raster_rect = Rect {
11883        x: logical_rect.x * root_scale,
11884        y: slice_y * root_scale,
11885        width: logical_rect.width * root_scale,
11886        height: slice_height * root_scale,
11887    };
11888    slice_raster_rect.x = slice_raster_rect.x.round();
11889    slice_raster_rect.y = slice_raster_rect.y.round();
11890    slice_raster_rect.width = slice_raster_rect.width.ceil().max(1.0);
11891    slice_raster_rect.height = slice_raster_rect.height.ceil().max(1.0);
11892
11893    let mut sliced_draw = text_draw.clone();
11894    sliced_draw.rect = Rect {
11895        x: logical_rect.x,
11896        y: slice_y,
11897        width: logical_rect.width,
11898        height: slice_height,
11899    };
11900    sliced_draw.text = Arc::new(text_draw.text.subsequence(byte_start..byte_end));
11901
11902    TextRasterSource {
11903        draw: Cow::Owned(sliced_draw),
11904        raster_rect: slice_raster_rect,
11905    }
11906}
11907
11908fn line_start_offsets(text: &str) -> Vec<usize> {
11909    let mut starts =
11910        Vec::with_capacity(text.as_bytes().iter().filter(|b| **b == b'\n').count() + 1);
11911    starts.push(0);
11912    starts.extend(
11913        text.char_indices()
11914            .filter_map(|(index, ch)| (ch == '\n').then_some(index + ch.len_utf8())),
11915    );
11916    starts
11917}
11918
11919fn line_end_offset(text: &str, line_starts: &[usize], line: usize) -> usize {
11920    line_starts.get(line + 1).copied().unwrap_or(text.len())
11921}
11922
11923fn composite_text_segment(
11924    canvas: &mut [u8],
11925    canvas_width: u32,
11926    canvas_height: u32,
11927    canvas_rect: Rect,
11928    segment_rect: Rect,
11929    segment_image: &ImageBitmap,
11930) {
11931    let offset_x = (segment_rect.x - canvas_rect.x).round() as i32;
11932    let offset_y = (segment_rect.y - canvas_rect.y).round() as i32;
11933    let src = segment_image.pixels();
11934    for sy in 0..segment_image.height() as i32 {
11935        let dy = offset_y + sy;
11936        if dy < 0 || dy >= canvas_height as i32 {
11937            continue;
11938        }
11939        for sx in 0..segment_image.width() as i32 {
11940            let dx = offset_x + sx;
11941            if dx < 0 || dx >= canvas_width as i32 {
11942                continue;
11943            }
11944            let src_index = ((sy as u32 * segment_image.width() + sx as u32) * 4) as usize;
11945            let dst_index = ((dy as u32 * canvas_width + dx as u32) * 4) as usize;
11946            blend_rgba_pixel(
11947                &mut canvas[dst_index..dst_index + 4],
11948                &src[src_index..src_index + 4],
11949            );
11950        }
11951    }
11952}
11953
11954fn blend_rgba_pixel(dst: &mut [u8], src: &[u8]) {
11955    let src_alpha = src[3] as f32 / 255.0;
11956    if src_alpha <= 0.0 {
11957        return;
11958    }
11959    let dst_alpha = dst[3] as f32 / 255.0;
11960    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
11961    if out_alpha <= f32::EPSILON {
11962        dst.copy_from_slice(&[0, 0, 0, 0]);
11963        return;
11964    }
11965
11966    for channel in 0..3 {
11967        let src_channel = src[channel] as f32 / 255.0;
11968        let dst_channel = dst[channel] as f32 / 255.0;
11969        let src_premult = src_channel * src_alpha;
11970        let dst_premult = dst_channel * dst_alpha;
11971        dst[channel] =
11972            (((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0) * 255.0)
11973                .round() as u8;
11974    }
11975    dst[3] = (out_alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
11976}
11977
11978fn align_to(value: u32, alignment: u32) -> u32 {
11979    debug_assert!(alignment > 0);
11980    value.div_ceil(alignment) * alignment
11981}
11982
11983#[cfg(not(target_arch = "wasm32"))]
11984fn align_usize_to(value: usize, alignment: usize) -> usize {
11985    debug_assert!(alignment > 0);
11986    value.div_ceil(alignment) * alignment
11987}
11988
11989impl GpuRenderer {
11990    fn convert_surface_pixels_to_rgba(&self, pixels: &mut [u8]) -> Result<(), String> {
11991        match self.surface_format {
11992            wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => Ok(()),
11993            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb => {
11994                for pixel in pixels.chunks_exact_mut(4) {
11995                    pixel.swap(0, 2);
11996                }
11997                Ok(())
11998            }
11999            format => Err(format!(
12000                "Screenshot readback unsupported for texture format: {format:?}"
12001            )),
12002        }
12003    }
12004}
12005
12006fn is_in_effect_range(z_index: usize, effect_z_ranges: &[Range<usize>]) -> bool {
12007    effect_z_ranges.iter().any(|range| range.contains(&z_index))
12008}
12009
12010#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12011enum SegmentDrawItem {
12012    Shape(usize),
12013    Image(usize),
12014    Text(usize),
12015    Shadow(usize),
12016    Composite(usize),
12017    ShaderComposite(usize),
12018    Retained(usize),
12019}
12020
12021#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12022enum SegmentBatchPlan {
12023    Shape {
12024        start: usize,
12025        end: usize,
12026        blend_mode: BlendMode,
12027    },
12028    Image {
12029        start: usize,
12030        end: usize,
12031        blend_mode: BlendMode,
12032    },
12033    Text {
12034        start: usize,
12035        end: usize,
12036    },
12037    Composite {
12038        start: usize,
12039        end: usize,
12040    },
12041    ShaderComposite {
12042        start: usize,
12043        end: usize,
12044    },
12045    /// Retained replay batches: each item is one bind + draw of GPU slots
12046    /// captured on an earlier frame, so they never merge and cost no budget.
12047    Retained {
12048        start: usize,
12049        end: usize,
12050    },
12051}
12052
12053#[derive(Clone, Debug, Default, PartialEq, Eq)]
12054struct SegmentDrawChunkPlan {
12055    batches: Vec<SegmentBatchPlan>,
12056}
12057
12058struct SegmentRenderOutcome {
12059    rendered_any: bool,
12060    pass_count: u32,
12061}
12062
12063struct SegmentCommandEncodeOutcome {
12064    first_batch: bool,
12065}
12066
12067#[cfg(not(target_arch = "wasm32"))]
12068#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12069enum TextGlyphPrewarmDecision {
12070    Candidate,
12071    MissingGeometry,
12072    DynamicMotion,
12073    Visible,
12074    OutsidePrewarmWindow,
12075}
12076
12077#[cfg(not(target_arch = "wasm32"))]
12078#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12079struct NativeSegmentFusionBudget {
12080    shape_count: usize,
12081    gradient_stop_count: usize,
12082}
12083
12084#[cfg(not(target_arch = "wasm32"))]
12085#[derive(Clone, Debug, PartialEq, Eq)]
12086struct NativeSegmentFusionPartition {
12087    chunk: SegmentDrawChunkPlan,
12088    budget: NativeSegmentFusionBudget,
12089}
12090
12091#[cfg(not(target_arch = "wasm32"))]
12092#[derive(Clone, Debug, PartialEq, Eq)]
12093enum FusedSegmentBatch {
12094    Shape {
12095        batch: PreparedShapeBatch,
12096        blend_mode: BlendMode,
12097    },
12098    Image {
12099        cmd_range: Range<usize>,
12100        blend_mode: BlendMode,
12101    },
12102    Text {
12103        image_cmd_range: Range<usize>,
12104        glyph_cmd_range: Range<usize>,
12105    },
12106    Composite {
12107        draw_range: Range<usize>,
12108    },
12109    ShaderComposite {
12110        draw_range: Range<usize>,
12111    },
12112    Retained {
12113        item_range: Range<usize>,
12114    },
12115}
12116
12117struct ShadowSourceRenderOutcome {
12118    rendered_any: bool,
12119    pass_count: u32,
12120}
12121
12122impl SegmentDrawChunkPlan {
12123    fn is_empty(&self) -> bool {
12124        self.batches.is_empty()
12125    }
12126
12127    fn push(&mut self, batch: SegmentBatchPlan) {
12128        self.batches.push(batch);
12129    }
12130
12131    fn iter(&self) -> impl Iterator<Item = SegmentBatchPlan> + '_ {
12132        self.batches.iter().copied()
12133    }
12134}
12135
12136#[derive(Clone, Debug, PartialEq, Eq)]
12137enum SegmentRenderCommand {
12138    DrawChunk(SegmentDrawChunkPlan),
12139    Shadow(usize),
12140}
12141
12142struct SegmentCommandIter<'a> {
12143    ordered_items: &'a [(usize, SegmentDrawItem)],
12144    shapes: &'a [DrawShape],
12145    images: &'a [ImageDraw],
12146    cursor: usize,
12147    batch_limits: ShapeBatchLimits,
12148}
12149
12150impl<'a> SegmentCommandIter<'a> {
12151    fn new(
12152        ordered_items: &'a [(usize, SegmentDrawItem)],
12153        shapes: &'a [DrawShape],
12154        images: &'a [ImageDraw],
12155        batch_limits: ShapeBatchLimits,
12156    ) -> Self {
12157        Self {
12158            ordered_items,
12159            shapes,
12160            images,
12161            cursor: 0,
12162            batch_limits,
12163        }
12164    }
12165}
12166
12167impl Iterator for SegmentCommandIter<'_> {
12168    type Item = SegmentRenderCommand;
12169
12170    fn next(&mut self) -> Option<Self::Item> {
12171        if self.cursor >= self.ordered_items.len() {
12172            return None;
12173        }
12174
12175        if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
12176            self.cursor += 1;
12177            return Some(SegmentRenderCommand::Shadow(index));
12178        }
12179
12180        let mut chunk = SegmentDrawChunkPlan::default();
12181        while self.cursor < self.ordered_items.len() {
12182            if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
12183                if chunk.is_empty() {
12184                    self.cursor += 1;
12185                    return Some(SegmentRenderCommand::Shadow(index));
12186                }
12187                break;
12188            }
12189
12190            let Some((batch, next_cursor)) = segment_batch_plan_at_cursor(
12191                self.ordered_items,
12192                self.shapes,
12193                self.images,
12194                self.cursor,
12195                self.batch_limits,
12196            ) else {
12197                break;
12198            };
12199            chunk.push(batch);
12200            self.cursor = next_cursor;
12201        }
12202
12203        Some(SegmentRenderCommand::DrawChunk(chunk))
12204    }
12205}
12206
12207#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12208struct PreparedShapeBatch {
12209    /// First vertex and vertex count for the unindexed shape draw; always
12210    /// multiples of 6 so `vs_main`'s `vertex_index / 6` lands on whole shapes.
12211    vertex_start: u32,
12212    vertex_count: u32,
12213    /// Whether any shape in the batch carries gradient stops. False routes
12214    /// a SrcOver draw through the `fs_solid` pipeline.
12215    has_gradient: bool,
12216    #[cfg(target_arch = "wasm32")]
12217    shape_slot: usize,
12218    #[cfg(target_arch = "wasm32")]
12219    uniform_slot: usize,
12220}
12221
12222struct PreparedImageBatch {
12223    cmds: Vec<ImageDrawCmd>,
12224    #[cfg(target_arch = "wasm32")]
12225    image_slot: usize,
12226    #[cfg(target_arch = "wasm32")]
12227    uniform_slot: usize,
12228}
12229
12230impl PreparedImageBatch {
12231    fn is_empty(&self) -> bool {
12232        self.cmds.is_empty()
12233    }
12234
12235    fn into_cmds(self) -> Vec<ImageDrawCmd> {
12236        self.cmds
12237    }
12238}
12239
12240struct PreparedGlyphBatch {
12241    cmds: Vec<GlyphDrawCmd>,
12242    #[cfg(target_arch = "wasm32")]
12243    image_slot: usize,
12244    #[cfg(target_arch = "wasm32")]
12245    uniform_slot: usize,
12246}
12247
12248impl PreparedGlyphBatch {
12249    fn is_empty(&self) -> bool {
12250        self.cmds.is_empty()
12251    }
12252
12253    fn into_cmds(self) -> Vec<GlyphDrawCmd> {
12254        self.cmds
12255    }
12256}
12257
12258#[cfg(not(target_arch = "wasm32"))]
12259fn gradient_stop_count_for_shape(shape: &DrawShape) -> usize {
12260    match &shape.brush {
12261        Brush::Solid(_) => 0,
12262        Brush::LinearGradient { colors, .. }
12263        | Brush::RadialGradient { colors, .. }
12264        | Brush::SweepGradient { colors, .. } => colors.len(),
12265    }
12266}
12267
12268#[cfg(not(target_arch = "wasm32"))]
12269fn native_segment_fusion_budget(
12270    ordered_items: &[(usize, SegmentDrawItem)],
12271    shapes: &[DrawShape],
12272    chunk: &SegmentDrawChunkPlan,
12273    batch_limits: ShapeBatchLimits,
12274) -> Result<Option<NativeSegmentFusionBudget>, String> {
12275    let mut shape_count = 0usize;
12276    let mut gradient_stop_count = 0usize;
12277
12278    for batch in chunk.iter() {
12279        let SegmentBatchPlan::Shape { start, end, .. } = batch else {
12280            continue;
12281        };
12282        for (_, item) in &ordered_items[start..end] {
12283            let SegmentDrawItem::Shape(shape_index) = item else {
12284                return Err(format!(
12285                    "shape batch contains non-shape draw item: {item:?}"
12286                ));
12287            };
12288            let shape = &shapes[*shape_index];
12289            shape_count = shape_count.saturating_add(1);
12290            gradient_stop_count =
12291                gradient_stop_count.saturating_add(gradient_stop_count_for_shape(shape));
12292        }
12293    }
12294
12295    if shape_count > batch_limits.max_shapes_per_batch
12296        || gradient_stop_count > batch_limits.max_gradient_stops
12297    {
12298        return Ok(None);
12299    }
12300
12301    Ok(Some(NativeSegmentFusionBudget {
12302        shape_count,
12303        gradient_stop_count,
12304    }))
12305}
12306
12307#[cfg(not(target_arch = "wasm32"))]
12308fn push_native_segment_fusion_partition(
12309    partitions: &mut Vec<NativeSegmentFusionPartition>,
12310    current: &mut SegmentDrawChunkPlan,
12311    current_budget: &mut NativeSegmentFusionBudget,
12312) {
12313    if current.is_empty() {
12314        return;
12315    }
12316
12317    partitions.push(NativeSegmentFusionPartition {
12318        chunk: std::mem::take(current),
12319        budget: *current_budget,
12320    });
12321    *current_budget = NativeSegmentFusionBudget {
12322        shape_count: 0,
12323        gradient_stop_count: 0,
12324    };
12325}
12326
12327#[cfg(not(target_arch = "wasm32"))]
12328fn native_segment_fusion_partitions(
12329    ordered_items: &[(usize, SegmentDrawItem)],
12330    shapes: &[DrawShape],
12331    chunk: &SegmentDrawChunkPlan,
12332    batch_limits: ShapeBatchLimits,
12333) -> Result<Option<Vec<NativeSegmentFusionPartition>>, String> {
12334    if let Some(budget) = native_segment_fusion_budget(ordered_items, shapes, chunk, batch_limits)?
12335    {
12336        return Ok(Some(vec![NativeSegmentFusionPartition {
12337            chunk: chunk.clone(),
12338            budget,
12339        }]));
12340    }
12341
12342    let mut partitions = Vec::new();
12343    let mut current = SegmentDrawChunkPlan::default();
12344    let mut current_budget = NativeSegmentFusionBudget {
12345        shape_count: 0,
12346        gradient_stop_count: 0,
12347    };
12348
12349    for batch in chunk.iter() {
12350        let SegmentBatchPlan::Shape {
12351            start,
12352            end,
12353            blend_mode,
12354        } = batch
12355        else {
12356            current.push(batch);
12357            continue;
12358        };
12359
12360        let mut run_start = start;
12361        for (item_cursor, (_, item)) in ordered_items.iter().enumerate().take(end).skip(start) {
12362            let SegmentDrawItem::Shape(shape_index) = *item else {
12363                return Err(format!(
12364                    "shape batch contains non-shape draw item: {:?}",
12365                    item
12366                ));
12367            };
12368            let gradient_stop_count = gradient_stop_count_for_shape(&shapes[shape_index]);
12369            if gradient_stop_count > batch_limits.max_gradient_stops {
12370                return Ok(None);
12371            }
12372
12373            let fits_shape_count =
12374                current_budget.shape_count.saturating_add(1) <= batch_limits.max_shapes_per_batch;
12375            let fits_gradient_count = current_budget
12376                .gradient_stop_count
12377                .saturating_add(gradient_stop_count)
12378                <= batch_limits.max_gradient_stops;
12379            if !fits_shape_count || !fits_gradient_count {
12380                if run_start < item_cursor {
12381                    current.push(SegmentBatchPlan::Shape {
12382                        start: run_start,
12383                        end: item_cursor,
12384                        blend_mode,
12385                    });
12386                }
12387                push_native_segment_fusion_partition(
12388                    &mut partitions,
12389                    &mut current,
12390                    &mut current_budget,
12391                );
12392                run_start = item_cursor;
12393            }
12394
12395            current_budget.shape_count = current_budget.shape_count.saturating_add(1);
12396            current_budget.gradient_stop_count = current_budget
12397                .gradient_stop_count
12398                .saturating_add(gradient_stop_count);
12399        }
12400
12401        if run_start < end {
12402            current.push(SegmentBatchPlan::Shape {
12403                start: run_start,
12404                end,
12405                blend_mode,
12406            });
12407        }
12408    }
12409
12410    push_native_segment_fusion_partition(&mut partitions, &mut current, &mut current_budget);
12411    Ok(Some(partitions))
12412}
12413
12414fn segment_batch_plan_at_cursor(
12415    ordered_items: &[(usize, SegmentDrawItem)],
12416    shapes: &[DrawShape],
12417    images: &[ImageDraw],
12418    start: usize,
12419    batch_limits: ShapeBatchLimits,
12420) -> Option<(SegmentBatchPlan, usize)> {
12421    match ordered_items[start].1 {
12422        SegmentDrawItem::Shape(index) => {
12423            let blend_mode = supported_blend_mode(shapes[index].blend_mode);
12424            let mut end = start + 1;
12425            let shape_limit = (start + batch_limits.max_shapes_per_batch).min(ordered_items.len());
12426            while end < shape_limit {
12427                match ordered_items[end].1 {
12428                    SegmentDrawItem::Shape(next_index)
12429                        if supported_blend_mode(shapes[next_index].blend_mode) == blend_mode =>
12430                    {
12431                        end += 1;
12432                    }
12433                    _ => break,
12434                }
12435            }
12436            Some((
12437                SegmentBatchPlan::Shape {
12438                    start,
12439                    end,
12440                    blend_mode,
12441                },
12442                end,
12443            ))
12444        }
12445        SegmentDrawItem::Image(index) => {
12446            let blend_mode = supported_blend_mode(images[index].blend_mode);
12447            let mut end = start + 1;
12448            while end < ordered_items.len() {
12449                match ordered_items[end].1 {
12450                    SegmentDrawItem::Image(next_index)
12451                        if supported_blend_mode(images[next_index].blend_mode) == blend_mode =>
12452                    {
12453                        end += 1;
12454                    }
12455                    _ => break,
12456                }
12457            }
12458            Some((
12459                SegmentBatchPlan::Image {
12460                    start,
12461                    end,
12462                    blend_mode,
12463                },
12464                end,
12465            ))
12466        }
12467        SegmentDrawItem::Text(_) => {
12468            let mut end = start + 1;
12469            while end < ordered_items.len() {
12470                if matches!(ordered_items[end].1, SegmentDrawItem::Text(_)) {
12471                    end += 1;
12472                } else {
12473                    break;
12474                }
12475            }
12476            Some((SegmentBatchPlan::Text { start, end }, end))
12477        }
12478        SegmentDrawItem::Composite(_) => {
12479            let mut end = start + 1;
12480            while end < ordered_items.len() {
12481                if matches!(ordered_items[end].1, SegmentDrawItem::Composite(_)) {
12482                    end += 1;
12483                } else {
12484                    break;
12485                }
12486            }
12487            Some((SegmentBatchPlan::Composite { start, end }, end))
12488        }
12489        SegmentDrawItem::ShaderComposite(_) => {
12490            let mut end = start + 1;
12491            while end < ordered_items.len() {
12492                if matches!(ordered_items[end].1, SegmentDrawItem::ShaderComposite(_)) {
12493                    end += 1;
12494                } else {
12495                    break;
12496                }
12497            }
12498            Some((SegmentBatchPlan::ShaderComposite { start, end }, end))
12499        }
12500        SegmentDrawItem::Retained(_) => {
12501            let mut end = start + 1;
12502            while end < ordered_items.len() {
12503                if matches!(ordered_items[end].1, SegmentDrawItem::Retained(_)) {
12504                    end += 1;
12505                } else {
12506                    break;
12507                }
12508            }
12509            Some((SegmentBatchPlan::Retained { start, end }, end))
12510        }
12511        SegmentDrawItem::Shadow(_) => None,
12512    }
12513}
12514
12515#[allow(clippy::too_many_arguments)]
12516fn collect_non_effect_segment_items(
12517    shapes: &[DrawShape],
12518    _images: &[ImageDraw],
12519    _texts: &[TextDraw],
12520    _shadow_draws: &[ShadowDraw],
12521    draw_ops: &[DrawOp],
12522    z_start: usize,
12523    z_end: usize,
12524    effect_z_ranges: &[Range<usize>],
12525    width: u32,
12526    height: u32,
12527    root_scale: f32,
12528    scratch: &mut Vec<(usize, SegmentDrawItem)>,
12529) {
12530    scratch.clear();
12531    let viewport = ViewportUniformParams {
12532        width,
12533        height,
12534        offset: [0.0, 0.0],
12535    };
12536
12537    scratch.extend(draw_ops.iter().filter_map(|op| {
12538        if op.z_index < z_start
12539            || op.z_index >= z_end
12540            || is_in_effect_range(op.z_index, effect_z_ranges)
12541        {
12542            return None;
12543        }
12544        let item = match op.kind {
12545            DrawOpKind::Shape(index) => {
12546                let shape = shapes.get(index)?;
12547                if !shape_draw_is_visible_in_viewport(shape, viewport, root_scale) {
12548                    return None;
12549                }
12550                SegmentDrawItem::Shape(index)
12551            }
12552            DrawOpKind::Image(index) => SegmentDrawItem::Image(index),
12553            DrawOpKind::Text(index) => SegmentDrawItem::Text(index),
12554            DrawOpKind::Shadow(index) => SegmentDrawItem::Shadow(index),
12555            DrawOpKind::Retained(index) => SegmentDrawItem::Retained(index),
12556        };
12557        Some((op.z_index, item))
12558    }));
12559}
12560
12561fn retain_renderable_shadow_items(
12562    ordered_items: &mut Vec<(usize, SegmentDrawItem)>,
12563    shadow_draws: &[ShadowDraw],
12564    width: u32,
12565    height: u32,
12566    root_scale: f32,
12567    max_texture_dim: u32,
12568) -> usize {
12569    let original_len = ordered_items.len();
12570    ordered_items.retain(|(_, item)| match item {
12571        SegmentDrawItem::Shadow(index) => shadow_draws.get(*index).is_some_and(|shadow| {
12572            shadow_draw_may_render(shadow, width, height, root_scale, max_texture_dim)
12573        }),
12574        _ => true,
12575    });
12576    original_len.saturating_sub(ordered_items.len())
12577}
12578
12579#[cfg(not(target_arch = "wasm32"))]
12580#[derive(Clone, Copy)]
12581struct SegmentDiagCounts {
12582    raw_shadow_items: usize,
12583    culled_shadow_items: usize,
12584    cached_shadow_composites: usize,
12585    composite_items: usize,
12586    shader_composite_items: usize,
12587}
12588
12589#[cfg(not(target_arch = "wasm32"))]
12590fn maybe_print_segment_diag(
12591    z_range: Range<usize>,
12592    ordered_items: &[(usize, SegmentDrawItem)],
12593    shapes: &[DrawShape],
12594    images: &[ImageDraw],
12595    counts: SegmentDiagCounts,
12596    batch_limits: ShapeBatchLimits,
12597) {
12598    if !cranpose_core::env_flag!("CRANPOSE_SEGMENT_DIAG") {
12599        return;
12600    }
12601    let line = SEGMENT_DIAG_LINES.fetch_add(1, Ordering::Relaxed);
12602    if line >= 64 {
12603        return;
12604    }
12605
12606    let remaining_shadow_items = ordered_items
12607        .iter()
12608        .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
12609        .count();
12610    let commands: Vec<_> =
12611        SegmentCommandIter::new(ordered_items, shapes, images, batch_limits).collect();
12612    let draw_chunks = commands
12613        .iter()
12614        .filter(|command| matches!(command, SegmentRenderCommand::DrawChunk(_)))
12615        .count();
12616    let shadow_commands = commands
12617        .iter()
12618        .filter(|command| matches!(command, SegmentRenderCommand::Shadow(_)))
12619        .count();
12620    let mut native_partitions = 0usize;
12621    let mut native_unfused_chunks = 0usize;
12622    for command in &commands {
12623        let SegmentRenderCommand::DrawChunk(chunk) = command else {
12624            continue;
12625        };
12626        match native_segment_fusion_partitions(ordered_items, shapes, chunk, batch_limits) {
12627            Ok(Some(partitions)) => native_partitions += partitions.len(),
12628            Ok(None) | Err(_) => native_unfused_chunks += 1,
12629        }
12630    }
12631
12632    eprintln!(
12633        "[segment-diag #{line}] z={}..{} items={} raw_shadows={} culled_shadows={} cached_shadows={} remaining_shadows={} composites={} shader_composites={} draw_chunks={} shadow_commands={} native_partitions={} native_unfused_chunks={}",
12634        z_range.start,
12635        z_range.end,
12636        ordered_items.len(),
12637        counts.raw_shadow_items,
12638        counts.culled_shadow_items,
12639        counts.cached_shadow_composites,
12640        remaining_shadow_items,
12641        counts.composite_items,
12642        counts.shader_composite_items,
12643        draw_chunks,
12644        shadow_commands,
12645        native_partitions,
12646        native_unfused_chunks,
12647    );
12648}
12649
12650pub(crate) fn has_backdrop_layer_in_range(
12651    backdrop_layers: &[BackdropLayer],
12652    z_start: usize,
12653    z_end: usize,
12654) -> bool {
12655    backdrop_layers
12656        .iter()
12657        .any(|layer| layer.z_index >= z_start && layer.z_index < z_end)
12658}
12659
12660pub(crate) fn scissor_rect_for_rect(
12661    rect: Rect,
12662    root_scale: f32,
12663    width: u32,
12664    height: u32,
12665) -> Option<(u32, u32, u32, u32)> {
12666    let mut left = canonicalize_device_coordinate(rect.x * root_scale);
12667    let mut top = canonicalize_device_coordinate(rect.y * root_scale);
12668    let mut right = canonicalize_device_coordinate((rect.x + rect.width) * root_scale);
12669    let mut bottom = canonicalize_device_coordinate((rect.y + rect.height) * root_scale);
12670
12671    left = left.max(0.0).min(width as f32).floor();
12672    top = top.max(0.0).min(height as f32).floor();
12673    right = right.max(0.0).min(width as f32).ceil();
12674    bottom = bottom.max(0.0).min(height as f32).ceil();
12675
12676    if right <= left || bottom <= top {
12677        return None;
12678    }
12679
12680    Some((
12681        left as u32,
12682        top as u32,
12683        (right - left) as u32,
12684        (bottom - top) as u32,
12685    ))
12686}
12687
12688fn scissor_rect_for_layer(
12689    rect: Rect,
12690    clip: Option<Rect>,
12691    root_scale: f32,
12692    width: u32,
12693    height: u32,
12694) -> Option<(u32, u32, u32, u32)> {
12695    let clipped_rect = match clip {
12696        Some(clip_rect) => rect.intersect(clip_rect)?,
12697        None => rect,
12698    };
12699
12700    scissor_rect_for_rect(clipped_rect, root_scale, width, height)
12701}
12702
12703fn tint_for_image(
12704    color_filter: Option<ColorFilter>,
12705    alpha: f32,
12706) -> ([f32; 4], Option<ColorFilter>) {
12707    let alpha = alpha.clamp(0.0, 1.0);
12708    match color_filter {
12709        Some(filter) if filter.supports_gpu_vertex_modulation() => {
12710            let Some(tint) = filter.gpu_vertex_tint() else {
12711                return ([1.0, 1.0, 1.0, alpha], Some(filter));
12712            };
12713            (
12714                [
12715                    tint[0].clamp(0.0, 1.0),
12716                    tint[1].clamp(0.0, 1.0),
12717                    tint[2].clamp(0.0, 1.0),
12718                    (tint[3] * alpha).clamp(0.0, 1.0),
12719                ],
12720                None,
12721            )
12722        }
12723        Some(filter) => ([1.0, 1.0, 1.0, alpha], Some(filter)),
12724        None => ([1.0, 1.0, 1.0, alpha], None),
12725    }
12726}
12727
12728fn image_uv_rect(image: &ImageBitmap, src_rect: Option<Rect>) -> Option<ImageUvRect> {
12729    let Some(src) = src_rect else {
12730        return Some(ImageUvRect {
12731            min: [0.0, 0.0],
12732            max: [1.0, 1.0],
12733            sample_bounds: [0.0, 0.0, 1.0, 1.0],
12734        });
12735    };
12736
12737    let (u_min, u_max, u_bound_min, u_bound_max) =
12738        source_axis_uv(src.x, src.width, image.width() as f32)?;
12739    let (v_min, v_max, v_bound_min, v_bound_max) =
12740        source_axis_uv(src.y, src.height, image.height() as f32)?;
12741
12742    Some(ImageUvRect {
12743        min: [u_min, v_min],
12744        max: [u_max, v_max],
12745        sample_bounds: [u_bound_min, v_bound_min, u_bound_max, v_bound_max],
12746    })
12747}
12748
12749/// Normalises an atlas entry against `atlas_size`, the side length of the
12750/// texture the entry was placed in. The atlas grows on overflow, so the size
12751/// has to be read from the live atlas rather than a constant — a UV computed
12752/// against the wrong size samples the wrong glyph.
12753fn glyph_atlas_uv_rect(entry: GlyphAtlasEntry, atlas_size: u32) -> ImageUvRect {
12754    let atlas_width = atlas_size as f32;
12755    let atlas_height = atlas_size as f32;
12756    let min = [entry.x as f32 / atlas_width, entry.y as f32 / atlas_height];
12757    let max = [
12758        (entry.x + entry.width) as f32 / atlas_width,
12759        (entry.y + entry.height) as f32 / atlas_height,
12760    ];
12761    let center_min = [
12762        (entry.x as f32 + 0.5) / atlas_width,
12763        (entry.y as f32 + 0.5) / atlas_height,
12764    ];
12765    let center_max = [
12766        (entry.x as f32 + entry.width as f32 - 0.5).max(entry.x as f32 + 0.5) / atlas_width,
12767        (entry.y as f32 + entry.height as f32 - 0.5).max(entry.y as f32 + 0.5) / atlas_height,
12768    ];
12769    ImageUvRect {
12770        min,
12771        max,
12772        sample_bounds: [center_min[0], center_min[1], center_max[0], center_max[1]],
12773    }
12774}
12775
12776fn snap_nearest_image_to_device_pixels(image: &mut ImageDraw, root_scale: f32) {
12777    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
12778        return;
12779    }
12780
12781    let Some(rect) = axis_aligned_quad_rect(image.quad) else {
12782        return;
12783    };
12784
12785    let left_px = (rect.x * root_scale).round();
12786    let top_px = (rect.y * root_scale).round();
12787    let width_px = (rect.width * root_scale).round().max(1.0);
12788    let height_px = (rect.height * root_scale).round().max(1.0);
12789    let snapped = Rect {
12790        x: left_px / root_scale,
12791        y: top_px / root_scale,
12792        width: width_px / root_scale,
12793        height: height_px / root_scale,
12794    };
12795
12796    image.rect = snapped;
12797    image.local_rect = Rect {
12798        x: image.local_rect.x + snapped.x - rect.x,
12799        y: image.local_rect.y + snapped.y - rect.y,
12800        width: snapped.width,
12801        height: snapped.height,
12802    };
12803    image.quad = crate::rect_to_quad(snapped);
12804}
12805
12806fn nearest_image_device_quad(image: &ImageDraw, root_scale: f32) -> Option<[[f32; 2]; 4]> {
12807    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
12808        return None;
12809    }
12810
12811    let rect = axis_aligned_quad_rect(image.quad)?;
12812    let left_px = (rect.x * root_scale).round();
12813    let top_px = (rect.y * root_scale).round();
12814    let width_px = (rect.width * root_scale).round().max(1.0);
12815    let height_px = (rect.height * root_scale).round().max(1.0);
12816    let right_px = left_px + width_px;
12817    let bottom_px = top_px + height_px;
12818    Some([
12819        [left_px, top_px],
12820        [right_px, top_px],
12821        [left_px, bottom_px],
12822        [right_px, bottom_px],
12823    ])
12824}
12825
12826fn source_axis_uv(start: f32, extent: f32, image_extent: f32) -> Option<(f32, f32, f32, f32)> {
12827    if !start.is_finite()
12828        || !extent.is_finite()
12829        || !image_extent.is_finite()
12830        || extent == 0.0
12831        || image_extent <= 0.0
12832    {
12833        return None;
12834    }
12835
12836    let end = start + extent;
12837    let edge_min = start.min(end).clamp(0.0, image_extent);
12838    let edge_max = start.max(end).clamp(0.0, image_extent);
12839    if edge_max <= edge_min {
12840        return None;
12841    }
12842
12843    let center_min = edge_min + 0.5;
12844    let center_max = edge_max - 0.5;
12845    let (bound_min, bound_max) = if center_min <= center_max {
12846        (center_min, center_max)
12847    } else {
12848        let center = (edge_min + edge_max) * 0.5;
12849        (center, center)
12850    };
12851
12852    Some((
12853        edge_min / image_extent,
12854        edge_max / image_extent,
12855        bound_min / image_extent,
12856        bound_max / image_extent,
12857    ))
12858}
12859
12860fn apply_filter_to_bitmap(image: &ImageBitmap, filter: ColorFilter) -> Result<ImageBitmap, String> {
12861    let mut filtered = Vec::with_capacity(image.pixels().len());
12862    for pixel in image.pixels().chunks_exact(4) {
12863        let rgba = [
12864            pixel[0] as f32 / 255.0,
12865            pixel[1] as f32 / 255.0,
12866            pixel[2] as f32 / 255.0,
12867            pixel[3] as f32 / 255.0,
12868        ];
12869        let out = filter.apply_rgba(rgba);
12870        filtered.push((out[0].clamp(0.0, 1.0) * 255.0).round() as u8);
12871        filtered.push((out[1].clamp(0.0, 1.0) * 255.0).round() as u8);
12872        filtered.push((out[2].clamp(0.0, 1.0) * 255.0).round() as u8);
12873        filtered.push((out[3].clamp(0.0, 1.0) * 255.0).round() as u8);
12874    }
12875    ImageBitmap::from_rgba8(image.width(), image.height(), filtered)
12876        .map_err(|error| format!("failed to build filtered bitmap: {error}"))
12877}
12878
12879fn scissor_rect_for_image(
12880    image: &ImageDraw,
12881    root_scale: f32,
12882    width: u32,
12883    height: u32,
12884) -> Option<(u32, u32, u32, u32)> {
12885    scissor_rect_for_layer(image.rect, image.clip, root_scale, width, height)
12886}
12887
12888fn inner_shadow_composite_mask(
12889    shadow: &ShadowDraw,
12890    root_scale: f32,
12891) -> Option<RoundedCompositeMask> {
12892    if !shadow
12893        .shapes
12894        .iter()
12895        .any(|(_, mode)| *mode == BlendMode::DstOut)
12896    {
12897        return None;
12898    }
12899    let (fill, _) = shadow.shapes.first()?;
12900    let rect = fill.local_rect;
12901    if rect.width <= 0.0 || rect.height <= 0.0 {
12902        return None;
12903    }
12904
12905    let radii = fill.shape.map_or([0.0; 4], |rounded| {
12906        let resolved = rounded.resolve(rect.width, rect.height);
12907        [
12908            resolved.top_left * root_scale,
12909            resolved.top_right * root_scale,
12910            resolved.bottom_left * root_scale,
12911            resolved.bottom_right * root_scale,
12912        ]
12913    });
12914
12915    Some(RoundedCompositeMask {
12916        rect: [
12917            rect.x * root_scale,
12918            rect.y * root_scale,
12919            rect.width * root_scale,
12920            rect.height * root_scale,
12921        ],
12922        radii,
12923    })
12924}
12925
12926#[cfg(test)]
12927mod tests {
12928    use super::*;
12929    use crate::normalized_scene::visible_draw_rect;
12930    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
12931    use cranpose_render_common::graph::{DrawPrimitiveNode, IsolationReasons, TextPrimitiveNode};
12932    use cranpose_render_common::raster_cache::LayerRasterCacheHashes;
12933    use cranpose_render_common::scene_builder::build_graph_from_applier;
12934    use cranpose_ui::text::{
12935        AnnotatedString, BaselineShift, RangeStyle, Shadow, SpanStyle, TextDecoration,
12936        TextDrawStyle, TextGeometricTransform, TextMotion, TextUnit,
12937    };
12938    use cranpose_ui::{
12939        LayoutEngine, LazyColumn, LazyColumnSpec, Modifier, Size, Text, TextLayoutOptions,
12940        TextStyle,
12941    };
12942    use cranpose_ui_graphics::{
12943        Brush, Color, CornerRadii, DrawPrimitive, Rect, RenderEffect, RoundedCornerShape,
12944        RuntimeShader,
12945    };
12946
12947    fn chunk(batches: &[SegmentBatchPlan]) -> SegmentDrawChunkPlan {
12948        let mut chunk = SegmentDrawChunkPlan::default();
12949        for batch in batches {
12950            chunk.push(*batch);
12951        }
12952        chunk
12953    }
12954
12955    fn with_test_app_context<R>(block: impl FnOnce() -> R) -> R {
12956        let app_context = cranpose_ui::AppContext::new();
12957        app_context.enter(block)
12958    }
12959
12960    fn assert_snap_anchor_close(actual: Option<SnapAnchor>, expected_origin: Point, message: &str) {
12961        let Some(actual) = actual else {
12962            panic!("{message}: missing snap anchor");
12963        };
12964        let expected = SnapAnchor::rigid(expected_origin);
12965        assert_eq!(
12966            actual.device_pixel_step, expected.device_pixel_step,
12967            "{message}: device pixel step changed"
12968        );
12969        assert!(
12970            (actual.origin.x - expected.origin.x).abs() <= 1e-4
12971                && (actual.origin.y - expected.origin.y).abs() <= 1e-4,
12972            "{message}: expected origin {:?}, got {:?}",
12973            expected.origin,
12974            actual.origin
12975        );
12976    }
12977
12978    fn effect_layer(z_start: usize, z_end: usize) -> EffectLayer {
12979        EffectLayer {
12980            rect: Rect {
12981                x: 0.0,
12982                y: 0.0,
12983                width: 10.0,
12984                height: 10.0,
12985            },
12986            clip: None,
12987            snap_anchor: None,
12988            effect: Some(RenderEffect::blur(4.0)),
12989            blend_mode: BlendMode::SrcOver,
12990            composite_alpha: 1.0,
12991            z_start,
12992            z_end,
12993            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
12994        }
12995    }
12996
12997    #[test]
12998    fn direct_shader_composite_accepts_box4_when_viewport_preserves_source_pixels() {
12999        assert_eq!(
13000            direct_shader_composite_viewport(
13001                1.0,
13002                BlendMode::SrcOver,
13003                Some((12.0, 18.0, 64.0, 32.0)),
13004                CompositeSampleMode::Box4,
13005                (64, 32),
13006            ),
13007            Some((12.0, 18.0, 64.0, 32.0))
13008        );
13009    }
13010
13011    #[test]
13012    fn direct_shader_composite_rejects_box4_when_viewport_resamples_source() {
13013        assert_eq!(
13014            direct_shader_composite_viewport(
13015                1.0,
13016                BlendMode::SrcOver,
13017                Some((12.0, 18.0, 64.5, 32.0)),
13018                CompositeSampleMode::Box4,
13019                (64, 32),
13020            ),
13021            None
13022        );
13023        assert_eq!(
13024            direct_shader_composite_viewport(
13025                1.0,
13026                BlendMode::SrcOver,
13027                Some((12.25, 18.0, 64.0, 32.0)),
13028                CompositeSampleMode::Box4,
13029                (64, 32),
13030            ),
13031            None
13032        );
13033    }
13034
13035    fn test_text_draw(rect: Rect, text_motion: TextMotion) -> TextDraw {
13036        let mut text_style = TextStyle::default();
13037        text_style.paragraph_style.text_motion = Some(text_motion);
13038        TextDraw {
13039            node_id: 42,
13040            rect,
13041            snap_anchor: None,
13042            translated_content_context: false,
13043            text: Arc::new(AnnotatedString::new("stable markdown row".to_string()).render_string()),
13044            color: Color::WHITE,
13045            text_style,
13046            font_size: 14.0,
13047            scale: 1.0,
13048            layout_options: TextLayoutOptions::default(),
13049            z_index: 0,
13050            clip: None,
13051        }
13052    }
13053
13054    #[test]
13055    fn static_text_image_cache_key_ignores_absolute_scroll_position() {
13056        let base = test_text_draw(
13057            Rect {
13058                x: 12.25,
13059                y: 40.75,
13060                width: 220.0,
13061                height: 24.0,
13062            },
13063            TextMotion::Static,
13064        );
13065        let scrolled = test_text_draw(
13066            Rect {
13067                x: 12.75,
13068                y: -318.5,
13069                width: 220.0,
13070                height: 24.0,
13071            },
13072            TextMotion::Static,
13073        );
13074
13075        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, true);
13076        let scrolled_key = GpuRenderer::text_image_cache_key(&scrolled, scrolled.rect, 1.0, true);
13077
13078        assert_eq!(
13079            base_key, scrolled_key,
13080            "scrolling static text must reuse the same raster cache entry"
13081        );
13082    }
13083
13084    #[test]
13085    fn static_text_glyph_run_cache_key_ignores_absolute_scroll_position() {
13086        let base = test_text_draw(
13087            Rect {
13088                x: 12.25,
13089                y: 40.75,
13090                width: 220.0,
13091                height: 24.0,
13092            },
13093            TextMotion::Static,
13094        );
13095        let scrolled = test_text_draw(
13096            Rect {
13097                x: 12.75,
13098                y: -318.5,
13099                width: 220.0,
13100                height: 24.0,
13101            },
13102            TextMotion::Static,
13103        );
13104
13105        let base_key = GpuRenderer::text_glyph_run_cache_key(&base, base.rect, 1.0, true);
13106        let scrolled_key =
13107            GpuRenderer::text_glyph_run_cache_key(&scrolled, scrolled.rect, 1.0, true);
13108
13109        assert_eq!(
13110            base_key, scrolled_key,
13111            "scrolling static text must reuse the same retained glyph run"
13112        );
13113    }
13114
13115    #[test]
13116    fn static_multiline_text_glyph_source_keeps_full_text_when_image_source_slices() {
13117        let rect = Rect {
13118            x: 8.0,
13119            y: 100.0,
13120            width: 240.0,
13121            height: 1_000.0,
13122        };
13123        let mut draw = test_text_draw(rect, TextMotion::Static);
13124        let lines = (0..100)
13125            .map(|line| format!("line-{line:03}"))
13126            .collect::<Vec<_>>()
13127            .join("\n");
13128        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
13129
13130        let raster_rect = Rect {
13131            x: 16.0,
13132            y: 200.0,
13133            width: 480.0,
13134            height: 2_000.0,
13135        };
13136        let clipped = clipped_text_raster_source(
13137            &draw,
13138            rect,
13139            raster_rect,
13140            Some(Rect {
13141                x: 0.0,
13142                y: 610.0,
13143                width: 800.0,
13144                height: 40.0,
13145            }),
13146            2.0,
13147            true,
13148        );
13149        let glyph = text_glyph_raster_source(&draw, raster_rect);
13150
13151        assert!(
13152            matches!(clipped.draw, Cow::Owned(_)),
13153            "the image source should still slice large clipped multiline text"
13154        );
13155        assert!(
13156            matches!(glyph.draw, Cow::Borrowed(_)),
13157            "the glyph source must keep a stable full-text run key while scrolling"
13158        );
13159
13160        let clipped_key = GpuRenderer::text_glyph_run_cache_key(
13161            clipped.draw.as_ref(),
13162            clipped.raster_rect,
13163            2.0,
13164            true,
13165        );
13166        let glyph_key = GpuRenderer::text_glyph_run_cache_key(
13167            glyph.draw.as_ref(),
13168            glyph.raster_rect,
13169            2.0,
13170            true,
13171        );
13172
13173        assert_ne!(
13174            clipped_key, glyph_key,
13175            "image slicing must not force glyph rendering onto per-scroll line-window cache keys"
13176        );
13177    }
13178
13179    #[cfg(not(target_arch = "wasm32"))]
13180    #[test]
13181    fn retained_glyph_viewport_offsets_relative_vertices_by_source_origin() {
13182        let viewport = ViewportUniformParams {
13183            width: 800,
13184            height: 600,
13185            offset: [10.0, 20.0],
13186        };
13187        let source = Rect {
13188            x: 40.0,
13189            y: 90.0,
13190            width: 120.0,
13191            height: 48.0,
13192        };
13193
13194        let retained = GpuRenderer::retained_glyph_viewport(viewport, source);
13195
13196        assert_eq!(retained.width, viewport.width);
13197        assert_eq!(retained.height, viewport.height);
13198        assert_eq!(retained.offset, [-30.0, -70.0]);
13199    }
13200
13201    #[cfg(not(target_arch = "wasm32"))]
13202    #[test]
13203    fn tiny_text_glyph_runs_stay_in_shared_uploads() {
13204        assert!(
13205            !should_use_retained_text_glyph_run(8, None),
13206            "tiny labels must stay in the shared fused batch"
13207        );
13208    }
13209
13210    #[cfg(not(target_arch = "wasm32"))]
13211    #[test]
13212    fn line_sized_text_glyph_runs_stay_in_shared_uploads() {
13213        assert!(
13214            !should_use_retained_text_glyph_run(64, None),
13215            "Markdown scroll frames contain many line-sized text runs; retaining each one creates per-run buffer binds instead of one shared glyph batch"
13216        );
13217    }
13218
13219    #[cfg(not(target_arch = "wasm32"))]
13220    #[test]
13221    fn large_clipped_text_glyph_runs_stay_in_shared_uploads() {
13222        assert!(
13223            !should_use_retained_text_glyph_run(
13224                MIN_RETAINED_TEXT_GLYPH_QUADS.saturating_mul(2),
13225                Some(Rect {
13226                    x: 0.0,
13227                    y: 0.0,
13228                    width: 200.0,
13229                    height: 100.0,
13230                }),
13231            ),
13232            "clipped lazy-list text must not draw a full retained run outside the viewport"
13233        );
13234    }
13235
13236    #[test]
13237    fn normal_text_glyph_draw_skips_offscreen_prewarm_candidates() {
13238        assert_eq!(
13239            text_glyph_draw_action(false, true, false),
13240            TextGlyphDrawAction::Skip,
13241            "normal draw traversal must not prepare offscreen text"
13242        );
13243    }
13244
13245    #[test]
13246    fn bounded_text_glyph_prewarm_admits_offscreen_candidates() {
13247        assert_eq!(
13248            text_glyph_draw_action(false, true, true),
13249            TextGlyphDrawAction::PrewarmOffscreen,
13250            "only the bounded prewarm path may prepare offscreen text"
13251        );
13252    }
13253
13254    #[test]
13255    fn visible_text_glyph_draws_are_always_admitted() {
13256        assert_eq!(
13257            text_glyph_draw_action(true, false, false),
13258            TextGlyphDrawAction::DrawVisible
13259        );
13260        assert_eq!(
13261            text_glyph_draw_action(true, true, true),
13262            TextGlyphDrawAction::DrawVisible
13263        );
13264    }
13265
13266    #[cfg(not(target_arch = "wasm32"))]
13267    #[test]
13268    fn offscreen_text_prewarm_skips_large_uncached_text_runs() {
13269        assert!(
13270            !offscreen_text_glyph_prewarm_work_is_bounded(
13271                None,
13272                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS + 1,
13273            ),
13274            "offscreen prewarm must not collect large uncached text runs in an input frame"
13275        );
13276    }
13277
13278    #[cfg(not(target_arch = "wasm32"))]
13279    #[test]
13280    fn offscreen_text_prewarm_admits_small_uncached_text_runs() {
13281        assert!(
13282            offscreen_text_glyph_prewarm_work_is_bounded(
13283                None,
13284                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
13285            ),
13286            "small labels can be warmed without risking a frame-budget spike"
13287        );
13288    }
13289
13290    #[cfg(not(target_arch = "wasm32"))]
13291    #[test]
13292    fn offscreen_text_prewarm_skips_large_cached_runs_without_quads() {
13293        assert!(
13294            !offscreen_text_glyph_prewarm_work_is_bounded(
13295                Some(MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS + 1),
13296                0,
13297            ),
13298            "cached glyph placements can still be too large to prepare during input frames"
13299        );
13300    }
13301
13302    #[cfg(not(target_arch = "wasm32"))]
13303    #[test]
13304    fn offscreen_text_prewarm_stops_after_candidate_budget() {
13305        assert!(
13306            offscreen_text_glyph_prewarm_budget_exhausted(
13307                Instant::now(),
13308                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES,
13309            ),
13310            "prewarm must be bounded by candidate count even when each candidate is cheap"
13311        );
13312    }
13313
13314    #[test]
13315    fn clipped_cached_glyph_quads_are_filtered_to_viewport() {
13316        fn quad(y: i32) -> CachedTextGlyphQuad {
13317            CachedTextGlyphQuad {
13318                x: 8,
13319                y,
13320                width: 20,
13321                height: 10,
13322                color: (1.0, 1.0, 1.0, 1.0),
13323                uv: ImageUvRect {
13324                    min: [0.0, 0.0],
13325                    max: [1.0, 1.0],
13326                    sample_bounds: [0.0, 0.0, 1.0, 1.0],
13327                },
13328            }
13329        }
13330
13331        let source = Rect {
13332            x: 0.0,
13333            y: 0.0,
13334            width: 320.0,
13335            height: 400.0,
13336        };
13337        let clip = Some(Rect {
13338            x: 0.0,
13339            y: 0.0,
13340            width: 320.0,
13341            height: 80.0,
13342        });
13343        let viewport = ViewportUniformParams {
13344            width: 320,
13345            height: 80,
13346            offset: [0.0, 0.0],
13347        };
13348
13349        assert!(cached_text_glyph_quad_is_visible_in_viewport(
13350            source,
13351            &quad(40),
13352            clip,
13353            viewport,
13354            1.0,
13355        ));
13356        assert!(
13357            !cached_text_glyph_quad_is_visible_in_viewport(source, &quad(140), clip, viewport, 1.0,),
13358            "glyphs outside the effective clip should not enter the frame command stream"
13359        );
13360    }
13361
13362    #[test]
13363    fn small_scene_range_cache_miss_observes_first_render() {
13364        let key = LayerRasterCacheKey::scene_range(
13365            0xCACE,
13366            Rect {
13367                x: 0.0,
13368                y: 0.0,
13369                width: 120.0,
13370                height: 80.0,
13371            },
13372            (120, 80),
13373            ScaleBucket::from_scale(1.0),
13374        );
13375
13376        assert!(
13377            !first_cache_miss_admission(&key),
13378            "a small scene-range miss should render directly first instead of materializing a tiny one-frame retained target"
13379        );
13380        assert!(
13381            repeated_cache_miss_admission(&key),
13382            "a repeated small scene-range miss is stable enough to materialize into the retained cache"
13383        );
13384    }
13385
13386    #[test]
13387    fn large_scene_range_cache_miss_requires_repeated_stable_key() {
13388        let key = LayerRasterCacheKey::scene_range(
13389            0xCACE,
13390            Rect {
13391                x: 0.0,
13392                y: 0.0,
13393                width: 1200.0,
13394                height: 900.0,
13395            },
13396            (1200, 900),
13397            ScaleBucket::from_scale(1.0),
13398        );
13399
13400        assert!(
13401            !first_cache_miss_admission(&key),
13402            "a large first scene-range miss should render directly instead of materializing a multi-MB one-frame cache entry"
13403        );
13404        assert!(
13405            repeated_cache_miss_admission(&key),
13406            "a repeated scene-range miss is stable enough to materialize into the retained cache"
13407        );
13408    }
13409
13410    #[test]
13411    fn renderer_warmup_frame_is_requested_for_cache_miss_stats_only() {
13412        let stats = gpu_stats::FrameStats::default();
13413        let mut snapshot = stats.snapshot();
13414        assert!(
13415            !frame_stats_need_warmup_frame(&snapshot),
13416            "a clean frame must not keep a static scene redrawing"
13417        );
13418
13419        snapshot.layer_cache_misses = 1;
13420        assert!(frame_stats_need_warmup_frame(&snapshot));
13421        snapshot.layer_cache_misses = 0;
13422
13423        snapshot.shadow_shape_cache_misses = 1;
13424        assert!(frame_stats_need_warmup_frame(&snapshot));
13425        snapshot.shadow_shape_cache_misses = 0;
13426
13427        snapshot.text_image_cache_misses = 1;
13428        assert!(frame_stats_need_warmup_frame(&snapshot));
13429        snapshot.text_image_cache_misses = 0;
13430
13431        snapshot.text_glyph_atlas_misses = 1;
13432        assert!(frame_stats_need_warmup_frame(&snapshot));
13433    }
13434
13435    #[test]
13436    fn renderer_warmup_budget_is_consumed_by_a_repeated_cache_miss() {
13437        let stats = gpu_stats::FrameStats::default();
13438        let mut snapshot = stats.snapshot();
13439        snapshot.layer_cache_misses = 1;
13440        let mut pending_frames = 0;
13441
13442        update_frame_warmup_budget(&mut pending_frames, &snapshot);
13443        assert_eq!(pending_frames, CACHE_MISS_WARMUP_FRAMES);
13444
13445        update_frame_warmup_budget(&mut pending_frames, &snapshot);
13446        assert_eq!(
13447            pending_frames, 0,
13448            "a cache miss during the warmup frame must not replenish its budget"
13449        );
13450    }
13451
13452    #[test]
13453    fn non_scene_layer_surface_cache_miss_admits_first_render() {
13454        let key = LayerRasterCacheKey::new(
13455            Some(77),
13456            0xC0FFEE,
13457            0,
13458            Rect {
13459                x: 0.0,
13460                y: 0.0,
13461                width: 120.0,
13462                height: 80.0,
13463            },
13464            (120, 80),
13465            ScaleBucket::from_scale(1.0),
13466        );
13467
13468        assert!(
13469            first_cache_miss_admission(&key),
13470            "ordinary retained layer surfaces should still cache on first miss"
13471        );
13472    }
13473
13474    #[test]
13475    fn text_image_cache_key_is_content_addressed_not_node_addressed() {
13476        let first = test_text_draw(
13477            Rect {
13478                x: 12.25,
13479                y: 40.75,
13480                width: 220.0,
13481                height: 24.0,
13482            },
13483            TextMotion::Static,
13484        );
13485        let mut second = first.clone();
13486        second.node_id = first.node_id + 1;
13487
13488        let first_key = GpuRenderer::text_image_cache_key(&first, first.rect, 1.0, true);
13489        let second_key = GpuRenderer::text_image_cache_key(&second, second.rect, 1.0, true);
13490
13491        assert_eq!(
13492            first_key, second_key,
13493            "text raster cache keys must be based on rendered pixels, not node identity"
13494        );
13495    }
13496
13497    #[test]
13498    fn animated_text_image_cache_key_keeps_fractional_phase_only() {
13499        let base = test_text_draw(
13500            Rect {
13501                x: 12.25,
13502                y: 40.75,
13503                width: 220.0,
13504                height: 24.0,
13505            },
13506            TextMotion::Animated,
13507        );
13508        let integer_translated = test_text_draw(
13509            Rect {
13510                x: 44.25,
13511                y: 88.75,
13512                width: 220.0,
13513                height: 24.0,
13514            },
13515            TextMotion::Animated,
13516        );
13517        let phase_shifted = test_text_draw(
13518            Rect {
13519                x: 44.5,
13520                y: 88.75,
13521                width: 220.0,
13522                height: 24.0,
13523            },
13524            TextMotion::Animated,
13525        );
13526
13527        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, false);
13528        let translated_key = GpuRenderer::text_image_cache_key(
13529            &integer_translated,
13530            integer_translated.rect,
13531            1.0,
13532            false,
13533        );
13534        let phase_shifted_key =
13535            GpuRenderer::text_image_cache_key(&phase_shifted, phase_shifted.rect, 1.0, false);
13536
13537        assert_eq!(
13538            base_key, translated_key,
13539            "integer translation should not invalidate animated text raster cache entries"
13540        );
13541        assert_ne!(
13542            base_key, phase_shifted_key,
13543            "fractional phase affects animated text rasterization and must stay in the key"
13544        );
13545    }
13546
13547    #[test]
13548    fn animated_translated_text_raster_geometry_applies_snap_anchor() {
13549        let mut base = test_text_draw(
13550            Rect {
13551                x: 14.25,
13552                y: 16.50,
13553                width: 220.0,
13554                height: 24.0,
13555            },
13556            TextMotion::Animated,
13557        );
13558        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.50)));
13559
13560        let mut scrolled = test_text_draw(
13561            Rect {
13562                x: 14.25,
13563                y: 15.80,
13564                width: 220.0,
13565                height: 24.0,
13566            },
13567            TextMotion::Animated,
13568        );
13569        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 15.80)));
13570
13571        let (base_logical, base_raster, _, _, base_static) =
13572            text_raster_geometry_for_draw(&base, 1.0).expect("base text geometry");
13573        let (scrolled_logical, scrolled_raster, _, _, scrolled_static) =
13574            text_raster_geometry_for_draw(&scrolled, 1.0).expect("scrolled text geometry");
13575
13576        assert!(!base_static);
13577        assert!(!scrolled_static);
13578        assert!((base_logical.x - 14.0).abs() < f32::EPSILON);
13579        assert!((base_logical.y - 17.0).abs() < f32::EPSILON);
13580        assert!((scrolled_logical.x - 14.0).abs() < f32::EPSILON);
13581        assert!((scrolled_logical.y - 16.0).abs() < f32::EPSILON);
13582        assert_eq!(base_raster.x.fract(), 0.0);
13583        assert_eq!(base_raster.y.fract(), 0.0);
13584        assert_eq!(scrolled_raster.x.fract(), 0.0);
13585        assert_eq!(scrolled_raster.y.fract(), 0.0);
13586
13587        let base_key = GpuRenderer::text_image_cache_key(&base, base_raster, 1.0, false);
13588        let scrolled_key =
13589            GpuRenderer::text_image_cache_key(&scrolled, scrolled_raster, 1.0, false);
13590        assert_eq!(
13591            base_key, scrolled_key,
13592            "translated animated text should keep a stable raster phase while scrolling"
13593        );
13594    }
13595
13596    #[test]
13597    fn translated_static_text_moves_one_device_pixel_at_half_pixel_phase() {
13598        let root_scale = 1.25;
13599        let mut base = test_text_draw(
13600            Rect {
13601                x: 14.0,
13602                y: 276.0,
13603                width: 220.0,
13604                height: 24.0,
13605            },
13606            TextMotion::Static,
13607        );
13608        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.600_006)));
13609
13610        let mut scrolled = test_text_draw(
13611            Rect {
13612                x: 14.0,
13613                y: 275.2,
13614                width: 220.0,
13615                height: 24.0,
13616            },
13617            TextMotion::Static,
13618        );
13619        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 126.799_99)));
13620
13621        let (_, base_raster, _, _, _) =
13622            text_raster_geometry_for_draw(&base, root_scale).expect("base text geometry");
13623        let (_, scrolled_raster, _, _, _) =
13624            text_raster_geometry_for_draw(&scrolled, root_scale).expect("scrolled text geometry");
13625
13626        assert_eq!(
13627            base_raster.y - scrolled_raster.y,
13628            1.0,
13629            "one physical pixel of rigid scrolling must move static text by one raster pixel"
13630        );
13631    }
13632
13633    #[test]
13634    fn translated_text_snap_does_not_move_its_fixed_ancestor_clip() {
13635        let root_scale = 1.25;
13636        let fixed_clip = Rect {
13637            x: 8.0,
13638            y: 20.0,
13639            width: 300.0,
13640            height: 680.0,
13641        };
13642        let mut draw = test_text_draw(
13643            Rect {
13644                x: 14.0,
13645                y: 276.0,
13646                width: 220.0,
13647                height: 24.0,
13648            },
13649            TextMotion::Static,
13650        );
13651        draw.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.4)));
13652        draw.clip = Some(fixed_clip);
13653
13654        let (_, _, clip, _, _) =
13655            text_raster_geometry_for_draw(&draw, root_scale).expect("clipped text geometry");
13656
13657        assert_eq!(
13658            clip,
13659            Some(fixed_clip),
13660            "content pixel snapping must not translate a fixed ancestor clip"
13661        );
13662    }
13663
13664    #[test]
13665    fn clipped_static_multiline_text_raster_source_limits_visible_line_window() {
13666        let rect = Rect {
13667            x: 8.0,
13668            y: 100.0,
13669            width: 240.0,
13670            height: 1_000.0,
13671        };
13672        let mut draw = test_text_draw(rect, TextMotion::Static);
13673        let lines = (0..100)
13674            .map(|line| format!("line-{line:03}"))
13675            .collect::<Vec<_>>()
13676            .join("\n");
13677        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
13678
13679        let raster_rect = Rect {
13680            x: 16.0,
13681            y: 200.0,
13682            width: 480.0,
13683            height: 2_000.0,
13684        };
13685        let source = clipped_text_raster_source(
13686            &draw,
13687            rect,
13688            raster_rect,
13689            Some(Rect {
13690                x: 0.0,
13691                y: 610.0,
13692                width: 800.0,
13693                height: 40.0,
13694            }),
13695            2.0,
13696            true,
13697        );
13698
13699        let Cow::Owned(sliced_draw) = source.draw else {
13700            panic!("clipped static multiline text should rasterize only the visible line window");
13701        };
13702        let sliced_text = sliced_draw.text.text.as_str();
13703        assert!(sliced_text.contains("line-050"));
13704        assert!(sliced_text.contains("line-055"));
13705        assert!(!sliced_text.contains("line-000"));
13706        assert!(!sliced_text.contains("line-099"));
13707        assert_eq!(source.raster_rect.x, raster_rect.x);
13708        assert!(source.raster_rect.y > raster_rect.y);
13709        assert!(source.raster_rect.height < raster_rect.height);
13710    }
13711
13712    #[test]
13713    fn clipped_static_multiline_text_raster_source_slices_short_multiline_text() {
13714        let rect = Rect {
13715            x: 8.0,
13716            y: 100.0,
13717            width: 240.0,
13718            height: 320.0,
13719        };
13720        let mut draw = test_text_draw(rect, TextMotion::Static);
13721        let lines = (0..24)
13722            .map(|line| format!("code-line-{line:02}"))
13723            .collect::<Vec<_>>()
13724            .join("\n");
13725        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
13726
13727        let raster_rect = Rect {
13728            x: 16.0,
13729            y: 200.0,
13730            width: 480.0,
13731            height: 640.0,
13732        };
13733        let source = clipped_text_raster_source(
13734            &draw,
13735            rect,
13736            raster_rect,
13737            Some(Rect {
13738                x: 0.0,
13739                y: 190.0,
13740                width: 800.0,
13741                height: 120.0,
13742            }),
13743            2.0,
13744            true,
13745        );
13746
13747        let Cow::Owned(sliced_draw) = source.draw else {
13748            panic!("clipped multiline text should rasterize only the visible line window");
13749        };
13750        assert!(sliced_draw.text.text.as_str().contains("code-line-06"));
13751        assert!(!sliced_draw.text.text.as_str().contains("code-line-00"));
13752        assert!(!sliced_draw.text.text.as_str().contains("code-line-23"));
13753        assert_eq!(source.raster_rect.x, raster_rect.x);
13754        assert!(source.raster_rect.y > raster_rect.y);
13755        assert!(source.raster_rect.height < raster_rect.height);
13756    }
13757
13758    #[test]
13759    fn text_line_index_cache_reuses_retained_index_for_same_text_instance() {
13760        let mut cache = TextLineIndexCache::new(4);
13761        let text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
13762
13763        let first = cache.line_starts(&text);
13764        let second = cache.line_starts(&text);
13765
13766        assert_eq!(first.as_ref(), &[0, 2, 4]);
13767        assert!(
13768            Rc::ptr_eq(&first, &second),
13769            "retained text should not rebuild its line index on every clipped frame"
13770        );
13771    }
13772
13773    #[test]
13774    fn text_line_index_cache_is_retained_text_instance_local() {
13775        let mut cache = TextLineIndexCache::new(4);
13776        let first_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
13777        let second_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
13778
13779        let first = cache.line_starts(&first_text);
13780        let second = cache.line_starts(&second_text);
13781
13782        assert_eq!(first.as_ref(), second.as_ref());
13783        assert!(
13784            !Rc::ptr_eq(&first, &second),
13785            "line index lookup should not hash large text contents to find unrelated retained nodes"
13786        );
13787    }
13788
13789    #[test]
13790    fn device_pixel_bounds_for_rect_snaps_origin_and_extents() {
13791        let bounds = device_pixel_bounds_for_rect(
13792            Rect {
13793                x: 10.25,
13794                y: 14.6,
13795                width: 20.1,
13796                height: 9.2,
13797            },
13798            200,
13799            120,
13800            2.0,
13801        )
13802        .expect("rect should intersect the viewport");
13803
13804        assert_eq!(
13805            bounds,
13806            DevicePixelBounds {
13807                x: 20.0,
13808                y: 29.0,
13809                width: 41,
13810                height: 19,
13811            }
13812        );
13813    }
13814
13815    #[test]
13816    fn visible_layer_rect_intersects_clip_and_viewport() {
13817        let visible = visible_layer_rect(
13818            Rect {
13819                x: -10.0,
13820                y: 5.0,
13821                width: 80.0,
13822                height: 40.0,
13823            },
13824            Some(Rect {
13825                x: 4.0,
13826                y: 8.0,
13827                width: 20.0,
13828                height: 50.0,
13829            }),
13830            2.0,
13831            60,
13832            40,
13833        )
13834        .expect("visible rect");
13835
13836        assert_eq!(
13837            visible,
13838            Rect {
13839                x: 4.0,
13840                y: 8.0,
13841                width: 20.0,
13842                height: 12.0,
13843            }
13844        );
13845    }
13846
13847    #[test]
13848    fn clamp_effect_surface_scale_caps_large_surfaces_but_keeps_base_scale() {
13849        let clamped = clamp_effect_surface_scale(
13850            Rect {
13851                x: 0.0,
13852                y: 0.0,
13853                width: 1200.0,
13854                height: 900.0,
13855            },
13856            1.0,
13857            8.0,
13858            16_384,
13859        );
13860
13861        assert!(
13862            clamped < 8.0,
13863            "large translated effect layers must be capped to avoid OOM, got {clamped}"
13864        );
13865        assert!(
13866            clamped >= 1.0,
13867            "effect surfaces must not fall below destination resolution, got {clamped}"
13868        );
13869    }
13870
13871    #[test]
13872    fn clamp_effect_surface_scale_keeps_decorated_text_capture_scale() {
13873        let clamped = clamp_effect_surface_scale(
13874            Rect {
13875                x: 0.0,
13876                y: 0.0,
13877                width: 446.0,
13878                height: 44.0,
13879            },
13880            1.0,
13881            9.0,
13882            16_384,
13883        );
13884
13885        assert_eq!(
13886            clamped, 9.0,
13887            "decorated text motion-stable captures must keep full scale"
13888        );
13889    }
13890
13891    fn backdrop_layer(z_index: usize) -> BackdropLayer {
13892        BackdropLayer {
13893            node_id: Some(700 + z_index),
13894            rect: Rect {
13895                x: 0.0,
13896                y: 0.0,
13897                width: 10.0,
13898                height: 10.0,
13899            },
13900            clip: None,
13901            snap_anchor: None,
13902            effect: RenderEffect::blur(2.0),
13903            z_index,
13904        }
13905    }
13906
13907    fn test_shape(z_index: usize, blend_mode: BlendMode) -> DrawShape {
13908        DrawShape {
13909            rect: Rect {
13910                x: 0.0,
13911                y: 0.0,
13912                width: 8.0,
13913                height: 8.0,
13914            },
13915            local_rect: Rect {
13916                x: 0.0,
13917                y: 0.0,
13918                width: 8.0,
13919                height: 8.0,
13920            },
13921            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
13922            snap_anchor: None,
13923            brush: Brush::solid(Color::BLACK),
13924            shape: None,
13925            stroke: None,
13926            arc: None,
13927            z_index,
13928            clip: None,
13929            blend_mode,
13930            motion_context_animated: false,
13931        }
13932    }
13933
13934    #[test]
13935    fn shape_shadow_content_hash_ignores_viewport_translation() {
13936        fn translate_shape(shape: &DrawShape, dx: f32, dy: f32) -> DrawShape {
13937            let mut translated = shape.clone();
13938            translated.rect.x += dx;
13939            translated.rect.y += dy;
13940            translated.local_rect.x += dx;
13941            translated.local_rect.y += dy;
13942            for point in &mut translated.quad {
13943                point[0] += dx;
13944                point[1] += dy;
13945            }
13946            translated.snap_anchor = translated.snap_anchor.map(|anchor| {
13947                SnapAnchor::rigid(Point::new(anchor.origin.x + dx, anchor.origin.y + dy))
13948            });
13949            translated.clip = translated.clip.map(|mut clip| {
13950                clip.x += dx;
13951                clip.y += dy;
13952                clip
13953            });
13954            translated
13955        }
13956
13957        let mut first = test_shape(1, BlendMode::SrcOver);
13958        first.rect = Rect {
13959            x: 10.0,
13960            y: 20.0,
13961            width: 80.0,
13962            height: 40.0,
13963        };
13964        first.local_rect = first.rect;
13965        first.quad = [[10.0, 20.0], [90.0, 20.0], [10.0, 60.0], [90.0, 60.0]];
13966        first.snap_anchor = Some(SnapAnchor::rigid(Point::new(7.0, 11.0)));
13967        first.shape = Some(RoundedCornerShape::uniform(8.0));
13968        first.clip = Some(Rect {
13969            x: 8.0,
13970            y: 18.0,
13971            width: 86.0,
13972            height: 44.0,
13973        });
13974        let mut cutout = test_shape(2, BlendMode::DstOut);
13975        cutout.rect = Rect {
13976            x: 18.0,
13977            y: 26.0,
13978            width: 62.0,
13979            height: 22.0,
13980        };
13981        cutout.local_rect = cutout.rect;
13982        cutout.quad = [[18.0, 26.0], [80.0, 26.0], [18.0, 48.0], [80.0, 48.0]];
13983        cutout.shape = Some(RoundedCornerShape::uniform(4.0));
13984
13985        let dx = 37.0;
13986        let dy = -11.5;
13987        let translated = translate_shape(&first, dx, dy);
13988        let translated_cutout = translate_shape(&cutout, dx, dy);
13989
13990        let root_scale = 1.25;
13991        let first_shapes = vec![
13992            (first.clone(), BlendMode::SrcOver),
13993            (cutout, BlendMode::DstOut),
13994        ];
13995        let translated_shapes = vec![
13996            (translated.clone(), BlendMode::SrcOver),
13997            (translated_cutout, BlendMode::DstOut),
13998        ];
13999
14000        let first_hash = shape_shadow_content_hash(&first_shapes, root_scale);
14001        let translated_hash = shape_shadow_content_hash(&translated_shapes, root_scale);
14002
14003        assert_eq!(first_hash, translated_hash);
14004
14005        let mut changed_shapes = translated_shapes;
14006        changed_shapes[0].0.rect.width += 1.0;
14007        let changed_hash = shape_shadow_content_hash(&changed_shapes, root_scale);
14008
14009        assert_ne!(first_hash, changed_hash);
14010    }
14011
14012    #[test]
14013    fn shape_shadow_content_hash_is_stable_under_fractional_scale_scroll() {
14014        // Regression: scrolling a shadowed panel on a fractional-scale display
14015        // (e.g. Xft.dpi 130 → scale ≈ 1.354) must not re-render the shadow blur
14016        // every frame. The production cache key derives its viewport offset from
14017        // FLOORED device-pixel bounds, so the residual subpixel phase used to leak
14018        // into the content hash and miss the cache on every scroll step.
14019        fn shadow_shapes_at(y: f32) -> Vec<(DrawShape, BlendMode)> {
14020            let mut shape = test_shape(1, BlendMode::SrcOver);
14021            shape.rect = Rect {
14022                x: 24.0,
14023                y,
14024                width: 180.0,
14025                height: 90.0,
14026            };
14027            shape.local_rect = shape.rect;
14028            shape.quad = crate::rect_to_quad(shape.rect);
14029            shape.shape = Some(RoundedCornerShape::uniform(14.0));
14030            vec![(shape, BlendMode::SrcOver)]
14031        }
14032
14033        let root_scale = 130.0f32 / 96.0;
14034        let blur_radius = 18.0f32;
14035        let pixel_radius = blur_radius * root_scale;
14036
14037        let key_at = |y: f32| {
14038            let shapes = shadow_shapes_at(y);
14039            let plan =
14040                shape_shadow_surface_plan(&shapes, None, blur_radius, 1600, 1600, root_scale, 8192)
14041                    .expect("surface plan");
14042            shape_shadow_surface_cache_key(
14043                &shapes,
14044                plan.source_device_bounds,
14045                pixel_radius,
14046                root_scale,
14047            )
14048            .expect("cache key")
14049        };
14050
14051        // Wheel scroll translates the panel by whole logical pixels; the device
14052        // subpixel phase changes on every step at fractional scale. The whole
14053        // cache key (content hash AND surface pixel size) must stay stable, or
14054        // every scroll frame re-renders the shadow blur.
14055        let base = key_at(640.0);
14056        for step in 1..=12 {
14057            let scrolled = key_at(640.0 - step as f32 * 4.0);
14058            assert_eq!(
14059                base, scrolled,
14060                "scrolled shadow cache key must stay stable at fractional scale (step {step})"
14061            );
14062        }
14063    }
14064
14065    #[test]
14066    fn shape_shadow_cache_key_uses_unclipped_source_bounds_for_scrolled_clip() {
14067        fn translated_card_shadow(y: f32) -> Vec<(DrawShape, BlendMode)> {
14068            let mut shape = test_shape(1, BlendMode::SrcOver);
14069            shape.rect = Rect {
14070                x: 24.0,
14071                y,
14072                width: 280.0,
14073                height: 120.0,
14074            };
14075            shape.local_rect = shape.rect;
14076            shape.quad = [[24.0, y], [304.0, y], [24.0, y + 120.0], [304.0, y + 120.0]];
14077            shape.shape = Some(RoundedCornerShape::uniform(18.0));
14078            vec![(shape, BlendMode::SrcOver)]
14079        }
14080
14081        let root_scale = 1.0;
14082        let blur_radius = 18.0;
14083        let viewport_clip = Rect {
14084            x: 0.0,
14085            y: 96.0,
14086            width: 360.0,
14087            height: 720.0,
14088        };
14089        let key_for = |y: f32| {
14090            let shapes = translated_card_shadow(y);
14091            let plan = shape_shadow_surface_plan(
14092                &shapes,
14093                Some(viewport_clip),
14094                blur_radius,
14095                360,
14096                900,
14097                root_scale,
14098                4096,
14099            )
14100            .expect("surface plan");
14101            shape_shadow_surface_cache_key(
14102                &shapes,
14103                plan.source_device_bounds,
14104                plan.pixel_radius,
14105                root_scale,
14106            )
14107            .expect("cache key")
14108        };
14109
14110        // The card scrolls under a fixed viewport clip; the visible portion
14111        // changes but the cache key must stay anchored to the unclipped source.
14112        assert_eq!(key_for(740.0), key_for(756.0));
14113    }
14114
14115    #[test]
14116    fn shape_visibility_uses_nonzero_viewport_offset_for_cropped_offscreen() {
14117        let mut shape = test_shape(1, BlendMode::SrcOver);
14118        shape.rect = Rect {
14119            x: 24.0,
14120            y: 740.0,
14121            width: 280.0,
14122            height: 120.0,
14123        };
14124        shape.local_rect = shape.rect;
14125        shape.quad = [[24.0, 740.0], [304.0, 740.0], [24.0, 860.0], [304.0, 860.0]];
14126        let viewport = ViewportUniformParams {
14127            width: 316,
14128            height: 228,
14129            offset: [6.0, 686.0],
14130        };
14131
14132        assert!(shape_draw_is_visible_in_viewport(&shape, viewport, 1.0));
14133    }
14134
14135    #[test]
14136    fn text_prewarm_uses_nonzero_viewport_offset_for_cropped_offscreen() {
14137        let viewport = ViewportUniformParams {
14138            width: 316,
14139            height: 228,
14140            offset: [6.0, 686.0],
14141        };
14142        let text_rect = Rect {
14143            x: 24.0,
14144            y: 740.0,
14145            width: 280.0,
14146            height: 40.0,
14147        };
14148
14149        assert!(text_draw_is_visible_in_viewport(
14150            text_rect, None, viewport, 1.0
14151        ));
14152        assert!(text_draw_should_prewarm_in_viewport(
14153            text_rect, None, viewport, 1.0
14154        ));
14155    }
14156
14157    fn test_shadow_draw(shapes: Vec<(DrawShape, BlendMode)>) -> ShadowDraw {
14158        ShadowDraw {
14159            shapes,
14160            texts: vec![],
14161            blur_radius: 8.0,
14162            clip: None,
14163            z_index: 0,
14164        }
14165    }
14166
14167    fn test_image(z_index: usize, blend_mode: BlendMode) -> ImageDraw {
14168        ImageDraw {
14169            rect: Rect {
14170                x: 0.0,
14171                y: 0.0,
14172                width: 8.0,
14173                height: 8.0,
14174            },
14175            local_rect: Rect {
14176                x: 0.0,
14177                y: 0.0,
14178                width: 8.0,
14179                height: 8.0,
14180            },
14181            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
14182            snap_anchor: None,
14183            image: ImageBitmap::from_rgba8(1, 1, vec![255, 255, 255, 255]).expect("image"),
14184            alpha: 1.0,
14185            color_filter: None,
14186            sampling: ImageSampling::Nearest,
14187            z_index,
14188            clip: None,
14189            blend_mode,
14190            src_rect: None,
14191            motion_context_animated: false,
14192        }
14193    }
14194
14195    #[test]
14196    fn image_sampler_descriptors_match_requested_sampling() {
14197        let nearest = image_sampler_descriptor(ImageSampling::Nearest);
14198        assert_eq!(nearest.mag_filter, wgpu::FilterMode::Nearest);
14199        assert_eq!(nearest.min_filter, wgpu::FilterMode::Nearest);
14200
14201        let linear = image_sampler_descriptor(ImageSampling::Linear);
14202        assert_eq!(linear.mag_filter, wgpu::FilterMode::Linear);
14203        assert_eq!(linear.min_filter, wgpu::FilterMode::Linear);
14204    }
14205
14206    #[test]
14207    fn image_uv_rect_clamps_source_rect_to_texel_centers() {
14208        let image = ImageBitmap::from_rgba8(24, 16, vec![0; 24 * 16 * 4]).expect("image");
14209        let uv = image_uv_rect(
14210            &image,
14211            Some(Rect {
14212                x: 0.0,
14213                y: 0.0,
14214                width: 16.0,
14215                height: 16.0,
14216            }),
14217        )
14218        .expect("uv rect");
14219
14220        assert_eq!(uv.min, [0.0, 0.0]);
14221        assert_eq!(uv.max, [16.0 / 24.0, 1.0]);
14222        assert_eq!(
14223            uv.sample_bounds,
14224            [0.5 / 24.0, 0.5 / 16.0, 15.5 / 24.0, 15.5 / 16.0]
14225        );
14226    }
14227
14228    #[test]
14229    fn image_uv_rect_keeps_full_image_unclamped() {
14230        let image = ImageBitmap::from_rgba8(2, 2, vec![0; 16]).expect("image");
14231        let uv = image_uv_rect(&image, None).expect("uv rect");
14232
14233        assert_eq!(uv.min, [0.0, 0.0]);
14234        assert_eq!(uv.max, [1.0, 1.0]);
14235        assert_eq!(uv.sample_bounds, [0.0, 0.0, 1.0, 1.0]);
14236    }
14237
14238    fn test_text(z_index: usize) -> TextDraw {
14239        TextDraw {
14240            node_id: 0,
14241            rect: Rect {
14242                x: 0.0,
14243                y: 0.0,
14244                width: 8.0,
14245                height: 8.0,
14246            },
14247            snap_anchor: None,
14248            translated_content_context: false,
14249            text: Arc::new(cranpose_ui::text::AnnotatedString::from("t").render_string()),
14250            color: Color::WHITE,
14251            text_style: cranpose_ui::TextStyle::default(),
14252            font_size: 12.0,
14253            scale: 1.0,
14254            layout_options: cranpose_ui::TextLayoutOptions::default(),
14255            z_index,
14256            clip: None,
14257        }
14258    }
14259
14260    #[test]
14261    fn text_draw_visibility_rejects_text_outside_clip_before_rasterization() {
14262        let viewport = ViewportUniformParams {
14263            width: 320,
14264            height: 240,
14265            offset: [0.0, 0.0],
14266        };
14267        let text_rect = Rect {
14268            x: 0.0,
14269            y: 260.0,
14270            width: 200.0,
14271            height: 40.0,
14272        };
14273        let clip = Some(Rect {
14274            x: 0.0,
14275            y: 0.0,
14276            width: 320.0,
14277            height: 200.0,
14278        });
14279
14280        assert!(
14281            !text_draw_is_visible_in_viewport(text_rect, clip, viewport, 1.0),
14282            "lazy-list beyond-bound text outside the clip must not be rasterized"
14283        );
14284    }
14285
14286    #[test]
14287    fn text_draw_prewarm_accepts_clipped_text_near_viewport() {
14288        let viewport = ViewportUniformParams {
14289            width: 320,
14290            height: 240,
14291            offset: [0.0, 0.0],
14292        };
14293        let text_rect = Rect {
14294            x: 0.0,
14295            y: 260.0,
14296            width: 200.0,
14297            height: 40.0,
14298        };
14299        let clip = Some(Rect {
14300            x: 0.0,
14301            y: 0.0,
14302            width: 320.0,
14303            height: 200.0,
14304        });
14305
14306        assert!(!text_draw_is_visible_in_viewport(
14307            text_rect, clip, viewport, 1.0
14308        ));
14309        assert!(text_draw_should_prewarm_in_viewport(
14310            text_rect, clip, viewport, 1.0
14311        ));
14312    }
14313
14314    #[test]
14315    fn text_draw_prewarm_rejects_far_clipped_text() {
14316        let viewport = ViewportUniformParams {
14317            width: 320,
14318            height: 240,
14319            offset: [0.0, 0.0],
14320        };
14321        let text_rect = Rect {
14322            x: 0.0,
14323            y: 1600.0,
14324            width: 200.0,
14325            height: 40.0,
14326        };
14327        let clip = Some(Rect {
14328            x: 0.0,
14329            y: 0.0,
14330            width: 320.0,
14331            height: 200.0,
14332        });
14333
14334        assert!(!text_draw_should_prewarm_in_viewport(
14335            text_rect, clip, viewport, 1.0
14336        ));
14337    }
14338
14339    #[test]
14340    fn text_draw_visibility_rejects_unclipped_text_outside_viewport() {
14341        let viewport = ViewportUniformParams {
14342            width: 320,
14343            height: 240,
14344            offset: [0.0, 0.0],
14345        };
14346        let text_rect = Rect {
14347            x: 0.0,
14348            y: 241.0,
14349            width: 200.0,
14350            height: 40.0,
14351        };
14352
14353        assert!(
14354            !text_draw_is_visible_in_viewport(text_rect, None, viewport, 1.0),
14355            "unclipped text outside the target viewport must not be rasterized"
14356        );
14357    }
14358
14359    #[test]
14360    fn text_draw_visibility_keeps_partially_visible_text() {
14361        let viewport = ViewportUniformParams {
14362            width: 320,
14363            height: 240,
14364            offset: [0.0, 0.0],
14365        };
14366        let text_rect = Rect {
14367            x: 0.0,
14368            y: 220.0,
14369            width: 200.0,
14370            height: 40.0,
14371        };
14372
14373        assert!(text_draw_is_visible_in_viewport(
14374            text_rect, None, viewport, 1.0
14375        ));
14376    }
14377
14378    fn test_draw_ops(
14379        shapes: &[DrawShape],
14380        images: &[ImageDraw],
14381        texts: &[TextDraw],
14382        shadows: &[ShadowDraw],
14383    ) -> Vec<DrawOp> {
14384        let mut ops = Vec::new();
14385        ops.extend(shapes.iter().enumerate().map(|(index, shape)| DrawOp {
14386            z_index: shape.z_index,
14387            kind: DrawOpKind::Shape(index),
14388        }));
14389        ops.extend(images.iter().enumerate().map(|(index, image)| DrawOp {
14390            z_index: image.z_index,
14391            kind: DrawOpKind::Image(index),
14392        }));
14393        ops.extend(texts.iter().enumerate().map(|(index, text)| DrawOp {
14394            z_index: text.z_index,
14395            kind: DrawOpKind::Text(index),
14396        }));
14397        ops.extend(shadows.iter().enumerate().map(|(index, shadow)| DrawOp {
14398            z_index: shadow.z_index,
14399            kind: DrawOpKind::Shadow(index),
14400        }));
14401        ops.sort_by_key(|op| op.z_index);
14402        ops
14403    }
14404
14405    fn test_layer(local_bounds: Rect, children: Vec<RenderNode>) -> LayerNode {
14406        crate::test_support::layer_node(
14407            local_bounds,
14408            ProjectiveTransform::identity(),
14409            GraphicsLayer::default(),
14410            children,
14411        )
14412    }
14413
14414    fn cacheable_layer(
14415        node_id: cranpose_core::NodeId,
14416        local_bounds: Rect,
14417        children: Vec<RenderNode>,
14418    ) -> LayerNode {
14419        let mut layer = test_layer(local_bounds, children);
14420        layer.node_id = Some(node_id);
14421        layer.cache_policy = cranpose_render_common::graph::CachePolicy::Auto;
14422        layer.recompute_raster_cache_hashes();
14423        layer
14424    }
14425
14426    fn text_layer_with_style(text: AnnotatedString, text_style: TextStyle) -> LayerNode {
14427        test_layer(
14428            Rect {
14429                x: 0.0,
14430                y: 0.0,
14431                width: 64.0,
14432                height: 32.0,
14433            },
14434            vec![RenderNode::Primitive(PrimitiveEntry {
14435                phase: PrimitivePhase::BeforeChildren,
14436                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
14437                    node_id: 1,
14438                    rect: Rect {
14439                        x: 2.0,
14440                        y: 3.0,
14441                        width: 48.0,
14442                        height: 18.0,
14443                    },
14444                    text: std::rc::Rc::new(text),
14445                    text_style,
14446                    font_size: 14.0,
14447                    layout_options: TextLayoutOptions::default(),
14448                    clip: None,
14449                })),
14450            })],
14451        )
14452    }
14453
14454    fn snapped_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
14455        LayerNode {
14456            node_id: Some(77),
14457            local_bounds: Rect {
14458                x: 0.0,
14459                y: 0.0,
14460                width: 48.0,
14461                height: 24.0,
14462            },
14463            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
14464            motion_context_animated: animated,
14465            translated_content_context,
14466            translated_content_offset: Point::default(),
14467            content_offset: Point::default(),
14468            scene_children_origin: cranpose_ui_graphics::Point::default(),
14469            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
14470            graphics_layer: GraphicsLayer::default(),
14471            clip_to_bounds: false,
14472            shadow_clip: None,
14473            hit_test: None,
14474            has_hit_targets: false,
14475            isolation: IsolationReasons::default(),
14476            cache_policy: CachePolicy::None,
14477            cache_hashes: LayerRasterCacheHashes::default(),
14478            cache_hashes_valid: false,
14479            children: vec![
14480                RenderNode::Primitive(PrimitiveEntry {
14481                    phase: PrimitivePhase::BeforeChildren,
14482                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
14483                        primitive: DrawPrimitive::RoundRect {
14484                            rect: Rect {
14485                                x: 0.0,
14486                                y: 0.0,
14487                                width: 48.0,
14488                                height: 24.0,
14489                            },
14490                            brush: Brush::solid(Color(0.28, 0.30, 0.46, 0.88)),
14491                            radii: CornerRadii::uniform(6.0),
14492                            stroke: None,
14493                        },
14494                        clip: None,
14495                    }),
14496                }),
14497                RenderNode::Primitive(PrimitiveEntry {
14498                    phase: PrimitivePhase::BeforeChildren,
14499                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
14500                        primitive: DrawPrimitive::Image {
14501                            rect: Rect {
14502                                x: 2.0,
14503                                y: 2.0,
14504                                width: 12.0,
14505                                height: 12.0,
14506                            },
14507                            image: ImageBitmap::from_rgba8(
14508                                2,
14509                                2,
14510                                vec![
14511                                    255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255,
14512                                    255,
14513                                ],
14514                            )
14515                            .expect("image"),
14516                            alpha: 1.0,
14517                            color_filter: None,
14518                            sampling: ImageSampling::Linear,
14519                            src_rect: None,
14520                        },
14521                        clip: None,
14522                    }),
14523                }),
14524                RenderNode::Primitive(PrimitiveEntry {
14525                    phase: PrimitivePhase::BeforeChildren,
14526                    node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
14527                        node_id: 77,
14528                        rect: Rect {
14529                            x: 6.0,
14530                            y: 4.0,
14531                            width: 36.0,
14532                            height: 16.0,
14533                        },
14534                        text: std::rc::Rc::new(AnnotatedString::from("48 px")),
14535                        text_style: TextStyle::default(),
14536                        font_size: 14.0,
14537                        layout_options: TextLayoutOptions::default(),
14538                        clip: None,
14539                    })),
14540                }),
14541            ],
14542        }
14543    }
14544
14545    fn snapped_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
14546        let text_leaf = snapped_text_leaf(animated, translated_content_context);
14547        test_layer(
14548            Rect {
14549                x: 0.0,
14550                y: 0.0,
14551                width: 96.0,
14552                height: 64.0,
14553            },
14554            vec![RenderNode::Layer(Box::new(text_leaf))],
14555        )
14556    }
14557
14558    fn translated_content_local_surface_root() -> LayerNode {
14559        let mut effectful_text = text_layer_with_style(
14560            AnnotatedString::from("shadow"),
14561            TextStyle::from_span_style(SpanStyle {
14562                shadow: Some(Shadow {
14563                    color: Color::BLACK,
14564                    offset: Point::new(1.0, 2.0),
14565                    blur_radius: 3.0,
14566                }),
14567                ..SpanStyle::default()
14568            }),
14569        );
14570        effectful_text.translated_content_context = true;
14571
14572        let translated_content = LayerNode {
14573            node_id: Some(78),
14574            local_bounds: Rect {
14575                x: 0.0,
14576                y: 0.0,
14577                width: 96.0,
14578                height: 64.0,
14579            },
14580            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
14581            motion_context_animated: false,
14582            translated_content_context: true,
14583            translated_content_offset: Point::default(),
14584            content_offset: Point::default(),
14585            scene_children_origin: cranpose_ui_graphics::Point::default(),
14586            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
14587            graphics_layer: GraphicsLayer::default(),
14588            clip_to_bounds: false,
14589            shadow_clip: None,
14590            hit_test: None,
14591            has_hit_targets: false,
14592            isolation: IsolationReasons::default(),
14593            cache_policy: CachePolicy::None,
14594            cache_hashes: LayerRasterCacheHashes::default(),
14595            cache_hashes_valid: false,
14596            children: vec![RenderNode::Layer(Box::new(effectful_text))],
14597        };
14598
14599        test_layer(
14600            Rect {
14601                x: 0.0,
14602                y: 0.0,
14603                width: 160.0,
14604                height: 120.0,
14605            },
14606            vec![RenderNode::Layer(Box::new(translated_content))],
14607        )
14608    }
14609
14610    #[test]
14611    fn scissor_rect_for_layer_intersects_with_clip() {
14612        let rect = Rect {
14613            x: 10.0,
14614            y: 10.0,
14615            width: 30.0,
14616            height: 20.0,
14617        };
14618        let clip = Rect {
14619            x: 20.0,
14620            y: 15.0,
14621            width: 100.0,
14622            height: 100.0,
14623        };
14624
14625        let scissor = scissor_rect_for_layer(rect, Some(clip), 1.0, 200, 200);
14626        assert_eq!(scissor, Some((20, 15, 20, 15)));
14627    }
14628
14629    #[test]
14630    fn visible_draw_rect_no_clip_returns_original() {
14631        let rect = Rect {
14632            x: 100.0,
14633            y: 200.0,
14634            width: 300.0,
14635            height: 400.0,
14636        };
14637        assert_eq!(visible_draw_rect(rect, None), Some(rect));
14638    }
14639
14640    #[test]
14641    fn visible_draw_rect_with_clip_intersects() {
14642        let rect = Rect {
14643            x: 0.0,
14644            y: 0.0,
14645            width: 2000.0,
14646            height: 5000.0,
14647        };
14648        let clip = Rect {
14649            x: 0.0,
14650            y: 0.0,
14651            width: 800.0,
14652            height: 600.0,
14653        };
14654        let visible = visible_draw_rect(rect, Some(clip)).expect("should have visible area");
14655        assert_eq!(visible.width, 800.0);
14656        assert_eq!(visible.height, 600.0);
14657    }
14658
14659    #[test]
14660    fn visible_draw_rect_fully_clipped_returns_none() {
14661        let rect = Rect {
14662            x: 1000.0,
14663            y: 1000.0,
14664            width: 200.0,
14665            height: 200.0,
14666        };
14667        let clip = Rect {
14668            x: 0.0,
14669            y: 0.0,
14670            width: 800.0,
14671            height: 600.0,
14672        };
14673        assert!(visible_draw_rect(rect, Some(clip)).is_none());
14674    }
14675
14676    #[test]
14677    fn scene_bounds_respects_clip_on_shapes() {
14678        let mut scene = CompositorScene::new();
14679        // Shape inside viewport — visible
14680        scene.shapes.push(DrawShape {
14681            rect: Rect {
14682                x: 10.0,
14683                y: 10.0,
14684                width: 100.0,
14685                height: 50.0,
14686            },
14687            clip: Some(Rect {
14688                x: 0.0,
14689                y: 0.0,
14690                width: 800.0,
14691                height: 600.0,
14692            }),
14693            ..test_shape(0, BlendMode::SrcOver)
14694        });
14695        // Shape far outside viewport — clipped away entirely
14696        scene.shapes.push(DrawShape {
14697            rect: Rect {
14698                x: 0.0,
14699                y: 3000.0,
14700                width: 100.0,
14701                height: 50.0,
14702            },
14703            clip: Some(Rect {
14704                x: 0.0,
14705                y: 0.0,
14706                width: 800.0,
14707                height: 600.0,
14708            }),
14709            ..test_shape(1, BlendMode::SrcOver)
14710        });
14711        let bounds = scene_bounds(&scene).expect("should have bounds");
14712        // Bounds should only cover the first shape's visible area,
14713        // NOT extend to y=3050 from the clipped second shape.
14714        assert!(bounds.y + bounds.height <= 600.0);
14715    }
14716
14717    #[test]
14718    fn scene_bounds_scroll_content_clipped_to_viewport() {
14719        // Simulates a scroll container: many items with large y offsets,
14720        // all clipped to a viewport-sized clip rect.
14721        let mut scene = CompositorScene::new();
14722        let viewport_clip = Rect {
14723            x: 0.0,
14724            y: 0.0,
14725            width: 800.0,
14726            height: 600.0,
14727        };
14728        for i in 0..20 {
14729            scene.shapes.push(DrawShape {
14730                rect: Rect {
14731                    x: 0.0,
14732                    y: i as f32 * 300.0,
14733                    width: 800.0,
14734                    height: 200.0,
14735                },
14736                clip: Some(viewport_clip),
14737                ..test_shape(i, BlendMode::SrcOver)
14738            });
14739        }
14740        let bounds = scene_bounds(&scene).expect("should have bounds");
14741        // All shapes are clipped to viewport — bounds should be viewport-sized,
14742        // NOT 20*300 = 6000 dp tall.
14743        assert_eq!(bounds.x, 0.0);
14744        assert_eq!(bounds.y, 0.0);
14745        assert!(bounds.width <= 800.0);
14746        assert!(bounds.height <= 600.0);
14747    }
14748
14749    #[test]
14750    fn scene_bounds_stable_across_scroll_offsets() {
14751        // Simulates horizontal scroll at different offsets —
14752        // bounds should be identical regardless of scroll position.
14753        let viewport_clip = Rect {
14754            x: 0.0,
14755            y: 0.0,
14756            width: 400.0,
14757            height: 50.0,
14758        };
14759        let compute_bounds_at_offset = |scroll_x: f32| {
14760            let mut scene = CompositorScene::new();
14761            for i in 0..10 {
14762                scene.shapes.push(DrawShape {
14763                    rect: Rect {
14764                        x: i as f32 * 100.0 - scroll_x,
14765                        y: 0.0,
14766                        width: 80.0,
14767                        height: 40.0,
14768                    },
14769                    clip: Some(viewport_clip),
14770                    ..test_shape(i, BlendMode::SrcOver)
14771                });
14772            }
14773            scene_bounds(&scene).expect("bounds")
14774        };
14775        let bounds_at_0 = compute_bounds_at_offset(0.0);
14776        let bounds_at_300 = compute_bounds_at_offset(300.0);
14777        let bounds_at_600 = compute_bounds_at_offset(600.0);
14778        // Width should be stable (clipped to viewport) regardless of scroll offset
14779        assert!(
14780            (bounds_at_0.width - bounds_at_300.width).abs() < 1.0,
14781            "bounds width changed with scroll: {} vs {}",
14782            bounds_at_0.width,
14783            bounds_at_300.width
14784        );
14785        assert!(
14786            (bounds_at_0.width - bounds_at_600.width).abs() < 1.0,
14787            "bounds width changed with scroll: {} vs {}",
14788            bounds_at_0.width,
14789            bounds_at_600.width
14790        );
14791    }
14792
14793    #[test]
14794    fn collect_effect_ranges_respects_excluded_effect() {
14795        let layers = vec![effect_layer(10, 40), effect_layer(20, 30)];
14796        let mut ranges = Vec::new();
14797        collect_effect_ranges(&layers, 10, 40, Some(0), &mut ranges);
14798        assert_eq!(ranges.len(), 1);
14799        assert_eq!(ranges[0], 20..30);
14800    }
14801
14802    #[test]
14803    fn collect_layer_events_includes_nested_when_parent_excluded() {
14804        let effects = vec![effect_layer(10, 40), effect_layer(20, 30)];
14805        let backdrops = vec![backdrop_layer(25)];
14806        let mut events = Vec::new();
14807        collect_layer_events(&effects, &backdrops, 10, 40, Some(0), &mut events);
14808        assert_eq!(events.len(), 2);
14809
14810        match events[0].kind {
14811            LayerEventKind::Effect(index) => assert_eq!(index, 1),
14812            LayerEventKind::Backdrop(_) => panic!("expected nested effect as first event"),
14813        }
14814        match events[1].kind {
14815            LayerEventKind::Backdrop(index) => assert_eq!(index, 0),
14816            LayerEventKind::Effect(_) => panic!("expected backdrop as second event"),
14817        }
14818    }
14819
14820    fn pure_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
14821        LayerNode {
14822            node_id: Some(177),
14823            local_bounds: Rect {
14824                x: 0.0,
14825                y: 0.0,
14826                width: 96.0,
14827                height: 32.0,
14828            },
14829            transform_to_parent: ProjectiveTransform::translation(11.4, 23.6),
14830            motion_context_animated: animated,
14831            translated_content_context,
14832            translated_content_offset: Point::default(),
14833            content_offset: Point::default(),
14834            scene_children_origin: cranpose_ui_graphics::Point::default(),
14835            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
14836            graphics_layer: GraphicsLayer::default(),
14837            clip_to_bounds: false,
14838            shadow_clip: None,
14839            hit_test: None,
14840            has_hit_targets: false,
14841            isolation: IsolationReasons::default(),
14842            cache_policy: CachePolicy::None,
14843            cache_hashes: LayerRasterCacheHashes::default(),
14844            cache_hashes_valid: false,
14845            children: vec![RenderNode::Primitive(PrimitiveEntry {
14846                phase: PrimitivePhase::BeforeChildren,
14847                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
14848                    node_id: 177,
14849                    rect: Rect {
14850                        x: 0.0,
14851                        y: 0.0,
14852                        width: 96.0,
14853                        height: 24.0,
14854                    },
14855                    clip: None,
14856                    text: std::rc::Rc::new(AnnotatedString::from("Pure text")),
14857                    text_style: TextStyle::default(),
14858                    font_size: 14.0,
14859                    layout_options: TextLayoutOptions::default(),
14860                })),
14861            })],
14862        }
14863    }
14864
14865    fn pure_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
14866        let text_leaf = pure_text_leaf(animated, translated_content_context);
14867        test_layer(
14868            Rect {
14869                x: 0.0,
14870                y: 0.0,
14871                width: 160.0,
14872                height: 96.0,
14873            },
14874            vec![RenderNode::Layer(Box::new(text_leaf))],
14875        )
14876    }
14877
14878    #[test]
14879    fn collect_layer_events_sorts_backdrop_before_effect_at_same_z() {
14880        let effects = vec![effect_layer(10, 20)];
14881        let backdrops = vec![backdrop_layer(10)];
14882        let mut events = Vec::new();
14883        collect_layer_events(&effects, &backdrops, 0, 30, None, &mut events);
14884        assert_eq!(events.len(), 2);
14885
14886        match events[0].kind {
14887            LayerEventKind::Backdrop(_) => {}
14888            LayerEventKind::Effect(_) => panic!("expected backdrop to run before effect"),
14889        }
14890        match events[1].kind {
14891            LayerEventKind::Effect(_) => {}
14892            LayerEventKind::Backdrop(_) => panic!("expected effect as second event"),
14893        }
14894    }
14895
14896    #[test]
14897    fn collect_layer_events_prefers_outer_effect_when_same_start_z() {
14898        // Child emitted before parent (matching scene collection order where a
14899        // parent effect is recorded after recursively processing children).
14900        let effects = vec![effect_layer(10, 20), effect_layer(10, 40)];
14901        let mut events = Vec::new();
14902        collect_layer_events(&effects, &[], 0, 50, None, &mut events);
14903
14904        assert_eq!(events.len(), 2);
14905        match events[0].kind {
14906            LayerEventKind::Effect(index) => assert_eq!(index, 1),
14907            LayerEventKind::Backdrop(_) => panic!("expected outer effect first"),
14908        }
14909        match events[1].kind {
14910            LayerEventKind::Effect(index) => assert_eq!(index, 0),
14911            LayerEventKind::Backdrop(_) => panic!("expected child effect second"),
14912        }
14913    }
14914
14915    #[test]
14916    fn collect_layer_events_prefers_later_effect_when_ranges_match() {
14917        let effects = vec![effect_layer(10, 20), effect_layer(10, 20)];
14918        let mut events = Vec::new();
14919        collect_layer_events(&effects, &[], 0, 30, None, &mut events);
14920
14921        assert_eq!(events.len(), 2);
14922        match events[0].kind {
14923            LayerEventKind::Effect(index) => assert_eq!(index, 1),
14924            LayerEventKind::Backdrop(_) => panic!("expected later effect first"),
14925        }
14926        match events[1].kind {
14927            LayerEventKind::Effect(index) => assert_eq!(index, 0),
14928            LayerEventKind::Backdrop(_) => panic!("expected earlier effect second"),
14929        }
14930    }
14931
14932    #[test]
14933    fn has_backdrop_layer_in_range_detects_nested_layers() {
14934        let backdrops = vec![backdrop_layer(5), backdrop_layer(15), backdrop_layer(25)];
14935        assert!(has_backdrop_layer_in_range(&backdrops, 10, 20));
14936        assert!(has_backdrop_layer_in_range(&backdrops, 0, 6));
14937        assert!(!has_backdrop_layer_in_range(&backdrops, 20, 25));
14938    }
14939
14940    #[test]
14941    fn layer_contains_descendant_backdrop_ignores_self_backdrop() {
14942        let mut self_backdrop = test_layer(
14943            Rect {
14944                x: 0.0,
14945                y: 0.0,
14946                width: 10.0,
14947                height: 10.0,
14948            },
14949            vec![],
14950        );
14951        self_backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
14952        assert!(!layer_contains_descendant_backdrop(&self_backdrop));
14953
14954        let mut child = test_layer(
14955            Rect {
14956                x: 0.0,
14957                y: 0.0,
14958                width: 8.0,
14959                height: 8.0,
14960            },
14961            vec![],
14962        );
14963        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
14964
14965        let parent = test_layer(
14966            Rect {
14967                x: 0.0,
14968                y: 0.0,
14969                width: 20.0,
14970                height: 20.0,
14971            },
14972            vec![RenderNode::Layer(Box::new(child))],
14973        );
14974        assert!(layer_contains_descendant_backdrop(&parent));
14975    }
14976
14977    fn child_layer_composite(
14978        layer: &LayerNode,
14979        z_index: usize,
14980        rect: Rect,
14981        needs_nested_underlay: bool,
14982    ) -> crate::normalized_scene::ChildLayerComposite {
14983        let mut requirements_cache = cranpose_core::collections::map::HashMap::new();
14984        let surface_requirements =
14985            crate::surface_plan::layer_surface_requirements_cached(layer, &mut requirements_cache);
14986        crate::normalized_scene::ChildLayerComposite {
14987            z_index,
14988            logical_rect: Rect {
14989                x: 0.0,
14990                y: 0.0,
14991                width: rect.width,
14992                height: rect.height,
14993            },
14994            dest_quad: rect_to_quad(rect),
14995            snap_anchor: None,
14996            composite_snap_origin: None,
14997            backdrop_rect: rect,
14998            visual_clip: None,
14999            surface_clip: None,
15000            shadow_draws: Vec::new(),
15001            needs_nested_underlay,
15002            node_id: layer.node_id,
15003            backdrop: layer.backdrop().cloned(),
15004            has_effect: layer.effect().is_some(),
15005            effect_contains_runtime_shader: layer
15006                .effect()
15007                .is_some_and(|effect| effect.contains_runtime_shader()),
15008            target_content_hash: layer.target_content_hash(),
15009            effect_hash: layer.effect_hash(),
15010            motion_source_content_hash: Some(layer.motion_source_content_hash()),
15011            contains_descendant_backdrop: layer_contains_descendant_backdrop(layer),
15012            cache_policy: layer.cache_policy,
15013            surface_requirements,
15014            rounded_clip: crate::surface_executor::backend::LayerSurfaceRoundedClip::from_layer(
15015                layer,
15016            ),
15017            isolation: cranpose_render_common::layer_composition::effective_layer_isolation(
15018                &layer.graphics_layer,
15019            ),
15020            translated_content_context: layer.translated_content_context,
15021            own_translated_content_axes: crate::surface_plan::translated_content_axes_for_layer(
15022                layer,
15023            ),
15024            clip_rect: layer.clip_rect(),
15025            local_bounds: layer.local_bounds,
15026            surface_scale: crate::surface_plan::layer_surface_scale(layer),
15027            source: crate::normalized_scene::LoweredChildSource::default(),
15028        }
15029    }
15030
15031    #[test]
15032    fn root_direct_preflight_allows_first_translated_child_underlay() {
15033        let child = test_layer(
15034            Rect {
15035                x: 0.0,
15036                y: 0.0,
15037                width: 400.0,
15038                height: 280.0,
15039            },
15040            vec![],
15041        );
15042        let collected = CollectedLayer {
15043            scene: CompositorScene::new(),
15044            child_layers: vec![child_layer_composite(
15045                &child,
15046                3,
15047                Rect {
15048                    x: 48.0,
15049                    y: 96.0,
15050                    width: 400.0,
15051                    height: 280.0,
15052                },
15053                true,
15054            )],
15055        };
15056
15057        assert!(direct_root_child_underlays_are_supported(&collected));
15058    }
15059
15060    #[test]
15061    fn root_direct_preflight_allows_axis_aligned_prior_child_underlay() {
15062        let first = test_layer(
15063            Rect {
15064                x: 0.0,
15065                y: 0.0,
15066                width: 80.0,
15067                height: 40.0,
15068            },
15069            vec![],
15070        );
15071        let backdrop_child = test_layer(
15072            Rect {
15073                x: 0.0,
15074                y: 0.0,
15075                width: 400.0,
15076                height: 280.0,
15077            },
15078            vec![],
15079        );
15080        let collected = CollectedLayer {
15081            scene: CompositorScene::new(),
15082            child_layers: vec![
15083                child_layer_composite(
15084                    &first,
15085                    1,
15086                    Rect {
15087                        x: 8.0,
15088                        y: 16.0,
15089                        width: 80.0,
15090                        height: 40.0,
15091                    },
15092                    false,
15093                ),
15094                child_layer_composite(
15095                    &backdrop_child,
15096                    4,
15097                    Rect {
15098                        x: 48.0,
15099                        y: 96.0,
15100                        width: 400.0,
15101                        height: 280.0,
15102                    },
15103                    true,
15104                ),
15105            ],
15106        };
15107
15108        assert!(direct_root_child_underlays_are_supported(&collected));
15109    }
15110
15111    #[test]
15112    fn root_direct_preflight_rejects_effectful_prior_child_underlay() {
15113        let mut first = test_layer(
15114            Rect {
15115                x: 0.0,
15116                y: 0.0,
15117                width: 80.0,
15118                height: 40.0,
15119            },
15120            vec![],
15121        );
15122        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
15123        let backdrop_child = test_layer(
15124            Rect {
15125                x: 0.0,
15126                y: 0.0,
15127                width: 400.0,
15128                height: 280.0,
15129            },
15130            vec![],
15131        );
15132        let collected = CollectedLayer {
15133            scene: CompositorScene::new(),
15134            child_layers: vec![
15135                child_layer_composite(
15136                    &first,
15137                    1,
15138                    Rect {
15139                        x: 64.0,
15140                        y: 112.0,
15141                        width: 80.0,
15142                        height: 40.0,
15143                    },
15144                    false,
15145                ),
15146                child_layer_composite(
15147                    &backdrop_child,
15148                    4,
15149                    Rect {
15150                        x: 48.0,
15151                        y: 96.0,
15152                        width: 400.0,
15153                        height: 280.0,
15154                    },
15155                    true,
15156                ),
15157            ],
15158        };
15159
15160        assert!(!direct_root_child_underlays_are_supported(&collected));
15161    }
15162
15163    #[test]
15164    fn root_direct_preflight_ignores_non_overlapping_effectful_prior_child_underlay() {
15165        let mut first = test_layer(
15166            Rect {
15167                x: 0.0,
15168                y: 0.0,
15169                width: 80.0,
15170                height: 40.0,
15171            },
15172            vec![],
15173        );
15174        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
15175        let backdrop_child = test_layer(
15176            Rect {
15177                x: 0.0,
15178                y: 0.0,
15179                width: 400.0,
15180                height: 280.0,
15181            },
15182            vec![],
15183        );
15184        let collected = CollectedLayer {
15185            scene: CompositorScene::new(),
15186            child_layers: vec![
15187                child_layer_composite(
15188                    &first,
15189                    1,
15190                    Rect {
15191                        x: 8.0,
15192                        y: 16.0,
15193                        width: 80.0,
15194                        height: 40.0,
15195                    },
15196                    false,
15197                ),
15198                child_layer_composite(
15199                    &backdrop_child,
15200                    4,
15201                    Rect {
15202                        x: 48.0,
15203                        y: 96.0,
15204                        width: 400.0,
15205                        height: 280.0,
15206                    },
15207                    true,
15208                ),
15209            ],
15210        };
15211
15212        assert!(direct_root_child_underlays_are_supported(&collected));
15213    }
15214
15215    #[test]
15216    fn root_direct_preflight_rejects_underlay_that_would_replay_prior_scene_effects() {
15217        let backdrop_child = test_layer(
15218            Rect {
15219                x: 0.0,
15220                y: 0.0,
15221                width: 400.0,
15222                height: 280.0,
15223            },
15224            vec![],
15225        );
15226        let mut scene = CompositorScene::new();
15227        scene.next_z = 1;
15228        scene.push_effect_layer(
15229            Rect {
15230                x: 0.0,
15231                y: 0.0,
15232                width: 120.0,
15233                height: 120.0,
15234            },
15235            None,
15236            Some(RenderEffect::blur(2.0)),
15237            BlendMode::SrcOver,
15238            1.0,
15239            0,
15240            1,
15241        );
15242        let collected = CollectedLayer {
15243            scene,
15244            child_layers: vec![child_layer_composite(
15245                &backdrop_child,
15246                4,
15247                Rect {
15248                    x: 48.0,
15249                    y: 96.0,
15250                    width: 400.0,
15251                    height: 280.0,
15252                },
15253                true,
15254            )],
15255        };
15256
15257        assert!(!direct_root_child_underlays_are_supported(&collected));
15258    }
15259
15260    #[test]
15261    fn root_direct_eligibility_does_not_reject_descendant_backdrop() {
15262        let mut backdrop = test_layer(
15263            Rect {
15264                x: 0.0,
15265                y: 0.0,
15266                width: 40.0,
15267                height: 40.0,
15268            },
15269            vec![],
15270        );
15271        backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(4.0));
15272        let child = test_layer(
15273            Rect {
15274                x: 0.0,
15275                y: 0.0,
15276                width: 120.0,
15277                height: 96.0,
15278            },
15279            vec![RenderNode::Layer(Box::new(backdrop))],
15280        );
15281        let root = test_layer(
15282            Rect {
15283                x: 0.0,
15284                y: 0.0,
15285                width: 240.0,
15286                height: 160.0,
15287            },
15288            vec![RenderNode::Layer(Box::new(child))],
15289        );
15290        let mut cache = HashMap::new();
15291
15292        assert!(root_can_render_directly_cached(&root, &mut cache));
15293    }
15294
15295    #[test]
15296    fn root_direct_scene_events_allow_root_local_effects() {
15297        let mut scene = CompositorScene::new();
15298        scene.effect_layers.push(EffectLayer {
15299            rect: Rect {
15300                x: 20.0,
15301                y: 30.0,
15302                width: 120.0,
15303                height: 80.0,
15304            },
15305            clip: None,
15306            snap_anchor: None,
15307            effect: Some(RenderEffect::blur(6.0)),
15308            blend_mode: BlendMode::SrcOver,
15309            composite_alpha: 1.0,
15310            z_start: 0,
15311            z_end: 1,
15312            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
15313        });
15314
15315        assert!(root_direct_scene_events_are_supported(&scene));
15316    }
15317
15318    #[test]
15319    fn root_direct_scene_events_reject_root_local_backdrops() {
15320        let mut scene = CompositorScene::new();
15321        scene.backdrop_layers.push(BackdropLayer {
15322            node_id: Some(99),
15323            rect: Rect {
15324                x: 20.0,
15325                y: 30.0,
15326                width: 120.0,
15327                height: 80.0,
15328            },
15329            clip: None,
15330            snap_anchor: None,
15331            effect: RenderEffect::blur(6.0),
15332            z_index: 1,
15333        });
15334
15335        assert!(!root_direct_scene_events_are_supported(&scene));
15336    }
15337
15338    #[test]
15339    fn estimate_layer_surface_rect_includes_transformed_child_bounds() {
15340        let mut child = test_layer(
15341            Rect {
15342                x: 0.0,
15343                y: 0.0,
15344                width: 10.0,
15345                height: 6.0,
15346            },
15347            vec![RenderNode::Primitive(PrimitiveEntry {
15348                phase: PrimitivePhase::BeforeChildren,
15349                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15350                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15351                        rect: Rect {
15352                            x: 0.0,
15353                            y: 0.0,
15354                            width: 10.0,
15355                            height: 6.0,
15356                        },
15357                        brush: Brush::solid(Color::WHITE),
15358                        stroke: None,
15359                    },
15360                    clip: None,
15361                }),
15362            })],
15363        );
15364        child.transform_to_parent = ProjectiveTransform::translation(18.0, 7.0);
15365
15366        let parent = test_layer(
15367            Rect {
15368                x: 0.0,
15369                y: 0.0,
15370                width: 4.0,
15371                height: 4.0,
15372            },
15373            vec![RenderNode::Layer(Box::new(child))],
15374        );
15375
15376        assert_eq!(
15377            estimate_layer_surface_rect(&parent),
15378            Rect {
15379                x: 18.0,
15380                y: 7.0,
15381                width: 10.0,
15382                height: 6.0,
15383            }
15384        );
15385    }
15386
15387    #[test]
15388    fn estimate_layer_surface_rect_clips_translated_clip_layers_without_hidden_leading_content() {
15389        let mut layer = test_layer(
15390            Rect {
15391                x: 0.0,
15392                y: 0.0,
15393                width: 120.0,
15394                height: 72.0,
15395            },
15396            vec![RenderNode::Primitive(PrimitiveEntry {
15397                phase: PrimitivePhase::BeforeChildren,
15398                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15399                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15400                        rect: Rect {
15401                            x: 24.0,
15402                            y: 0.0,
15403                            width: 200.0,
15404                            height: 480.0,
15405                        },
15406                        brush: Brush::solid(Color::WHITE),
15407                        stroke: None,
15408                    },
15409                    clip: None,
15410                }),
15411            })],
15412        );
15413        layer.translated_content_context = true;
15414        layer.motion_context_animated = true;
15415        layer.clip_to_bounds = true;
15416
15417        assert_eq!(
15418            estimate_layer_surface_rect(&layer),
15419            Rect {
15420                x: 24.0,
15421                y: 0.0,
15422                width: 96.0,
15423                height: 72.0,
15424            }
15425        );
15426    }
15427
15428    #[test]
15429    fn estimate_layer_surface_rect_clips_active_horizontal_scroll_content() {
15430        let mut layer = test_layer(
15431            Rect {
15432                x: 0.0,
15433                y: 0.0,
15434                width: 120.0,
15435                height: 72.0,
15436            },
15437            vec![RenderNode::Primitive(PrimitiveEntry {
15438                phase: PrimitivePhase::BeforeChildren,
15439                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15440                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15441                        rect: Rect {
15442                            x: -24.0,
15443                            y: 0.0,
15444                            width: 200.0,
15445                            height: 480.0,
15446                        },
15447                        brush: Brush::solid(Color::WHITE),
15448                        stroke: None,
15449                    },
15450                    clip: None,
15451                }),
15452            })],
15453        );
15454        layer.translated_content_context = true;
15455        layer.motion_context_animated = true;
15456        layer.clip_to_bounds = true;
15457
15458        assert_eq!(
15459            estimate_layer_surface_rect(&layer),
15460            Rect {
15461                x: 0.0,
15462                y: 0.0,
15463                width: 120.0,
15464                height: 72.0,
15465            }
15466        );
15467    }
15468
15469    #[test]
15470    fn estimate_layer_surface_rect_clips_active_vertical_scroll_content() {
15471        let mut layer = test_layer(
15472            Rect {
15473                x: 0.0,
15474                y: 0.0,
15475                width: 120.0,
15476                height: 72.0,
15477            },
15478            vec![RenderNode::Primitive(PrimitiveEntry {
15479                phase: PrimitivePhase::BeforeChildren,
15480                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15481                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15482                        rect: Rect {
15483                            x: 0.0,
15484                            y: -24.0,
15485                            width: 120.0,
15486                            height: 200.0,
15487                        },
15488                        brush: Brush::solid(Color::WHITE),
15489                        stroke: None,
15490                    },
15491                    clip: None,
15492                }),
15493            })],
15494        );
15495        layer.translated_content_context = true;
15496        layer.motion_context_animated = true;
15497        layer.clip_to_bounds = true;
15498
15499        assert_eq!(
15500            estimate_layer_surface_rect(&layer),
15501            Rect {
15502                x: 0.0,
15503                y: 0.0,
15504                width: 120.0,
15505                height: 72.0,
15506            }
15507        );
15508    }
15509
15510    #[test]
15511    fn estimate_layer_surface_rect_keeps_shallow_scroll_capture_origin_stable() {
15512        fn shallow_scroll_surface_rect(content_y: f32) -> Rect {
15513            let mut layer = test_layer(
15514                Rect {
15515                    x: 0.0,
15516                    y: 0.0,
15517                    width: 120.0,
15518                    height: 72.0,
15519                },
15520                vec![RenderNode::Primitive(PrimitiveEntry {
15521                    phase: PrimitivePhase::BeforeChildren,
15522                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
15523                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15524                            rect: Rect {
15525                                x: 0.0,
15526                                y: content_y,
15527                                width: 120.0,
15528                                height: 200.0,
15529                            },
15530                            brush: Brush::solid(Color::WHITE),
15531                            stroke: None,
15532                        },
15533                        clip: None,
15534                    }),
15535                })],
15536            );
15537            layer.translated_content_context = true;
15538            layer.motion_context_animated = true;
15539            layer.clip_to_bounds = true;
15540            estimate_layer_surface_rect(&layer)
15541        }
15542
15543        assert_eq!(
15544            shallow_scroll_surface_rect(-24.0),
15545            shallow_scroll_surface_rect(-25.0),
15546            "shallow scroll capture bounds must not move the offscreen surface origin on adjacent scroll positions"
15547        );
15548    }
15549
15550    #[test]
15551    fn estimate_layer_surface_rect_clips_active_xy_scroll_content() {
15552        let mut layer = test_layer(
15553            Rect {
15554                x: 0.0,
15555                y: 0.0,
15556                width: 120.0,
15557                height: 72.0,
15558            },
15559            vec![RenderNode::Primitive(PrimitiveEntry {
15560                phase: PrimitivePhase::BeforeChildren,
15561                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15562                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15563                        rect: Rect {
15564                            x: -16.0,
15565                            y: -24.0,
15566                            width: 180.0,
15567                            height: 240.0,
15568                        },
15569                        brush: Brush::solid(Color::WHITE),
15570                        stroke: None,
15571                    },
15572                    clip: None,
15573                }),
15574            })],
15575        );
15576        layer.translated_content_context = true;
15577        layer.motion_context_animated = true;
15578        layer.clip_to_bounds = true;
15579
15580        assert_eq!(
15581            estimate_layer_surface_rect(&layer),
15582            Rect {
15583                x: 0.0,
15584                y: 0.0,
15585                width: 120.0,
15586                height: 72.0,
15587            }
15588        );
15589    }
15590
15591    #[test]
15592    fn estimate_layer_surface_rect_clips_deep_hidden_active_scroll_content() {
15593        let mut layer = test_layer(
15594            Rect {
15595                x: 0.0,
15596                y: 0.0,
15597                width: 120.0,
15598                height: 72.0,
15599            },
15600            vec![RenderNode::Primitive(PrimitiveEntry {
15601                phase: PrimitivePhase::BeforeChildren,
15602                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15603                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15604                        rect: Rect {
15605                            x: 0.0,
15606                            y: -1200.0,
15607                            width: 120.0,
15608                            height: 1400.0,
15609                        },
15610                        brush: Brush::solid(Color::WHITE),
15611                        stroke: None,
15612                    },
15613                    clip: None,
15614                }),
15615            })],
15616        );
15617        layer.translated_content_context = true;
15618        layer.motion_context_animated = true;
15619        layer.clip_to_bounds = true;
15620
15621        assert_eq!(
15622            estimate_layer_surface_rect(&layer),
15623            Rect {
15624                x: 0.0,
15625                y: 0.0,
15626                width: 120.0,
15627                height: 72.0,
15628            }
15629        );
15630    }
15631
15632    #[test]
15633    fn estimate_layer_surface_rect_keeps_deep_scroll_capture_origin_stable() {
15634        fn deep_scroll_surface_rect(content_y: f32) -> Rect {
15635            let mut layer = test_layer(
15636                Rect {
15637                    x: 0.0,
15638                    y: 0.0,
15639                    width: 120.0,
15640                    height: 72.0,
15641                },
15642                vec![RenderNode::Primitive(PrimitiveEntry {
15643                    phase: PrimitivePhase::BeforeChildren,
15644                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
15645                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15646                            rect: Rect {
15647                                x: 0.0,
15648                                y: content_y,
15649                                width: 120.0,
15650                                height: 1400.0,
15651                            },
15652                            brush: Brush::solid(Color::WHITE),
15653                            stroke: None,
15654                        },
15655                        clip: None,
15656                    }),
15657                })],
15658            );
15659            layer.translated_content_context = true;
15660            layer.motion_context_animated = true;
15661            layer.clip_to_bounds = true;
15662            estimate_layer_surface_rect(&layer)
15663        }
15664
15665        assert_eq!(
15666            deep_scroll_surface_rect(-1200.0),
15667            deep_scroll_surface_rect(-1201.0),
15668            "deep scroll capture bounds must not re-phase the offscreen surface origin on adjacent scroll positions"
15669        );
15670    }
15671
15672    #[test]
15673    fn motion_stable_capture_bounds_bounds_shadows_for_clipped_effect_layer() {
15674        let mut layer = test_layer(
15675            Rect {
15676                x: 0.0,
15677                y: 0.0,
15678                width: 120.0,
15679                height: 72.0,
15680            },
15681            vec![],
15682        );
15683        layer.clip_to_bounds = true;
15684        layer.graphics_layer.clip = true;
15685        layer.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
15686
15687        let mut shadow_shape = test_shape(0, BlendMode::SrcOver);
15688        shadow_shape.rect = Rect {
15689            x: -24.0,
15690            y: -1200.0,
15691            width: 180.0,
15692            height: 1400.0,
15693        };
15694        let mut scene = CompositorScene::new();
15695        scene
15696            .shadow_draws
15697            .push(test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]));
15698
15699        let requirements = SurfaceRequirementSet::default()
15700            .with(SurfaceRequirement::RenderEffect)
15701            .with(SurfaceRequirement::MotionStableCapture);
15702
15703        assert_eq!(
15704            motion_stable_capture_bounds(
15705                &layer,
15706                &scene,
15707                &[],
15708                requirements,
15709                TranslatedContentAxes::default(),
15710                None,
15711            ),
15712            Some(Rect {
15713                x: -360.0,
15714                y: -216.0,
15715                width: 480.0,
15716                height: 288.0,
15717            })
15718        );
15719    }
15720
15721    #[test]
15722    fn vertical_motion_stable_capture_uses_viewport_cross_axis_bounds() {
15723        let mut layer = test_layer(
15724            Rect {
15725                x: 0.0,
15726                y: 0.0,
15727                width: 200.0,
15728                height: 100.0,
15729            },
15730            vec![],
15731        );
15732        layer.clip_to_bounds = true;
15733        layer.graphics_layer.clip = true;
15734
15735        let mut shape = test_shape(0, BlendMode::SrcOver);
15736        shape.rect = Rect {
15737            x: 60.0,
15738            y: -80.0,
15739            width: 80.0,
15740            height: 220.0,
15741        };
15742        let mut scene = CompositorScene::new();
15743        scene.shapes.push(shape);
15744
15745        let requirements =
15746            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
15747
15748        assert_eq!(
15749            motion_stable_capture_bounds(
15750                &layer,
15751                &scene,
15752                &[],
15753                requirements,
15754                TranslatedContentAxes { x: false, y: true },
15755                None,
15756            ),
15757            Some(Rect {
15758                x: -96.0,
15759                y: -64.0,
15760                width: 296.0,
15761                height: 164.0,
15762            })
15763        );
15764    }
15765
15766    #[test]
15767    fn vertical_motion_stable_capture_uses_external_surface_clip() {
15768        let layer = test_layer(
15769            Rect {
15770                x: 0.0,
15771                y: 0.0,
15772                width: 200.0,
15773                height: 100.0,
15774            },
15775            vec![],
15776        );
15777
15778        let mut shape = test_shape(0, BlendMode::SrcOver);
15779        shape.rect = Rect {
15780            x: 60.0,
15781            y: -80.0,
15782            width: 80.0,
15783            height: 220.0,
15784        };
15785        let mut scene = CompositorScene::new();
15786        scene.shapes.push(shape);
15787
15788        let requirements =
15789            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
15790
15791        assert_eq!(
15792            motion_stable_capture_bounds(
15793                &layer,
15794                &scene,
15795                &[],
15796                requirements,
15797                TranslatedContentAxes { x: false, y: true },
15798                Some(Rect {
15799                    x: 0.0,
15800                    y: 0.0,
15801                    width: 200.0,
15802                    height: 100.0,
15803                }),
15804            ),
15805            Some(Rect {
15806                x: -96.0,
15807                y: -64.0,
15808                width: 296.0,
15809                height: 164.0,
15810            })
15811        );
15812    }
15813
15814    #[test]
15815    fn estimate_layer_surface_rect_expands_for_child_layer_shadow() {
15816        let mut child = test_layer(
15817            Rect {
15818                x: 0.0,
15819                y: 0.0,
15820                width: 12.0,
15821                height: 8.0,
15822            },
15823            vec![],
15824        );
15825        child.transform_to_parent = ProjectiveTransform::translation(20.0, 9.0);
15826        child.graphics_layer.shadow_elevation = 6.0;
15827
15828        let parent = test_layer(
15829            Rect {
15830                x: 0.0,
15831                y: 0.0,
15832                width: 4.0,
15833                height: 4.0,
15834            },
15835            vec![RenderNode::Layer(Box::new(child))],
15836        );
15837
15838        let rect = estimate_layer_surface_rect(&parent);
15839        assert!(rect.x < 20.0);
15840        assert!(rect.y < 9.0);
15841        assert!(rect.width > 12.0);
15842        assert!(rect.height > 8.0);
15843    }
15844
15845    #[test]
15846    fn estimate_layer_surface_rect_respects_local_bounds_for_effect_layers() {
15847        let mut layer = test_layer(
15848            Rect {
15849                x: 0.0,
15850                y: 0.0,
15851                width: 28.0,
15852                height: 28.0,
15853            },
15854            vec![RenderNode::Primitive(PrimitiveEntry {
15855                phase: PrimitivePhase::BeforeChildren,
15856                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15857                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15858                        rect: Rect {
15859                            x: 10.0,
15860                            y: 10.0,
15861                            width: 10.0,
15862                            height: 10.0,
15863                        },
15864                        brush: Brush::solid(Color::WHITE),
15865                        stroke: None,
15866                    },
15867                    clip: None,
15868                }),
15869            })],
15870        );
15871        layer.graphics_layer.render_effect = Some(RenderEffect::blur(12.0));
15872
15873        assert_eq!(
15874            estimate_layer_surface_rect(&layer),
15875            Rect {
15876                x: 0.0,
15877                y: 0.0,
15878                width: 28.0,
15879                height: 28.0,
15880            }
15881        );
15882    }
15883
15884    #[test]
15885    fn layer_raster_cache_candidate_ignores_parent_transform() {
15886        let primitive = PrimitiveEntry {
15887            phase: PrimitivePhase::BeforeChildren,
15888            node: PrimitiveNode::Draw(DrawPrimitiveNode {
15889                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15890                    rect: Rect {
15891                        x: 2.0,
15892                        y: 3.0,
15893                        width: 6.0,
15894                        height: 4.0,
15895                    },
15896                    brush: Brush::solid(Color::BLACK),
15897                    stroke: None,
15898                },
15899                clip: None,
15900            }),
15901        };
15902        let base = cacheable_layer(
15903            41,
15904            Rect {
15905                x: 0.0,
15906                y: 0.0,
15907                width: 20.0,
15908                height: 20.0,
15909            },
15910            vec![RenderNode::Primitive(primitive.clone())],
15911        );
15912        let mut moved = base.clone();
15913        moved.transform_to_parent = ProjectiveTransform::translation(32.0, 18.0);
15914
15915        assert_eq!(
15916            layer_raster_cache_candidate(&base, 1.25, false, false),
15917            layer_raster_cache_candidate(&moved, 1.25, false, false)
15918        );
15919    }
15920
15921    #[test]
15922    fn layer_raster_cache_candidate_changes_for_translated_content_offset() {
15923        let primitive = PrimitiveEntry {
15924            phase: PrimitivePhase::BeforeChildren,
15925            node: PrimitiveNode::Draw(DrawPrimitiveNode {
15926                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15927                    rect: Rect {
15928                        x: 2.0,
15929                        y: 3.0,
15930                        width: 6.0,
15931                        height: 4.0,
15932                    },
15933                    brush: Brush::solid(Color::BLACK),
15934                    stroke: None,
15935                },
15936                clip: None,
15937            }),
15938        };
15939        let mut base = cacheable_layer(
15940            42,
15941            Rect {
15942                x: 0.0,
15943                y: 0.0,
15944                width: 20.0,
15945                height: 20.0,
15946            },
15947            vec![RenderNode::Primitive(primitive)],
15948        );
15949        base.translated_content_context = true;
15950        base.translated_content_offset = Point::new(0.0, -8.0);
15951        base.recompute_raster_cache_hashes();
15952
15953        let mut moved = base.clone();
15954        moved.translated_content_offset = Point::new(0.0, -16.0);
15955        moved.recompute_raster_cache_hashes();
15956
15957        assert_ne!(
15958            layer_raster_cache_candidate(&base, 1.25, false, false),
15959            layer_raster_cache_candidate(&moved, 1.25, false, false),
15960            "full-surface layer cache candidates must not alias different scroll offsets"
15961        );
15962    }
15963
15964    #[test]
15965    fn layer_raster_cache_candidate_changes_for_child_transform() {
15966        let mut child = cacheable_layer(
15967            8,
15968            Rect {
15969                x: 0.0,
15970                y: 0.0,
15971                width: 12.0,
15972                height: 10.0,
15973            },
15974            vec![],
15975        );
15976        child.transform_to_parent = ProjectiveTransform::translation(4.0, 6.0);
15977        let base = cacheable_layer(
15978            7,
15979            Rect {
15980                x: 0.0,
15981                y: 0.0,
15982                width: 20.0,
15983                height: 20.0,
15984            },
15985            vec![RenderNode::Layer(Box::new(child.clone()))],
15986        );
15987        let mut moved_child = child;
15988        moved_child.transform_to_parent = ProjectiveTransform::translation(9.0, 6.0);
15989        let moved = cacheable_layer(
15990            7,
15991            Rect {
15992                x: 0.0,
15993                y: 0.0,
15994                width: 20.0,
15995                height: 20.0,
15996            },
15997            vec![RenderNode::Layer(Box::new(moved_child))],
15998        );
15999
16000        assert_ne!(
16001            layer_raster_cache_candidate(&base, 1.0, false, false),
16002            layer_raster_cache_candidate(&moved, 1.0, false, false)
16003        );
16004    }
16005
16006    #[test]
16007    fn layer_raster_cache_candidate_rejects_external_backdrop_dependency() {
16008        let mut child = cacheable_layer(
16009            12,
16010            Rect {
16011                x: 0.0,
16012                y: 0.0,
16013                width: 8.0,
16014                height: 8.0,
16015            },
16016            vec![],
16017        );
16018        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
16019        let parent = cacheable_layer(
16020            11,
16021            Rect {
16022                x: 0.0,
16023                y: 0.0,
16024                width: 16.0,
16025                height: 16.0,
16026            },
16027            vec![RenderNode::Layer(Box::new(child))],
16028        );
16029
16030        assert!(layer_raster_cache_candidate(&parent, 1.0, false, false).is_some());
16031        assert!(layer_raster_cache_candidate(&parent, 1.0, true, false).is_none());
16032    }
16033
16034    #[test]
16035    fn layer_raster_cache_candidate_does_not_force_translation_only_text_surfaces() {
16036        let text = RenderNode::Primitive(PrimitiveEntry {
16037            phase: PrimitivePhase::BeforeChildren,
16038            node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
16039                node_id: 77,
16040                rect: Rect {
16041                    x: 2.0,
16042                    y: 3.0,
16043                    width: 48.0,
16044                    height: 18.0,
16045                },
16046                text: std::rc::Rc::new(AnnotatedString::from("runtime cache")),
16047                text_style: TextStyle::default(),
16048                font_size: 14.0,
16049                layout_options: TextLayoutOptions::default(),
16050                clip: None,
16051            })),
16052        });
16053        let mut layer = test_layer(
16054            Rect {
16055                x: 0.0,
16056                y: 0.0,
16057                width: 64.0,
16058                height: 32.0,
16059            },
16060            vec![text],
16061        );
16062        layer.node_id = Some(77);
16063        layer.recompute_raster_cache_hashes();
16064
16065        assert!(
16066            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
16067            "root path should not isolate plain translation-only text layers"
16068        );
16069        assert!(
16070            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
16071            "child path should also render plain translation-only text layers directly"
16072        );
16073    }
16074
16075    #[test]
16076    fn layer_raster_cache_candidate_allows_stable_runtime_child_effect_surfaces() {
16077        let mut layer = test_layer(
16078            Rect {
16079                x: 0.0,
16080                y: 0.0,
16081                width: 64.0,
16082                height: 32.0,
16083            },
16084            vec![RenderNode::Primitive(PrimitiveEntry {
16085                phase: PrimitivePhase::BeforeChildren,
16086                node: PrimitiveNode::Draw(DrawPrimitiveNode {
16087                    primitive: DrawPrimitive::Rect {
16088                        rect: Rect {
16089                            x: 0.0,
16090                            y: 0.0,
16091                            width: 64.0,
16092                            height: 32.0,
16093                        },
16094                        brush: Brush::solid(Color::WHITE),
16095                        stroke: None,
16096                    },
16097                    clip: None,
16098                }),
16099            })],
16100        );
16101        layer.node_id = Some(78);
16102        layer.graphics_layer.render_effect = Some(RenderEffect::blur(4.0));
16103        layer.recompute_raster_cache_hashes();
16104
16105        assert!(
16106            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
16107            "root direct path should not force-cache ordinary stable effects"
16108        );
16109        assert!(
16110            layer_raster_cache_candidate(&layer, 1.0, false, true).is_some(),
16111            "child surface rendering should retain stable non-runtime effects"
16112        );
16113    }
16114
16115    #[test]
16116    fn layer_raster_cache_candidate_rejects_runtime_shader_child_effect_surfaces() {
16117        let mut layer = test_layer(
16118            Rect {
16119                x: 0.0,
16120                y: 0.0,
16121                width: 64.0,
16122                height: 32.0,
16123            },
16124            vec![],
16125        );
16126        layer.node_id = Some(79);
16127        layer.graphics_layer.render_effect = Some(RenderEffect::runtime_shader(
16128            RuntimeShader::new("runtime shader"),
16129        ));
16130        layer.recompute_raster_cache_hashes();
16131
16132        assert!(
16133            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
16134            "runtime shaders must not fill the retained layer cache with per-frame uniform variants"
16135        );
16136    }
16137
16138    #[test]
16139    fn layer_surface_requirements_keep_plain_text_on_direct_path() {
16140        let layer = text_layer_with_style(AnnotatedString::from("plain"), TextStyle::default());
16141
16142        let requirements = layer_surface_requirements(&layer);
16143
16144        assert_eq!(requirements.direct_translation, Some(Point::default()));
16145        assert!(requirements
16146            .surface_requirements
16147            .contains(SurfaceRequirement::PixelStableComposite));
16148        assert!(!requirements
16149            .surface_requirements
16150            .has_isolating_requirement());
16151    }
16152
16153    #[test]
16154    fn layer_surface_requirements_keep_translated_plain_text_leaf_on_direct_path() {
16155        let layer = pure_text_leaf(false, true);
16156
16157        let requirements = layer_surface_requirements(&layer);
16158
16159        assert_eq!(
16160            requirements.direct_translation,
16161            Some(Point::new(11.4, 23.6))
16162        );
16163        assert!(
16164            requirements
16165                .surface_requirements
16166                .contains(SurfaceRequirement::PixelStableComposite)
16167                && !requirements
16168                    .surface_requirements
16169                    .has_isolating_requirement(),
16170            "translated plain text should stay on the direct path and isolate only the glyph draw"
16171        );
16172    }
16173
16174    #[test]
16175    fn layer_surface_requirements_keep_translated_text_leaf_with_background_on_direct_path() {
16176        let layer = snapped_text_leaf(false, true);
16177
16178        let requirements = layer_surface_requirements(&layer);
16179
16180        assert_eq!(
16181            requirements.direct_translation,
16182            Some(Point::new(14.25, 16.5))
16183        );
16184        assert!(
16185            requirements
16186                .surface_requirements
16187                .contains(SurfaceRequirement::PixelStableComposite)
16188                && !requirements
16189                    .surface_requirements
16190                    .has_isolating_requirement(),
16191            "translated text with direct sibling decoration/background should keep the layer direct"
16192        );
16193    }
16194
16195    #[test]
16196    fn translated_plain_text_uses_bounded_snap_surface() {
16197        let root = pure_text_leaf_root(true, true);
16198        let mut rect_cache = HashMap::new();
16199        let mut requirements_cache = HashMap::new();
16200        let collected =
16201            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16202
16203        assert_eq!(collected.child_layers.len(), 1);
16204        assert!(collected.scene.texts.is_empty());
16205        assert!(collected.scene.effect_layers.is_empty());
16206        assert_snap_anchor_close(
16207            collected.child_layers[0].snap_anchor,
16208            Point::new(11.4, 23.6),
16209            "translated plain text's bounded local surface should composite at the content-origin snap phase",
16210        );
16211    }
16212
16213    /// Not a correctness test: a local timing harness for the shape-run
16214    /// collect path. Run manually with
16215    /// `cargo test --release -p cranpose-render-wgpu -- --ignored collect_timing --nocapture`.
16216    #[test]
16217    #[ignore]
16218    fn shape_run_collect_timing_harness() {
16219        use cranpose_render_common::graph::DrawPrimitiveNode;
16220        use cranpose_render_common::layer_composition::local_content_layer_for;
16221        use cranpose_ui_graphics::Stroke;
16222
16223        let bounds = Rect {
16224            x: 0.0,
16225            y: 0.0,
16226            width: 1080.0,
16227            height: 2244.0,
16228        };
16229        let graphics_layer = GraphicsLayer::default();
16230
16231        // A MEGA-BOSS-shaped workload: thousands of consecutive arcs, most
16232        // solid, some gradient, one text-free layer.
16233        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
16234        for i in 0..3000u32 {
16235            let f = i as f32;
16236            let brush = if i % 8 == 0 {
16237                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
16238            } else {
16239                Brush::Solid(Color(0.5, 0.2, 0.8, 1.0))
16240            };
16241            let center = Point::new(540.0 + (f % 400.0), 1122.0 + (f % 350.0));
16242            let radius = 8.0 + (i % 23) as f32;
16243            let half = radius + 4.0;
16244            nodes.push(DrawPrimitiveNode {
16245                primitive: DrawPrimitive::Arc {
16246                    rect: Rect {
16247                        x: center.x - half,
16248                        y: center.y - half,
16249                        width: half * 2.0,
16250                        height: half * 2.0,
16251                    },
16252                    brush,
16253                    center,
16254                    radius,
16255                    start_angle: f * 0.07,
16256                    sweep_angle: 0.5 + (i % 5) as f32,
16257                    stroke: (i % 3 != 0).then(|| Stroke::new(4.0)),
16258                    inner_radius: if i % 3 == 0 { radius * 0.6 } else { 0.0 },
16259                },
16260                clip: None,
16261            });
16262        }
16263
16264        let children: Vec<RenderNode> = nodes
16265            .iter()
16266            .map(|node| {
16267                RenderNode::Primitive(PrimitiveEntry {
16268                    phase: PrimitivePhase::BeforeChildren,
16269                    node: PrimitiveNode::Draw(node.clone()),
16270                })
16271            })
16272            .collect();
16273        let layer = crate::test_support::layer_node(
16274            bounds,
16275            ProjectiveTransform::identity(),
16276            graphics_layer,
16277            children,
16278        );
16279
16280        const ITERS: usize = 300;
16281
16282        // Reference: the pre-run per-primitive path.
16283        let local_layer = local_content_layer_for(&layer.graphics_layer);
16284        let start = Instant::now();
16285        let mut sink_shapes = 0usize;
16286        for _ in 0..ITERS {
16287            let mut scene = CompositorScene::new();
16288            for node in &nodes {
16289                crate::pipeline::push_draw_primitive(
16290                    &node.primitive,
16291                    bounds,
16292                    &local_layer,
16293                    None,
16294                    &mut scene,
16295                    None,
16296                    false,
16297                );
16298            }
16299            sink_shapes = scene.shapes.len();
16300        }
16301        let serial = start.elapsed();
16302
16303        let mut rect_cache = HashMap::new();
16304        let mut requirements_cache = HashMap::new();
16305        let start = Instant::now();
16306        let mut run_shapes = 0usize;
16307        for _ in 0..ITERS {
16308            let collected = collect_layer_contents(
16309                &layer,
16310                None,
16311                None,
16312                &mut rect_cache,
16313                &mut requirements_cache,
16314            );
16315            run_shapes = collected.scene.shapes.len();
16316        }
16317        let run = start.elapsed();
16318
16319        println!(
16320            "per-primitive: {:?}/iter ({sink_shapes} shapes)  shape-run: {:?}/iter ({run_shapes} shapes)",
16321            serial / ITERS as u32,
16322            run / ITERS as u32,
16323        );
16324    }
16325
16326    /// Shared body for the serial and forced-parallel equivalence tests:
16327    fn assert_shape_run_collect_matches_per_primitive_emission() {
16328        use cranpose_render_common::graph::DrawPrimitiveNode;
16329        use cranpose_render_common::layer_composition::local_content_layer_for;
16330        use cranpose_render_common::primitive_emit::{resolve_primitive_clip, PrimitiveClipSpace};
16331        use cranpose_ui_graphics::{CornerRadii, Stroke};
16332
16333        let bounds = Rect {
16334            x: 0.0,
16335            y: 0.0,
16336            width: 800.0,
16337            height: 800.0,
16338        };
16339        // Rotation keeps rigid snapping off, so both paths agree on
16340        // `snap_anchor: None` without replicating the anchor computation here.
16341        let graphics_layer = GraphicsLayer {
16342            scale: 1.25,
16343            translation_x: 3.5,
16344            translation_y: -2.0,
16345            alpha: 0.9,
16346            rotation_z: 0.35,
16347            ..GraphicsLayer::default()
16348        };
16349
16350        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
16351        for i in 0..600u32 {
16352            let f = i as f32;
16353            let brush = if i % 11 == 0 {
16354                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
16355            } else {
16356                Brush::Solid(Color(0.1 + (i % 7) as f32 * 0.1, 0.5, 0.9, 1.0))
16357            };
16358            let stroke = (i % 5 == 0).then(|| Stroke::new(1.0 + (i % 3) as f32));
16359            let primitive = match i % 3 {
16360                0 => DrawPrimitive::Rect {
16361                    rect: Rect {
16362                        x: f % 37.0,
16363                        y: f % 53.0,
16364                        width: 8.0 + f % 9.0,
16365                        height: 6.0 + f % 5.0,
16366                    },
16367                    brush,
16368                    stroke,
16369                },
16370                1 => DrawPrimitive::RoundRect {
16371                    rect: Rect {
16372                        x: f % 41.0,
16373                        y: f % 43.0,
16374                        width: 12.0,
16375                        height: 10.0,
16376                    },
16377                    brush,
16378                    radii: CornerRadii::uniform(2.0 + (i % 4) as f32),
16379                    stroke,
16380                },
16381                _ => {
16382                    let center = Point::new(60.0 + f % 71.0, 60.0 + f % 67.0);
16383                    let radius = 5.0 + (i % 13) as f32;
16384                    // One degenerate sweep proves dropped draws stay dropped.
16385                    let sweep_angle = if i == 302 { 0.0 } else { 0.4 + (i % 6) as f32 };
16386                    let half = radius + 4.0;
16387                    DrawPrimitive::Arc {
16388                        rect: Rect {
16389                            x: center.x - half,
16390                            y: center.y - half,
16391                            width: half * 2.0,
16392                            height: half * 2.0,
16393                        },
16394                        brush,
16395                        center,
16396                        radius,
16397                        start_angle: f * 0.11,
16398                        sweep_angle,
16399                        stroke: (i % 2 == 0).then(|| Stroke::new(3.0)),
16400                        inner_radius: if i % 4 == 2 { radius * 0.5 } else { 0.0 },
16401                    }
16402                }
16403            };
16404            let primitive = if i == 300 {
16405                // A nested blend disqualifies the run view and forces a
16406                // mid-run flush through the serial path, splitting 600 draws
16407                // into two runs that are both long enough to fan out.
16408                DrawPrimitive::Blend {
16409                    primitive: Box::new(DrawPrimitive::Blend {
16410                        primitive: Box::new(primitive),
16411                        blend_mode: BlendMode::SrcOver,
16412                    }),
16413                    blend_mode: BlendMode::DstOut,
16414                }
16415            } else if i % 7 == 3 {
16416                DrawPrimitive::Blend {
16417                    primitive: Box::new(primitive),
16418                    blend_mode: BlendMode::DstOut,
16419                }
16420            } else {
16421                primitive
16422            };
16423            let clip = (i % 31 == 7).then_some(Rect {
16424                x: 0.0,
16425                y: 0.0,
16426                width: 30.0,
16427                height: 30.0,
16428            });
16429            nodes.push(DrawPrimitiveNode { primitive, clip });
16430        }
16431
16432        let children: Vec<RenderNode> = nodes
16433            .iter()
16434            .map(|node| {
16435                RenderNode::Primitive(PrimitiveEntry {
16436                    phase: PrimitivePhase::BeforeChildren,
16437                    node: PrimitiveNode::Draw(node.clone()),
16438                })
16439            })
16440            .collect();
16441        let layer = crate::test_support::layer_node(
16442            bounds,
16443            ProjectiveTransform::identity(),
16444            graphics_layer,
16445            children,
16446        );
16447
16448        let mut rect_cache = HashMap::new();
16449        let mut requirements_cache = HashMap::new();
16450        let collected =
16451            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
16452
16453        // The reference scene: every primitive through the per-primitive
16454        // emission path, exactly as the pre-run collect loop ran it.
16455        let local_layer = local_content_layer_for(&layer.graphics_layer);
16456        let mut expected = CompositorScene::new();
16457        for node in &nodes {
16458            let clip = resolve_primitive_clip(
16459                node.clip,
16460                bounds,
16461                &local_layer,
16462                None,
16463                PrimitiveClipSpace::Local,
16464            );
16465            if node.clip.is_some() && clip.is_none() {
16466                continue;
16467            }
16468            crate::pipeline::push_draw_primitive(
16469                &node.primitive,
16470                bounds,
16471                &local_layer,
16472                clip,
16473                &mut expected,
16474                None,
16475                false,
16476            );
16477        }
16478
16479        assert!(
16480            collected.scene.shapes.len() >= 590,
16481            "the runs should engage the parallel branch: got {} shapes",
16482            collected.scene.shapes.len()
16483        );
16484        assert_eq!(collected.scene.shapes.len(), expected.shapes.len());
16485        assert_eq!(collected.scene.draw_ops, expected.draw_ops);
16486        assert_eq!(collected.scene.next_z, expected.next_z);
16487        assert!(
16488            collected
16489                .scene
16490                .shapes
16491                .iter()
16492                .all(|s| s.snap_anchor.is_none()),
16493            "a rotated layer must not rigid-snap; the reference scene assumes it"
16494        );
16495        for (index, (got, want)) in collected
16496            .scene
16497            .shapes
16498            .iter()
16499            .zip(&expected.shapes)
16500            .enumerate()
16501        {
16502            assert_eq!(got.rect, want.rect, "shape {index} rect");
16503            assert_eq!(got.local_rect, want.local_rect, "shape {index} local_rect");
16504            assert_eq!(got.quad, want.quad, "shape {index} quad");
16505            assert_eq!(got.snap_anchor, want.snap_anchor, "shape {index} snap");
16506            assert_eq!(got.brush, want.brush, "shape {index} brush");
16507            assert_eq!(got.shape, want.shape, "shape {index} shape");
16508            assert_eq!(got.stroke, want.stroke, "shape {index} stroke");
16509            assert_eq!(got.arc, want.arc, "shape {index} arc");
16510            assert_eq!(got.z_index, want.z_index, "shape {index} z");
16511            assert_eq!(got.clip, want.clip, "shape {index} clip");
16512            assert_eq!(got.blend_mode, want.blend_mode, "shape {index} blend");
16513            assert_eq!(
16514                got.motion_context_animated, want.motion_context_animated,
16515                "shape {index} motion flag"
16516            );
16517        }
16518    }
16519
16520    /// The run collector must emit exactly what per-primitive emission does,
16521    /// on BOTH flush paths: the serial drain and the scoped-thread fan-out
16522    /// (forced via the tuning override, since a test-sized scene would never
16523    /// cross the size gate on its own).
16524    #[test]
16525    fn shape_run_collect_matches_per_primitive_emission_exactly() {
16526        assert_shape_run_collect_matches_per_primitive_emission();
16527        crate::normalized_scene::force_shape_run_parallel_for_tests(true);
16528        let outcome =
16529            std::panic::catch_unwind(assert_shape_run_collect_matches_per_primitive_emission);
16530        crate::normalized_scene::force_shape_run_parallel_for_tests(false);
16531        if let Err(payload) = outcome {
16532            std::panic::resume_unwind(payload);
16533        }
16534    }
16535
16536    #[test]
16537    fn non_translated_text_local_surface_keeps_linear_composite_resolve() {
16538        let layer = text_layer_with_style(
16539            AnnotatedString::from("gradient"),
16540            TextStyle::from_span_style(SpanStyle {
16541                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
16542                ..SpanStyle::default()
16543            }),
16544        );
16545        let requirements = layer_surface_requirements(&layer);
16546
16547        assert!(requirements
16548            .surface_requirements
16549            .contains(SurfaceRequirement::TextMaterialMask));
16550        assert_eq!(
16551            composite_sample_mode_for_requirements(false, false, requirements),
16552            CompositeSampleMode::Linear
16553        );
16554    }
16555
16556    #[test]
16557    fn inherited_translated_text_local_surface_uses_box4_layer_surface() {
16558        let layer = text_layer_with_style(
16559            AnnotatedString::from("shadow"),
16560            TextStyle::from_span_style(SpanStyle {
16561                shadow: Some(Shadow {
16562                    color: Color::BLACK,
16563                    offset: Point::new(1.0, 2.0),
16564                    blur_radius: 3.0,
16565                }),
16566                ..SpanStyle::default()
16567            }),
16568        );
16569        let requirements = layer_surface_requirements(&layer);
16570
16571        assert!(requirements
16572            .surface_requirements
16573            .contains(SurfaceRequirement::TextMaterialMask));
16574        assert_eq!(
16575            composite_sample_mode_for_requirements(true, false, requirements),
16576            CompositeSampleMode::Box4
16577        );
16578        assert_eq!(
16579            layer_surface_target_scale(
16580                true,
16581                false,
16582                requirements,
16583                1.25,
16584                layer_surface_scale(&layer)
16585            ),
16586            SurfaceRequirementSet::default()
16587                .with(SurfaceRequirement::TextMaterialMask)
16588                .with(SurfaceRequirement::MotionStableCapture)
16589                .target_scale(1.25, 1.0)
16590        );
16591    }
16592
16593    #[test]
16594    fn translated_text_local_surface_inside_capture_keeps_parent_scale() {
16595        let layer = text_layer_with_style(
16596            AnnotatedString::from("shadow"),
16597            TextStyle::from_span_style(SpanStyle {
16598                shadow: Some(Shadow {
16599                    color: Color::BLACK,
16600                    offset: Point::new(1.0, 2.0),
16601                    blur_radius: 3.0,
16602                }),
16603                ..SpanStyle::default()
16604            }),
16605        );
16606        let requirements = layer_surface_requirements(&layer);
16607
16608        assert_eq!(
16609            composite_sample_mode_for_requirements(true, true, requirements),
16610            CompositeSampleMode::Linear
16611        );
16612        assert_eq!(
16613            layer_surface_target_scale(true, true, requirements, 10.0, layer_surface_scale(&layer)),
16614            SurfaceRequirementSet::default()
16615                .with(SurfaceRequirement::TextMaterialMask)
16616                .target_scale(10.0, 1.0)
16617        );
16618    }
16619
16620    #[test]
16621    fn layer_surface_requirements_use_local_surface_for_gradient_and_stroke_text() {
16622        let cases = [
16623            (
16624                "draw_style",
16625                AnnotatedString::from("draw_style"),
16626                TextStyle::from_span_style(SpanStyle {
16627                    draw_style: Some(TextDrawStyle::Stroke { width: 2.0 }),
16628                    ..SpanStyle::default()
16629                }),
16630            ),
16631            (
16632                "gradient_brush",
16633                AnnotatedString::from("gradient"),
16634                TextStyle::from_span_style(SpanStyle {
16635                    brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
16636                    ..SpanStyle::default()
16637                }),
16638            ),
16639        ];
16640
16641        for (label, text, text_style) in cases {
16642            let layer = text_layer_with_style(text, text_style);
16643            let requirements = layer_surface_requirements(&layer);
16644            assert!(
16645                requirements
16646                    .surface_requirements
16647                    .contains(SurfaceRequirement::TextMaterialMask),
16648                "{label} text should use a bounded local surface: {requirements:?}"
16649            );
16650        }
16651    }
16652
16653    #[test]
16654    fn layer_surface_requirements_use_local_surface_for_complex_text_effects() {
16655        let cases = [
16656            (
16657                "shadow",
16658                AnnotatedString::from("shadow"),
16659                TextStyle::from_span_style(SpanStyle {
16660                    shadow: Some(Shadow {
16661                        color: Color::BLACK,
16662                        offset: Point::new(1.0, 2.0),
16663                        blur_radius: 3.0,
16664                    }),
16665                    ..SpanStyle::default()
16666                }),
16667            ),
16668            (
16669                "background",
16670                AnnotatedString::from("background"),
16671                TextStyle::from_span_style(SpanStyle {
16672                    background: Some(Color::BLACK),
16673                    ..SpanStyle::default()
16674                }),
16675            ),
16676            (
16677                "baseline_shift",
16678                AnnotatedString::from("baseline_shift"),
16679                TextStyle::from_span_style(SpanStyle {
16680                    baseline_shift: Some(BaselineShift::SUPERSCRIPT),
16681                    ..SpanStyle::default()
16682                }),
16683            ),
16684            (
16685                "geometric_transform",
16686                AnnotatedString::from("geometric_transform"),
16687                TextStyle::from_span_style(SpanStyle {
16688                    text_geometric_transform: Some(TextGeometricTransform {
16689                        scale_x: 1.2,
16690                        skew_x: 0.15,
16691                    }),
16692                    ..SpanStyle::default()
16693                }),
16694            ),
16695            (
16696                "letter_spacing",
16697                AnnotatedString::from("letter_spacing"),
16698                TextStyle::from_span_style(SpanStyle {
16699                    letter_spacing: TextUnit::Em(0.2),
16700                    ..SpanStyle::default()
16701                }),
16702            ),
16703        ];
16704
16705        for (label, text, text_style) in cases {
16706            let layer = text_layer_with_style(text, text_style);
16707            let requirements = layer_surface_requirements(&layer);
16708            assert!(
16709                requirements
16710                    .surface_requirements
16711                    .contains(SurfaceRequirement::TextMaterialMask),
16712                "{label} text should use a bounded local surface: {requirements:?}"
16713            );
16714            assert_eq!(
16715                requirements.direct_translation,
16716                Some(Point::default()),
16717                "{label} text should still classify as a direct translation"
16718            );
16719        }
16720    }
16721
16722    #[test]
16723    fn layer_surface_requirements_color_only_span_styles_use_direct_path() {
16724        let layer = text_layer_with_style(
16725            AnnotatedString {
16726                text: "styled".to_string(),
16727                span_styles: vec![RangeStyle {
16728                    item: SpanStyle {
16729                        color: Some(Color::BLACK),
16730                        ..SpanStyle::default()
16731                    },
16732                    range: 0..3,
16733                }],
16734                ..AnnotatedString::default()
16735            },
16736            TextStyle::default(),
16737        );
16738        let requirements = layer_surface_requirements(&layer);
16739        assert!(
16740            !requirements
16741                .surface_requirements
16742                .contains(SurfaceRequirement::TextMaterialMask),
16743            "color-only span styles should render directly via software text raster colors"
16744        );
16745    }
16746
16747    #[test]
16748    fn layer_surface_requirements_keep_decoration_only_text_on_direct_path() {
16749        let layer = text_layer_with_style(
16750            AnnotatedString::from("decoration"),
16751            TextStyle::from_span_style(SpanStyle {
16752                text_decoration: Some(TextDecoration::UNDERLINE),
16753                ..SpanStyle::default()
16754            }),
16755        );
16756
16757        let requirements = layer_surface_requirements(&layer);
16758
16759        assert_eq!(requirements.direct_translation, Some(Point::default()));
16760        assert!(
16761            requirements
16762                .surface_requirements
16763                .contains(SurfaceRequirement::PixelStableComposite)
16764                && !requirements
16765                    .surface_requirements
16766                    .has_isolating_requirement(),
16767            "decoration-only text should not force an isolating layer surface: {requirements:?}"
16768        );
16769    }
16770
16771    #[test]
16772    fn direct_text_leaf_snaps_modifier_background_and_text_with_one_anchor() {
16773        let root = snapped_text_leaf_root(false, false);
16774        let mut rect_cache = HashMap::new();
16775        let mut requirements_cache = HashMap::new();
16776
16777        let collected =
16778            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16779
16780        assert_eq!(collected.scene.shapes.len(), 1);
16781        assert_eq!(collected.scene.images.len(), 1);
16782        assert_eq!(collected.scene.texts.len(), 1);
16783        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
16784        assert_eq!(collected.scene.shapes[0].snap_anchor, expected_anchor);
16785        assert_eq!(collected.scene.images[0].snap_anchor, expected_anchor);
16786        assert_eq!(collected.scene.texts[0].snap_anchor, expected_anchor);
16787    }
16788
16789    #[test]
16790    fn animated_translated_content_text_leaf_uses_bounded_content_snap() {
16791        let root = snapped_text_leaf_root(true, true);
16792        let mut rect_cache = HashMap::new();
16793        let mut requirements_cache = HashMap::new();
16794
16795        let collected =
16796            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16797
16798        assert_eq!(collected.child_layers.len(), 1);
16799        assert!(collected.scene.shapes.is_empty());
16800        assert!(collected.scene.images.is_empty());
16801        assert!(collected.scene.texts.is_empty());
16802        assert!(collected.scene.effect_layers.is_empty());
16803        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
16804        assert_eq!(
16805            collected.child_layers[0].snap_anchor, expected_anchor,
16806            "active translated leaf surface should keep the content-origin snap phase"
16807        );
16808    }
16809
16810    #[test]
16811    fn translated_content_assigns_motion_anchor_to_rotated_child_surface() {
16812        let mut child = snapped_text_leaf(false, false);
16813        child.graphics_layer.rotation_z = 5.0;
16814        child.transform_to_parent =
16815            cranpose_render_common::layer_transform::layer_transform_to_parent(
16816                child.local_bounds,
16817                Point::new(108.0, 3.0),
16818                &child.graphics_layer,
16819            );
16820        child.recompute_raster_cache_hashes();
16821        let mut root = test_layer(
16822            Rect {
16823                x: 0.0,
16824                y: 0.0,
16825                width: 320.0,
16826                height: 180.0,
16827            },
16828            vec![RenderNode::Layer(Box::new(child))],
16829        );
16830        root.translated_content_context = true;
16831        root.translated_content_offset = Point::new(0.0, -80.8);
16832        root.recompute_raster_cache_hashes();
16833        let mut rect_cache = HashMap::new();
16834        let mut requirements_cache = HashMap::new();
16835
16836        let collected =
16837            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16838
16839        assert_eq!(collected.child_layers.len(), 1);
16840        assert!(
16841            collected.child_layers[0].snap_anchor.is_some(),
16842            "a projective child still translates rigidly with its scrolling parent"
16843        );
16844    }
16845
16846    #[test]
16847    fn rested_translated_content_context_text_leaf_snaps_for_crisp_scroll_rest() {
16848        let root = snapped_text_leaf_root(false, true);
16849        let mut rect_cache = HashMap::new();
16850        let mut requirements_cache = HashMap::new();
16851
16852        let collected =
16853            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16854
16855        assert_eq!(collected.child_layers.len(), 0);
16856        assert_eq!(collected.scene.shapes.len(), 1);
16857        assert_eq!(collected.scene.images.len(), 1);
16858        assert_eq!(collected.scene.texts.len(), 1);
16859        assert_eq!(collected.scene.effect_layers.len(), 0);
16860        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
16861        assert_eq!(
16862            collected.scene.shapes[0].snap_anchor, expected_anchor,
16863            "rested scroll content should snap back to device pixels"
16864        );
16865        assert_eq!(
16866            collected.scene.images[0].snap_anchor, expected_anchor,
16867            "rested scroll images should snap back to device pixels"
16868        );
16869        assert_eq!(
16870            collected.scene.texts[0].snap_anchor, expected_anchor,
16871            "rested scroll text should snap back to device pixels"
16872        );
16873    }
16874
16875    #[test]
16876    fn complex_text_uses_local_surface() {
16877        let root = translated_content_local_surface_root();
16878        let mut rect_cache = HashMap::new();
16879        let mut requirements_cache = HashMap::new();
16880
16881        let collected =
16882            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16883
16884        assert!(
16885            !collected.child_layers.is_empty(),
16886            "translated-content effectful text should render through a bounded local surface"
16887        );
16888        assert!(collected.scene.texts.is_empty());
16889        assert!(collected.scene.shadow_draws.is_empty());
16890    }
16891
16892    #[test]
16893    fn translated_content_surface_composite_uses_scroll_content_snap_anchor() {
16894        let mut root = translated_content_local_surface_root();
16895        let scroll_offset = Point::new(0.0, -18.5);
16896        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
16897            panic!("expected translated content layer");
16898        };
16899        translated_content.translated_content_offset = scroll_offset;
16900        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
16901            panic!("expected effectful text layer");
16902        };
16903        effectful_text.transform_to_parent =
16904            effectful_text
16905                .transform_to_parent
16906                .then(ProjectiveTransform::translation(
16907                    scroll_offset.x,
16908                    scroll_offset.y,
16909                ));
16910
16911        let mut rect_cache = HashMap::new();
16912        let mut requirements_cache = HashMap::new();
16913        let collected =
16914            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16915
16916        assert_eq!(collected.child_layers.len(), 1);
16917        assert_eq!(
16918            collected.child_layers[0].snap_anchor,
16919            Some(SnapAnchor::rigid(Point::new(14.25, -2.0))),
16920            "isolated scrolled descendants must composite with the same content-origin snap phase"
16921        );
16922    }
16923
16924    #[test]
16925    fn animated_translated_content_surface_composite_uses_scroll_content_snap_anchor() {
16926        let mut root = translated_content_local_surface_root();
16927        let scroll_offset = Point::new(0.0, -18.5);
16928        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
16929            panic!("expected translated content layer");
16930        };
16931        translated_content.motion_context_animated = true;
16932        translated_content.translated_content_offset = scroll_offset;
16933        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
16934            panic!("expected effectful text layer");
16935        };
16936        effectful_text.transform_to_parent =
16937            effectful_text
16938                .transform_to_parent
16939                .then(ProjectiveTransform::translation(
16940                    scroll_offset.x,
16941                    scroll_offset.y,
16942                ));
16943
16944        let mut rect_cache = HashMap::new();
16945        let mut requirements_cache = HashMap::new();
16946        let collected =
16947            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16948
16949        assert_eq!(collected.child_layers.len(), 1);
16950        assert_eq!(
16951            collected.child_layers[0].snap_anchor,
16952            Some(SnapAnchor::rigid(Point::new(14.25, 16.5))),
16953            "animated translated content should composite the stable local surface at the viewport-origin snap phase"
16954        );
16955    }
16956
16957    #[test]
16958    fn translated_text_material_effect_layer_uses_scroll_content_snap_anchor() {
16959        let mut layer = text_layer_with_style(
16960            AnnotatedString::from("gradient"),
16961            TextStyle::from_span_style(SpanStyle {
16962                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
16963                ..SpanStyle::default()
16964            }),
16965        );
16966        layer.translated_content_context = true;
16967        layer.translated_content_offset = Point::new(0.0, -18.5);
16968        let mut rect_cache = HashMap::new();
16969        let mut requirements_cache = HashMap::new();
16970
16971        let collected =
16972            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
16973
16974        assert_eq!(collected.scene.effect_layers.len(), 1);
16975        assert_eq!(
16976            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
16977            CompositeSampleMode::Box4
16978        );
16979        assert_eq!(
16980            collected.scene.effect_layers[0].snap_anchor,
16981            Some(SnapAnchor::rigid(Point::new(0.0, -18.5))),
16982            "text material surfaces must composite with the scroll content-origin snap phase"
16983        );
16984    }
16985
16986    #[test]
16987    fn translated_layer_surface_capture_does_not_restart_local_picture_for_shadow_text() {
16988        let mut layer = text_layer_with_style(
16989            AnnotatedString::from("shadow"),
16990            TextStyle::from_span_style(SpanStyle {
16991                shadow: Some(Shadow {
16992                    color: Color::BLACK,
16993                    offset: Point::new(1.0, 2.0),
16994                    blur_radius: 3.0,
16995                }),
16996                ..SpanStyle::default()
16997            }),
16998        );
16999        layer.translated_content_context = true;
17000        let mut rect_cache = HashMap::new();
17001        let mut requirements_cache = HashMap::new();
17002
17003        let collected = collect_layer_contents_with_translation_context(
17004            &layer,
17005            None,
17006            None,
17007            TranslationRenderContext {
17008                inherited_content_translation: false,
17009                surface_capture_active: true,
17010                local_picture_capture_active: true,
17011                ..TranslationRenderContext::default()
17012            },
17013            &mut rect_cache,
17014            &mut requirements_cache,
17015        );
17016
17017        assert!(
17018            collected.scene.effect_layers.is_empty(),
17019            "a translated layer surface already provides the stable local capture"
17020        );
17021        assert_eq!(collected.scene.shadow_draws.len(), 1);
17022        assert_eq!(collected.scene.texts.len(), 1);
17023        assert!(
17024            !collected.scene.texts[0].translated_content_context,
17025            "text inside an active motion-stable capture must raster in capture-local coordinates"
17026        );
17027    }
17028
17029    #[test]
17030    fn translated_layer_surface_capture_keeps_only_material_effect_layers() {
17031        let mut layer = text_layer_with_style(
17032            AnnotatedString::from("gradient"),
17033            TextStyle::from_span_style(SpanStyle {
17034                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
17035                ..SpanStyle::default()
17036            }),
17037        );
17038        layer.translated_content_context = true;
17039        let mut rect_cache = HashMap::new();
17040        let mut requirements_cache = HashMap::new();
17041
17042        let collected = collect_layer_contents_with_translation_context(
17043            &layer,
17044            None,
17045            None,
17046            TranslationRenderContext {
17047                inherited_content_translation: false,
17048                surface_capture_active: true,
17049                local_picture_capture_active: true,
17050                ..TranslationRenderContext::default()
17051            },
17052            &mut rect_cache,
17053            &mut requirements_cache,
17054        );
17055
17056        assert_eq!(collected.scene.effect_layers.len(), 1);
17057        assert!(
17058            collected.scene.effect_layers[0]
17059                .requirements
17060                .contains(SurfaceRequirement::MotionStableCapture),
17061            "translated text materials still need motion-stable resolve semantics inside a stable capture"
17062        );
17063        assert_eq!(
17064            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
17065            CompositeSampleMode::Box4
17066        );
17067        assert_eq!(
17068            effect_layer_target_scale(&collected.scene.effect_layers[0], 10.0),
17069            10.0
17070        );
17071        assert!(collected.scene.effect_layers[0].effect.is_some());
17072    }
17073
17074    #[test]
17075    fn translated_viewport_surface_does_not_add_plain_local_picture_capture() {
17076        let mut layer = text_layer_with_style(
17077            AnnotatedString::from("shadow"),
17078            TextStyle::from_span_style(SpanStyle {
17079                shadow: Some(Shadow {
17080                    color: Color::BLACK,
17081                    offset: Point::new(1.0, 2.0),
17082                    blur_radius: 3.0,
17083                }),
17084                ..SpanStyle::default()
17085            }),
17086        );
17087        layer.translated_content_context = true;
17088        layer.motion_context_animated = true;
17089        let mut rect_cache = HashMap::new();
17090        let mut requirements_cache = HashMap::new();
17091
17092        let collected = collect_layer_contents_with_translation_context(
17093            &layer,
17094            None,
17095            None,
17096            TranslationRenderContext {
17097                surface_capture_active: true,
17098                ..TranslationRenderContext::default()
17099            },
17100            &mut rect_cache,
17101            &mut requirements_cache,
17102        );
17103
17104        assert_eq!(
17105            collected.scene.effect_layers.len(),
17106            0,
17107            "plain translated content inside a viewport surface should not be captured again"
17108        );
17109        assert_eq!(collected.scene.shadow_draws.len(), 1);
17110        assert_eq!(collected.scene.texts.len(), 1);
17111    }
17112
17113    #[test]
17114    fn static_pure_text_leaf_snaps_without_sibling_draw_primitives() {
17115        let root = pure_text_leaf_root(false, false);
17116        let mut rect_cache = HashMap::new();
17117        let mut requirements_cache = HashMap::new();
17118
17119        let collected =
17120            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17121
17122        assert_eq!(collected.scene.texts.len(), 1);
17123        assert!(
17124            collected.scene.texts[0].snap_anchor.is_some(),
17125            "idle pure text leaves should participate in rigid snap anchoring"
17126        );
17127    }
17128
17129    #[test]
17130    fn animated_pure_text_leaf_stays_unsnapped() {
17131        let root = pure_text_leaf_root(true, false);
17132        let mut rect_cache = HashMap::new();
17133        let mut requirements_cache = HashMap::new();
17134
17135        let collected =
17136            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17137
17138        assert_eq!(collected.scene.texts.len(), 1);
17139        assert_eq!(collected.scene.texts[0].snap_anchor, None);
17140    }
17141
17142    #[test]
17143    fn animated_translated_pure_text_uses_bounded_content_snap() {
17144        let root = pure_text_leaf_root(true, true);
17145        let mut rect_cache = HashMap::new();
17146        let mut requirements_cache = HashMap::new();
17147
17148        let collected =
17149            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17150
17151        assert_eq!(collected.child_layers.len(), 1);
17152        assert!(collected.scene.texts.is_empty());
17153        assert!(collected.scene.effect_layers.is_empty());
17154        assert_snap_anchor_close(
17155            collected.child_layers[0].snap_anchor,
17156            Point::new(11.4, 23.6),
17157            "animated translated pure text should use the bounded content snap phase",
17158        );
17159    }
17160
17161    #[test]
17162    fn rested_translated_pure_text_leaf_snaps_for_crisp_scroll_rest() {
17163        let root = pure_text_leaf_root(false, true);
17164        let mut rect_cache = HashMap::new();
17165        let mut requirements_cache = HashMap::new();
17166
17167        let collected =
17168            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17169
17170        assert_eq!(collected.child_layers.len(), 0);
17171        assert_eq!(collected.scene.texts.len(), 1);
17172        assert_eq!(collected.scene.effect_layers.len(), 0);
17173        assert_snap_anchor_close(
17174            collected.scene.texts[0].snap_anchor,
17175            Point::new(11.4, 23.6),
17176            "rested translated text should snap to device pixels",
17177        );
17178    }
17179
17180    #[test]
17181    fn static_gpu_effect_text_leaf_stays_unsnapped() {
17182        let root = text_layer_with_style(
17183            AnnotatedString::from("Gradient"),
17184            TextStyle::from_span_style(SpanStyle {
17185                brush: Some(Brush::linear_gradient(vec![
17186                    Color(0.2, 0.8, 1.0, 1.0),
17187                    Color(1.0, 0.7, 0.4, 1.0),
17188                ])),
17189                draw_style: Some(TextDrawStyle::Stroke { width: 2.5 }),
17190                ..SpanStyle::default()
17191            }),
17192        );
17193        let mut rect_cache = HashMap::new();
17194        let mut requirements_cache = HashMap::new();
17195
17196        let collected =
17197            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17198
17199        assert_eq!(collected.scene.texts.len(), 1);
17200        assert_eq!(
17201            collected.scene.texts[0].snap_anchor, None,
17202            "gpu text-effect leaves must not take the rigid text snap path"
17203        );
17204        assert_eq!(
17205            collected.scene.effect_layers.len(),
17206            1,
17207            "gradient stroke text should still emit a runtime shader effect layer"
17208        );
17209    }
17210
17211    #[test]
17212    fn layer_surface_requirements_keep_shape_plus_direct_child_on_direct_path() {
17213        let mut child = test_layer(
17214            Rect {
17215                x: 0.0,
17216                y: 0.0,
17217                width: 40.0,
17218                height: 20.0,
17219            },
17220            vec![RenderNode::Primitive(PrimitiveEntry {
17221                phase: PrimitivePhase::BeforeChildren,
17222                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17223                    primitive: DrawPrimitive::Rect {
17224                        rect: Rect {
17225                            x: 0.0,
17226                            y: 0.0,
17227                            width: 40.0,
17228                            height: 20.0,
17229                        },
17230                        brush: Brush::solid(Color::WHITE),
17231                        stroke: None,
17232                    },
17233                    clip: None,
17234                }),
17235            })],
17236        );
17237        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
17238
17239        let layer = test_layer(
17240            Rect {
17241                x: 0.0,
17242                y: 0.0,
17243                width: 64.0,
17244                height: 32.0,
17245            },
17246            vec![
17247                RenderNode::Primitive(PrimitiveEntry {
17248                    phase: PrimitivePhase::BeforeChildren,
17249                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
17250                        primitive: DrawPrimitive::Rect {
17251                            rect: Rect {
17252                                x: 0.0,
17253                                y: 0.0,
17254                                width: 64.0,
17255                                height: 32.0,
17256                            },
17257                            brush: Brush::solid(Color::BLACK),
17258                            stroke: None,
17259                        },
17260                        clip: None,
17261                    }),
17262                }),
17263                RenderNode::Layer(Box::new(child)),
17264            ],
17265        );
17266
17267        let requirements = layer_surface_requirements(&layer);
17268
17269        assert_eq!(requirements.direct_translation, Some(Point::default()));
17270        assert!(!requirements
17271            .surface_requirements
17272            .contains(SurfaceRequirement::MixedDirectContent));
17273        assert!(!requirements
17274            .surface_requirements
17275            .has_isolating_requirement());
17276    }
17277
17278    #[test]
17279    fn collect_layer_contents_translates_direct_text_rects_into_parent_space() {
17280        let mut child = text_layer_with_style(
17281            AnnotatedString::from("direct"),
17282            TextStyle::from_span_style(SpanStyle {
17283                text_decoration: Some(TextDecoration::UNDERLINE),
17284                ..SpanStyle::default()
17285            }),
17286        );
17287        child.transform_to_parent = ProjectiveTransform::translation(9.0, 7.0);
17288
17289        let parent = test_layer(
17290            Rect {
17291                x: 0.0,
17292                y: 0.0,
17293                width: 64.0,
17294                height: 32.0,
17295            },
17296            vec![RenderNode::Layer(Box::new(child))],
17297        );
17298
17299        let mut rect_cache = HashMap::new();
17300        let mut requirements_cache = HashMap::new();
17301        let collected = with_test_app_context(|| {
17302            collect_layer_contents(
17303                &parent,
17304                None,
17305                None,
17306                &mut rect_cache,
17307                &mut requirements_cache,
17308            )
17309        });
17310
17311        assert!(
17312            collected.child_layers.is_empty(),
17313            "decoration-only text child should collapse directly into the parent scene"
17314        );
17315        assert_eq!(collected.scene.texts.len(), 1, "expected one text draw");
17316        let text = &collected.scene.texts[0];
17317        assert!(
17318            text.rect.x >= 9.0 && text.rect.y >= 7.0,
17319            "collapsed text rect should be translated into parent space, got {:?}",
17320            text.rect
17321        );
17322        assert!(
17323            collected
17324                .scene
17325                .shapes
17326                .iter()
17327                .any(|shape| shape.rect.y >= 7.0),
17328            "collapsed underline geometry should also be translated into parent space"
17329        );
17330    }
17331
17332    #[test]
17333    fn normalized_scene_keeps_lazy_after_bound_text_for_prewarm() {
17334        use std::cell::RefCell;
17335
17336        fn collect_graph_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
17337            for child in &layer.children {
17338                match child {
17339                    RenderNode::Primitive(PrimitiveEntry {
17340                        node: PrimitiveNode::Text(text),
17341                        ..
17342                    }) => labels.push(text.text.text.clone()),
17343                    RenderNode::Layer(child_layer) => {
17344                        collect_graph_text_labels(child_layer, labels)
17345                    }
17346                    RenderNode::Primitive(_) | RenderNode::DrawRun(_) => {}
17347                }
17348            }
17349        }
17350
17351        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
17352        let state_holder_for_comp = state_holder.clone();
17353        let mut composition = cranpose_ui::run_test_composition(move || {
17354            let list_state = remember_lazy_list_state();
17355            *state_holder_for_comp.borrow_mut() = Some(list_state);
17356            let mut spec = LazyColumnSpec::new()
17357                .vertical_arrangement(cranpose_ui::LinearArrangement::SpacedBy(6.0));
17358            spec.beyond_bounds_item_count = 0;
17359            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
17360                scope.items(
17361                    12,
17362                    None::<fn(usize) -> u64>,
17363                    None::<fn(usize) -> u64>,
17364                    |index| {
17365                        Text(
17366                            format!("WarmRow {index}"),
17367                            Modifier::empty().height(32.0),
17368                            TextStyle::default(),
17369                        );
17370                    },
17371                );
17372            });
17373        });
17374
17375        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
17376        list_state.scroll_to_item(4, 0.0);
17377
17378        let root = composition.root().expect("lazy column root");
17379        let handle = composition.runtime_handle();
17380        let mut applier = composition.applier_mut();
17381        applier.set_runtime_handle(handle);
17382        let _ = applier
17383            .compute_layout(
17384                root,
17385                Size {
17386                    width: 240.0,
17387                    height: 240.0,
17388                },
17389            )
17390            .expect("lazy column layout");
17391        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
17392        applier.clear_runtime_handle();
17393        let mut graph_labels = Vec::new();
17394        collect_graph_text_labels(&graph.root, &mut graph_labels);
17395
17396        let visible_indices: Vec<_> = list_state
17397            .layout_info()
17398            .visible_items_info
17399            .iter()
17400            .map(|item| item.index)
17401            .collect();
17402        assert_eq!(
17403            visible_indices,
17404            vec![4, 5, 6],
17405            "test setup expects exactly three viewport-visible rows"
17406        );
17407
17408        let mut rect_cache = HashMap::new();
17409        let mut requirements_cache = HashMap::new();
17410        let collected = with_test_app_context(|| {
17411            collect_layer_contents(
17412                &graph.root,
17413                None,
17414                None,
17415                &mut rect_cache,
17416                &mut requirements_cache,
17417            )
17418        });
17419        let root_text_labels: Vec<_> = collected
17420            .scene
17421            .texts
17422            .iter()
17423            .map(|text| text.text.text.clone())
17424            .collect();
17425        let child_layer_count = collected.child_layers.len();
17426        let warm_text = collected
17427            .scene
17428            .texts
17429            .iter()
17430            .find(|text| text.text.text == "WarmRow 7")
17431            .unwrap_or_else(|| {
17432                panic!(
17433                    "after-bound lazy text should reach WGPU scene collection; graph_texts={graph_labels:?} root_texts={root_text_labels:?} child_layers={child_layer_count}"
17434                )
17435            });
17436
17437        assert!(
17438            warm_text.rect.y >= 96.0,
17439            "after-bound text should be below the viewport, got {:?}",
17440            warm_text.rect
17441        );
17442        assert_eq!(
17443            visible_draw_rect(warm_text.rect, warm_text.clip),
17444            None,
17445            "after-bound text should remain clipped away for drawing while staying available for glyph prewarm"
17446        );
17447        assert!(
17448            text_draw_should_prewarm_in_viewport(
17449                warm_text.rect,
17450                warm_text.clip,
17451                ViewportUniformParams {
17452                    width: 240,
17453                    height: 96,
17454                    offset: [0.0, 0.0],
17455                },
17456                1.0,
17457            ),
17458            "after-bound text inside the warm window must be selected by WGPU prewarm"
17459        );
17460    }
17461
17462    #[test]
17463    fn direct_translation_accepts_nearly_identity_axis_scale_noise() {
17464        let local_bounds = Rect {
17465            x: 0.0,
17466            y: 0.0,
17467            width: 393.3,
17468            height: 16.8,
17469        };
17470        let quad = [
17471            [10.0, 78.399_994],
17472            [403.3, 78.399_994],
17473            [10.0, 95.2],
17474            [403.3, 95.2],
17475        ];
17476        let transform = ProjectiveTransform::from_rect_to_quad(local_bounds, quad);
17477
17478        assert_eq!(
17479            direct_translation(transform),
17480            Some(Point::new(10.0, 78.399_994)),
17481        );
17482    }
17483
17484    #[test]
17485    fn layer_surface_requirements_keep_shape_plus_isolating_child_as_mixed_content() {
17486        let mut child = test_layer(
17487            Rect {
17488                x: 0.0,
17489                y: 0.0,
17490                width: 24.0,
17491                height: 18.0,
17492            },
17493            vec![RenderNode::Primitive(PrimitiveEntry {
17494                phase: PrimitivePhase::BeforeChildren,
17495                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17496                    primitive: DrawPrimitive::Rect {
17497                        rect: Rect {
17498                            x: 0.0,
17499                            y: 0.0,
17500                            width: 24.0,
17501                            height: 18.0,
17502                        },
17503                        brush: Brush::solid(Color::WHITE),
17504                        stroke: None,
17505                    },
17506                    clip: None,
17507                }),
17508            })],
17509        );
17510        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
17511        child.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
17512
17513        let layer = test_layer(
17514            Rect {
17515                x: 0.0,
17516                y: 0.0,
17517                width: 64.0,
17518                height: 32.0,
17519            },
17520            vec![
17521                RenderNode::Primitive(PrimitiveEntry {
17522                    phase: PrimitivePhase::BeforeChildren,
17523                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
17524                        primitive: DrawPrimitive::Rect {
17525                            rect: Rect {
17526                                x: 0.0,
17527                                y: 0.0,
17528                                width: 64.0,
17529                                height: 32.0,
17530                            },
17531                            brush: Brush::solid(Color::BLACK),
17532                            stroke: None,
17533                        },
17534                        clip: None,
17535                    }),
17536                }),
17537                RenderNode::Layer(Box::new(child)),
17538            ],
17539        );
17540
17541        let requirements = layer_surface_requirements(&layer);
17542
17543        assert!(requirements
17544            .surface_requirements
17545            .contains(SurfaceRequirement::MixedDirectContent));
17546        assert!(!requirements
17547            .surface_requirements
17548            .has_isolating_requirement());
17549    }
17550
17551    #[test]
17552    fn build_scene_window_filters_and_translates_items() {
17553        let mut shape = test_shape(6, BlendMode::SrcOver);
17554        shape.rect.x = 12.0;
17555        shape.rect.y = 25.0;
17556        shape.local_rect.x = 12.0;
17557        shape.local_rect.y = 25.0;
17558        shape.quad = [[12.0, 25.0], [20.0, 25.0], [12.0, 33.0], [20.0, 33.0]];
17559        shape.clip = Some(Rect {
17560            x: 11.0,
17561            y: 24.0,
17562            width: 10.0,
17563            height: 10.0,
17564        });
17565
17566        let mut image = test_image(8, BlendMode::SrcOver);
17567        image.rect.x = 18.0;
17568        image.rect.y = 27.0;
17569        image.local_rect.x = 18.0;
17570        image.local_rect.y = 27.0;
17571        image.quad = [[18.0, 27.0], [26.0, 27.0], [18.0, 35.0], [26.0, 35.0]];
17572
17573        let mut text = test_text(9);
17574        text.rect.x = 16.0;
17575        text.rect.y = 29.0;
17576        text.clip = Some(Rect {
17577            x: 15.0,
17578            y: 28.0,
17579            width: 9.0,
17580            height: 6.0,
17581        });
17582
17583        let mut shadow_shape = test_shape(7, BlendMode::SrcOver);
17584        shadow_shape.rect.x = 14.0;
17585        shadow_shape.rect.y = 26.0;
17586        shadow_shape.local_rect.x = 14.0;
17587        shadow_shape.local_rect.y = 26.0;
17588        shadow_shape.quad = [[14.0, 26.0], [22.0, 26.0], [14.0, 34.0], [22.0, 34.0]];
17589        let mut shadow = test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]);
17590        shadow.z_index = 7;
17591
17592        let mut nested_effect = effect_layer(6, 10);
17593        nested_effect.rect.x = 13.0;
17594        nested_effect.rect.y = 24.0;
17595        nested_effect.clip = Some(Rect {
17596            x: 15.0,
17597            y: 25.0,
17598            width: 4.0,
17599            height: 5.0,
17600        });
17601
17602        let mut nested_backdrop = backdrop_layer(8);
17603        nested_backdrop.rect.x = 17.0;
17604        nested_backdrop.rect.y = 26.0;
17605        nested_backdrop.clip = Some(Rect {
17606            x: 18.0,
17607            y: 27.0,
17608            width: 3.0,
17609            height: 4.0,
17610        });
17611
17612        let window = build_scene_window(
17613            SceneWindowSource {
17614                shapes: &[test_shape(4, BlendMode::SrcOver), shape],
17615                images: &[image],
17616                texts: &[text],
17617                shadow_draws: &[shadow],
17618                draw_ops: &[],
17619                effect_layers: &[effect_layer(2, 4), nested_effect.clone()],
17620                backdrop_layers: &[backdrop_layer(4), nested_backdrop.clone()],
17621            },
17622            5,
17623            10,
17624            Rect {
17625                x: 10.0,
17626                y: 20.0,
17627                width: 20.0,
17628                height: 20.0,
17629            },
17630        );
17631
17632        assert_eq!(window.shapes.len(), 1);
17633        assert_eq!(
17634            window.shapes[0].rect,
17635            Rect {
17636                x: 2.0,
17637                y: 5.0,
17638                width: 8.0,
17639                height: 8.0,
17640            }
17641        );
17642        assert_eq!(
17643            window.shapes[0].clip,
17644            Some(Rect {
17645                x: 1.0,
17646                y: 4.0,
17647                width: 10.0,
17648                height: 10.0,
17649            })
17650        );
17651        assert_eq!(window.images.len(), 1);
17652        assert_eq!(window.images[0].rect.x, 8.0);
17653        assert_eq!(window.images[0].rect.y, 7.0);
17654        assert_eq!(window.texts.len(), 1);
17655        assert_eq!(window.texts[0].rect.x, 6.0);
17656        assert_eq!(window.texts[0].rect.y, 9.0);
17657        assert_eq!(
17658            window.texts[0].clip,
17659            Some(Rect {
17660                x: 5.0,
17661                y: 8.0,
17662                width: 9.0,
17663                height: 6.0,
17664            })
17665        );
17666        assert_eq!(window.shadow_draws.len(), 1);
17667        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.x, 4.0);
17668        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.y, 6.0);
17669        assert_eq!(window.effect_layers.len(), 1);
17670        assert_eq!(
17671            window.effect_layers[0].rect,
17672            Rect {
17673                x: 3.0,
17674                y: 4.0,
17675                width: 10.0,
17676                height: 10.0,
17677            }
17678        );
17679        assert_eq!(
17680            window.effect_layers[0].clip,
17681            Some(Rect {
17682                x: 5.0,
17683                y: 5.0,
17684                width: 4.0,
17685                height: 5.0,
17686            })
17687        );
17688        assert_eq!(window.backdrop_layers.len(), 1);
17689        assert_eq!(
17690            window.backdrop_layers[0].rect,
17691            Rect {
17692                x: 7.0,
17693                y: 6.0,
17694                width: 10.0,
17695                height: 10.0,
17696            }
17697        );
17698        assert_eq!(
17699            window.backdrop_layers[0].clip,
17700            Some(Rect {
17701                x: 8.0,
17702                y: 7.0,
17703                width: 3.0,
17704                height: 4.0,
17705            })
17706        );
17707    }
17708
17709    #[test]
17710    fn filtered_effect_layer_index_counts_only_window_members() {
17711        let effects = vec![
17712            effect_layer(0, 2),
17713            effect_layer(5, 12),
17714            effect_layer(6, 10),
17715            effect_layer(14, 20),
17716        ];
17717
17718        assert_eq!(filtered_effect_layer_index(&effects, 1, 5, 12), Some(0));
17719        assert_eq!(filtered_effect_layer_index(&effects, 2, 5, 12), Some(1));
17720        assert_eq!(filtered_effect_layer_index(&effects, 3, 5, 12), None);
17721    }
17722
17723    #[test]
17724    fn blend_mode_support_matrix_is_explicit() {
17725        assert!(is_blend_mode_supported(BlendMode::SrcOver));
17726        assert!(is_blend_mode_supported(BlendMode::DstOut));
17727        assert!(!is_blend_mode_supported(BlendMode::Clear));
17728        assert!(!is_blend_mode_supported(BlendMode::Multiply));
17729    }
17730
17731    #[test]
17732    fn collect_non_effect_segment_items_preserves_global_z_order() {
17733        let shapes = vec![
17734            test_shape(3, BlendMode::SrcOver),
17735            test_shape(1, BlendMode::DstOut),
17736        ];
17737        let images = vec![test_image(2, BlendMode::SrcOver)];
17738        let texts = vec![test_text(0)];
17739        let shadows: Vec<ShadowDraw> = Vec::new();
17740        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
17741
17742        let mut scratch = Vec::new();
17743        collect_non_effect_segment_items(
17744            &shapes,
17745            &images,
17746            &texts,
17747            &shadows,
17748            &draw_ops,
17749            0,
17750            4,
17751            &[],
17752            100,
17753            100,
17754            1.0,
17755            &mut scratch,
17756        );
17757        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
17758        assert_eq!(
17759            items,
17760            vec![
17761                SegmentDrawItem::Text(0),
17762                SegmentDrawItem::Shape(1),
17763                SegmentDrawItem::Image(0),
17764                SegmentDrawItem::Shape(0),
17765            ]
17766        );
17767    }
17768
17769    #[test]
17770    fn collect_non_effect_segment_items_filters_effect_ranges() {
17771        let shapes = vec![
17772            test_shape(1, BlendMode::SrcOver),
17773            test_shape(3, BlendMode::DstOut),
17774        ];
17775        let images = vec![test_image(2, BlendMode::SrcOver)];
17776        let texts = vec![test_text(4)];
17777        let shadows: Vec<ShadowDraw> = Vec::new();
17778        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
17779        let effect_ranges = [std::ops::Range { start: 2, end: 4 }];
17780
17781        let mut scratch = Vec::new();
17782        collect_non_effect_segment_items(
17783            &shapes,
17784            &images,
17785            &texts,
17786            &shadows,
17787            &draw_ops,
17788            0,
17789            5,
17790            &effect_ranges,
17791            100,
17792            100,
17793            1.0,
17794            &mut scratch,
17795        );
17796        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
17797        assert_eq!(
17798            items,
17799            vec![SegmentDrawItem::Shape(0), SegmentDrawItem::Text(0)]
17800        );
17801    }
17802
17803    #[test]
17804    fn collect_non_effect_segment_items_culls_offscreen_shapes_but_keeps_text_prewarm() {
17805        let mut shape = test_shape(0, BlendMode::SrcOver);
17806        shape.rect.y = 160.0;
17807        shape.local_rect.y = 160.0;
17808        shape.quad = [[0.0, 160.0], [8.0, 160.0], [0.0, 168.0], [8.0, 168.0]];
17809
17810        let shapes = vec![shape];
17811        let images = Vec::new();
17812        let mut text = test_text(1);
17813        text.rect.y = 160.0;
17814        let texts = vec![text];
17815        let shadows: Vec<ShadowDraw> = Vec::new();
17816        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
17817
17818        let mut scratch = Vec::new();
17819        collect_non_effect_segment_items(
17820            &shapes,
17821            &images,
17822            &texts,
17823            &shadows,
17824            &draw_ops,
17825            0,
17826            2,
17827            &[],
17828            100,
17829            100,
17830            1.0,
17831            &mut scratch,
17832        );
17833
17834        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
17835        assert_eq!(items, vec![SegmentDrawItem::Text(0)]);
17836    }
17837
17838    #[test]
17839    fn segment_command_iter_merges_non_conflicting_batches_into_one_chunk() {
17840        let ordered_items = vec![
17841            (0, SegmentDrawItem::Shape(0)),
17842            (1, SegmentDrawItem::Image(0)),
17843            (2, SegmentDrawItem::Text(0)),
17844        ];
17845        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
17846        let images = vec![test_image(1, BlendMode::DstOut)];
17847
17848        let commands: Vec<_> = SegmentCommandIter::new(
17849            &ordered_items,
17850            &shapes,
17851            &images,
17852            ShapeBatchLimits::desktop(),
17853        )
17854        .collect();
17855
17856        assert_eq!(
17857            commands,
17858            vec![SegmentRenderCommand::DrawChunk(chunk(&[
17859                SegmentBatchPlan::Shape {
17860                    start: 0,
17861                    end: 1,
17862                    blend_mode: BlendMode::SrcOver,
17863                },
17864                SegmentBatchPlan::Image {
17865                    start: 1,
17866                    end: 2,
17867                    blend_mode: BlendMode::DstOut,
17868                },
17869                SegmentBatchPlan::Text { start: 2, end: 3 },
17870            ]))]
17871        );
17872    }
17873
17874    #[test]
17875    fn segment_command_iter_keeps_layer_composites_in_ordered_draw_chunk() {
17876        let ordered_items = vec![
17877            (0, SegmentDrawItem::Shape(0)),
17878            (1, SegmentDrawItem::Composite(0)),
17879            (2, SegmentDrawItem::Image(0)),
17880            (3, SegmentDrawItem::Composite(1)),
17881            (4, SegmentDrawItem::Text(0)),
17882        ];
17883        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
17884        let images = vec![test_image(2, BlendMode::SrcOver)];
17885
17886        let commands: Vec<_> = SegmentCommandIter::new(
17887            &ordered_items,
17888            &shapes,
17889            &images,
17890            ShapeBatchLimits::desktop(),
17891        )
17892        .collect();
17893
17894        assert_eq!(
17895            commands,
17896            vec![SegmentRenderCommand::DrawChunk(chunk(&[
17897                SegmentBatchPlan::Shape {
17898                    start: 0,
17899                    end: 1,
17900                    blend_mode: BlendMode::SrcOver,
17901                },
17902                SegmentBatchPlan::Composite { start: 1, end: 2 },
17903                SegmentBatchPlan::Image {
17904                    start: 2,
17905                    end: 3,
17906                    blend_mode: BlendMode::SrcOver,
17907                },
17908                SegmentBatchPlan::Composite { start: 3, end: 4 },
17909                SegmentBatchPlan::Text { start: 4, end: 5 },
17910            ]))]
17911        );
17912    }
17913
17914    #[test]
17915    fn retain_renderable_shadow_items_culls_invisible_shadow_boundaries() {
17916        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
17917        let images = vec![test_image(2, BlendMode::SrcOver)];
17918        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
17919        shadow_shape.rect = Rect {
17920            x: 500.0,
17921            y: 500.0,
17922            width: 12.0,
17923            height: 12.0,
17924        };
17925        let shadow_draws = vec![ShadowDraw {
17926            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
17927            texts: Vec::new(),
17928            blur_radius: 8.0,
17929            clip: None,
17930            z_index: 1,
17931        }];
17932        let mut ordered_items = vec![
17933            (0, SegmentDrawItem::Shape(0)),
17934            (1, SegmentDrawItem::Shadow(0)),
17935            (2, SegmentDrawItem::Image(0)),
17936        ];
17937
17938        let culled =
17939            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
17940        let commands: Vec<_> = SegmentCommandIter::new(
17941            &ordered_items,
17942            &shapes,
17943            &images,
17944            ShapeBatchLimits::desktop(),
17945        )
17946        .collect();
17947
17948        assert_eq!(culled, 1);
17949        assert_eq!(
17950            commands,
17951            vec![SegmentRenderCommand::DrawChunk(chunk(&[
17952                SegmentBatchPlan::Shape {
17953                    start: 0,
17954                    end: 1,
17955                    blend_mode: BlendMode::SrcOver,
17956                },
17957                SegmentBatchPlan::Image {
17958                    start: 1,
17959                    end: 2,
17960                    blend_mode: BlendMode::SrcOver,
17961                },
17962            ]))]
17963        );
17964    }
17965
17966    #[test]
17967    fn retain_renderable_shadow_items_keeps_visible_shadow_boundaries() {
17968        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
17969        shadow_shape.rect = Rect {
17970            x: 20.0,
17971            y: 20.0,
17972            width: 12.0,
17973            height: 12.0,
17974        };
17975        let shadow_draws = vec![ShadowDraw {
17976            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
17977            texts: Vec::new(),
17978            blur_radius: 8.0,
17979            clip: None,
17980            z_index: 1,
17981        }];
17982        let mut ordered_items = vec![(1, SegmentDrawItem::Shadow(0))];
17983
17984        let culled =
17985            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
17986
17987        assert_eq!(culled, 0);
17988        assert_eq!(ordered_items, vec![(1, SegmentDrawItem::Shadow(0))]);
17989    }
17990
17991    #[test]
17992    fn shape_data_layout_matches_the_wgsl_mirror() {
17993        // 10 x vec4-sized slots. The uniform address space requires a 16-byte
17994        // multiple, and `shape.wgsl`'s array length literal is derived from
17995        // this size — if it drifts, batches silently overrun the binding.
17996        assert_eq!(std::mem::size_of::<ShapeData>(), 160);
17997        assert_eq!(std::mem::size_of::<ShapeData>() % 16, 0);
17998        assert_eq!(std::mem::size_of::<GradientStop>(), 32);
17999    }
18000
18001    #[test]
18002    fn shape_flags_pack_kind_cap_and_join_without_collision() {
18003        assert_eq!(
18004            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
18005            0.0
18006        );
18007        assert_eq!(
18008            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Miter),
18009            1.0
18010        );
18011        assert_eq!(
18012            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Butt, StrokeJoin::Miter),
18013            2.0
18014        );
18015        // cap in bits 2-3, join in bits 4-5
18016        assert_eq!(
18017            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Round, StrokeJoin::Miter),
18018            2.0 + 4.0
18019        );
18020        assert_eq!(
18021            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Square, StrokeJoin::Miter),
18022            2.0 + 8.0
18023        );
18024        assert_eq!(
18025            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Round),
18026            1.0 + 16.0
18027        );
18028        assert_eq!(
18029            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Bevel),
18030            1.0 + 32.0
18031        );
18032        // Every combination must round-trip through f32 exactly.
18033        for kind in [SHAPE_KIND_FILL, SHAPE_KIND_STROKE, SHAPE_KIND_ARC] {
18034            for cap in [StrokeCap::Butt, StrokeCap::Round, StrokeCap::Square] {
18035                for join in [StrokeJoin::Miter, StrokeJoin::Round, StrokeJoin::Bevel] {
18036                    let packed = pack_shape_flags(kind, cap, join);
18037                    let bits = packed as u32;
18038                    assert_eq!(bits & 3, kind);
18039                    assert_eq!((bits >> 2) & 3, stroke_cap_code(cap));
18040                    assert_eq!((bits >> 4) & 3, stroke_join_code(join));
18041                    assert_eq!(packed, bits as f32, "flags must be exact in f32");
18042                }
18043            }
18044        }
18045    }
18046
18047    #[cfg(not(target_arch = "wasm32"))]
18048    #[test]
18049    fn mesh_vertex_layout_matches_the_wgsl_input() {
18050        // {pos: vec2<f32>, uv: vec2<f32>, shape_idx: u32} = 20 bytes, no
18051        // padding — the vertex buffer layout stride relies on it.
18052        assert_eq!(std::mem::size_of::<MeshVertex>(), 20);
18053    }
18054
18055    /// f32 port of `sdf_arc_band` (shape.wgsl), operation for operation: the
18056    /// same ra/rb derivation and clamp, the same mirror trick (`abs` on the
18057    /// rotated x), the same cap branches.
18058    #[cfg(not(target_arch = "wasm32"))]
18059    #[allow(clippy::too_many_arguments)]
18060    fn sdf_arc_band_reference(
18061        p: [f32; 2],
18062        center: [f32; 2],
18063        inner: f32,
18064        outer: f32,
18065        mid_sin_cos: [f32; 2],
18066        half_sin_cos: [f32; 2],
18067        cap: u32,
18068    ) -> f32 {
18069        let ra = (outer + inner) * 0.5;
18070        let rb = ((outer - inner) * 0.5).max(0.0);
18071        let sm = mid_sin_cos[0];
18072        let cm = mid_sin_cos[1];
18073        let d = [p[0] - center[0], p[1] - center[1]];
18074        let mut q = [-sm * d[0] + cm * d[1], cm * d[0] + sm * d[1]];
18075        q[0] = q[0].abs();
18076        let sc = half_sin_cos;
18077        let mut dist = if sc[1] * q[0] > sc[0] * q[1] {
18078            let dx = q[0] - sc[0] * ra;
18079            let dy = q[1] - sc[1] * ra;
18080            (dx * dx + dy * dy).sqrt() - rb
18081        } else {
18082            ((q[0] * q[0] + q[1] * q[1]).sqrt() - ra).abs() - rb
18083        };
18084        let plane = sc[1] * q[0] - sc[0] * q[1];
18085        // STROKE_CAP_BUTT = 0, STROKE_CAP_SQUARE = 2, as in the shader.
18086        if cap == 0 {
18087            dist = dist.max(plane);
18088        } else if cap == 2 {
18089            dist = dist.max(plane - rb);
18090        }
18091        dist
18092    }
18093
18094    #[cfg(not(target_arch = "wasm32"))]
18095    fn point_in_triangle(p: [f64; 2], tri: &[[f64; 2]; 3]) -> bool {
18096        let side = |a: [f64; 2], b: [f64; 2]| {
18097            (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
18098        };
18099        let d0 = side(tri[0], tri[1]);
18100        let d1 = side(tri[1], tri[2]);
18101        let d2 = side(tri[2], tri[0]);
18102        let has_neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0;
18103        let has_pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0;
18104        !(has_neg && has_pos)
18105    }
18106
18107    #[cfg(not(target_arch = "wasm32"))]
18108    fn converted_arc_shape(arc: cranpose_ui_graphics::ArcGeometry, root_scale: f32) -> ShapeData {
18109        let bounds = arc.bounds();
18110        let mut shape = test_shape(0, BlendMode::SrcOver);
18111        shape.rect = bounds;
18112        shape.local_rect = bounds;
18113        shape.quad = [
18114            [bounds.x, bounds.y],
18115            [bounds.x + bounds.width, bounds.y],
18116            [bounds.x, bounds.y + bounds.height],
18117            [bounds.x + bounds.width, bounds.y + bounds.height],
18118        ];
18119        shape.arc = Some(arc);
18120        let mut converted = ShapeData::zeroed();
18121        convert_shape_into_slots(&shape, root_scale, 0, &mut converted, &mut []);
18122        converted
18123    }
18124
18125    /// The containment invariant, checked directly: every point of the
18126    /// capture box whose (exactly ported) SDF keeps it must lie inside the
18127    /// emitted triangle set. Thin/thick, tiny/huge, full rings, near-zero
18128    /// and near-TAU sweeps, all caps, `Ri == 0` discs and pie wedges.
18129    #[cfg(not(target_arch = "wasm32"))]
18130    #[test]
18131    fn arc_mesh_contains_every_band_pixel() {
18132        use cranpose_ui_graphics::ArcGeometry;
18133        let tau = cranpose_ui_graphics::TAU;
18134        let center = Point::new(250.0, 250.0);
18135        let cases: &[(f32, f32, f32, f32, StrokeCap)] = &[
18136            // full ring, thin band
18137            (90.0, 100.0, 0.0, tau, StrokeCap::Round),
18138            // sweep > TAU normalizes to a closed ring
18139            (80.0, 100.0, 1.0, 10.0, StrokeCap::Butt),
18140            // full disc: Ri == 0
18141            (0.0, 40.0, 0.0, tau, StrokeCap::Round),
18142            // thick partial arc, every cap
18143            (30.0, 80.0, 0.7, 2.5, StrokeCap::Butt),
18144            (30.0, 80.0, 0.7, 2.5, StrokeCap::Round),
18145            (30.0, 80.0, 0.7, 2.5, StrokeCap::Square),
18146            // thin, axis-crossing sweep
18147            (99.0, 101.0, 3.0, 4.0, StrokeCap::Round),
18148            // tiny
18149            (0.6, 2.0, 0.3, 1.2, StrokeCap::Butt),
18150            // huge radius, thin band
18151            (1900.0, 1904.0, 0.1, 0.35, StrokeCap::Square),
18152            // near-zero sweep
18153            (40.0, 60.0, 5.0, 1e-3, StrokeCap::Round),
18154            // sweep near TAU: the cap pads wrap the range closed
18155            (40.0, 60.0, 0.2, tau - 1e-3, StrokeCap::Butt),
18156            // rb_m >= ra: the cap disc wraps the center (pie wedge)
18157            (0.0, 3.0, 1.0, 2.0, StrokeCap::Round),
18158            // filled annular sector (butt radial ends)
18159            (20.0, 60.0, 4.5, 1.9, StrokeCap::Butt),
18160        ];
18161        for (case, &(inner, outer, start, sweep, cap)) in cases.iter().enumerate() {
18162            // 2.75 is deliberately non-dyadic: quad corners and rect then
18163            // disagree by an ulp, which the axis-aligned gate must tolerate
18164            // (an equality-with-rect gate silently failed every arc on the
18165            // Huawei at scale 2.75).
18166            for root_scale in [1.0f32, 2.0, 2.75] {
18167                let arc = ArcGeometry::new(center, inner, outer, start, sweep, cap);
18168                assert!(!arc.is_degenerate(), "case {case} must be drawable");
18169                let converted = converted_arc_shape(arc, root_scale);
18170                let band = arc_mesh_band(&converted)
18171                    .unwrap_or_else(|| panic!("case {case} must qualify for meshing"));
18172                let mut vertices = Vec::new();
18173                let mut indices = Vec::new();
18174                let segments =
18175                    emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
18176                        .unwrap_or_else(|| panic!("case {case} must produce a mesh"));
18177                assert!(segments >= ARC_MESH_MIN_SEGMENTS);
18178                // The rasterized set is the indexed walk: triangles are index
18179                // triples into the shared vertex list.
18180                let position = |index: u32| {
18181                    let p = vertices[index as usize].position;
18182                    [p[0] as f64, p[1] as f64]
18183                };
18184                let triangles: Vec<[[f64; 2]; 3]> = indices
18185                    .chunks_exact(3)
18186                    .map(|tri| [position(tri[0]), position(tri[1]), position(tri[2])])
18187                    .collect();
18188
18189                // Sample the QUAD box, not `rect`: quad expansion rasterizes the
18190                // quad, the mesh clips to the quad, and at non-dyadic root
18191                // scales the two boxes differ by an ulp.
18192                let [qx, qy, ..] = converted.quad01;
18193                let [_, _, qr, qb] = converted.quad23;
18194                let (rw, rh) = (qr - qx, qb - qy);
18195                let cap_bits = (converted.stroke_params[1].max(0.0) as u32 >> 2) & 3;
18196                let step = (rw.max(rh) / 400.0).clamp(0.25, 2.0);
18197                let mut band_points = 0usize;
18198                let mut y = qy;
18199                while y <= qb {
18200                    let mut x = qx;
18201                    while x <= qr {
18202                        let dist = sdf_arc_band_reference(
18203                            [x, y],
18204                            [converted.arc_params[0], converted.arc_params[1]],
18205                            converted.stroke_params[3],
18206                            converted.stroke_params[2],
18207                            [converted.radii[0], converted.radii[1]],
18208                            [converted.radii[2], converted.radii[3]],
18209                            cap_bits,
18210                        );
18211                        if dist <= 0.5 {
18212                            band_points += 1;
18213                            let p = [x as f64, y as f64];
18214                            assert!(
18215                                triangles.iter().any(|tri| point_in_triangle(p, tri)),
18216                                "case {case} scale {root_scale}: band point ({x}, {y}) \
18217                                 dist {dist} escapes the mesh"
18218                            );
18219                        }
18220                        x += step;
18221                    }
18222                    y += step;
18223                }
18224                assert!(
18225                    band_points > 0,
18226                    "case {case} scale {root_scale}: the sampling grid never hit the band"
18227                );
18228            }
18229        }
18230    }
18231
18232    #[cfg(not(target_arch = "wasm32"))]
18233    #[test]
18234    fn arc_mesh_passthrough_replicates_the_quad_expansion() {
18235        let shape = test_shape(0, BlendMode::SrcOver);
18236        let mut converted = ShapeData::zeroed();
18237        convert_shape_into_slots(&shape, 1.0, 0, &mut converted, &mut []);
18238        let build =
18239            build_arc_mesh_vertices(std::slice::from_ref(&converted)).expect("within budget");
18240        assert_eq!(build.meshed_arcs, 0);
18241        assert_eq!(build.passthrough, 1);
18242        // Four shared corner vertices, six indices — amplification-free.
18243        assert_eq!(build.vertices.len(), 4);
18244        assert_eq!(build.index_prefix, vec![0, 6]);
18245        assert_eq!(build.indices, vec![0, 1, 2, 2, 1, 3]);
18246        let corners = [
18247            ([converted.quad01[0], converted.quad01[1]], [0.0f32, 0.0]),
18248            ([converted.quad01[2], converted.quad01[3]], [1.0, 0.0]),
18249            ([converted.quad23[0], converted.quad23[1]], [0.0, 1.0]),
18250            ([converted.quad23[2], converted.quad23[3]], [1.0, 1.0]),
18251        ];
18252        for (vertex, corner) in build.vertices.iter().zip(corners) {
18253            assert_eq!(vertex.position, corner.0);
18254            assert_eq!(vertex.uv, corner.1);
18255            assert_eq!(vertex.shape_idx, 0);
18256        }
18257        // The indexed walk expands to vs_main's slot order: triangles
18258        // (0, 1, 2) and (2, 1, 3).
18259        for (index, corner) in build.indices.iter().zip([0usize, 1, 2, 2, 1, 3]) {
18260            assert_eq!(build.vertices[*index as usize].position, corners[corner].0);
18261            assert_eq!(build.vertices[*index as usize].uv, corners[corner].1);
18262        }
18263    }
18264
18265    /// The indexed-topology contract for arcs whose trapezoids survive
18266    /// clipping whole: every band boundary contributes exactly one (inner,
18267    /// outer) vertex pair, both adjacent trapezoids reference it through the
18268    /// index list, and a closed ring's last segment wraps around to boundary
18269    /// zero's pair — one seam vertex pair instead of bitwise-equal copies.
18270    #[cfg(not(target_arch = "wasm32"))]
18271    #[test]
18272    fn arc_mesh_indices_share_boundary_vertices_and_wrap_closed_rings() {
18273        use cranpose_ui_graphics::ArcGeometry;
18274        let tau = cranpose_ui_graphics::TAU;
18275        // (sweep, expected boundary count relation): a closed ring wraps
18276        // (boundaries == segments), an open arc does not (segments + 1).
18277        for (sweep, closed) in [(tau, true), (1.9f32, false)] {
18278            let arc = ArcGeometry::new(
18279                Point::new(250.0, 250.0),
18280                80.0,
18281                100.0,
18282                0.7,
18283                sweep,
18284                StrokeCap::Round,
18285            );
18286            let mut converted = converted_arc_shape(arc, 1.0);
18287            // Inflate the quad box (and rect, for uv) far beyond the dilated
18288            // band so NO trapezoid is clipped: every segment must take the
18289            // shared-boundary path.
18290            converted.rect = [0.0, 0.0, 500.0, 500.0];
18291            converted.quad01 = [0.0, 0.0, 500.0, 0.0];
18292            converted.quad23 = [0.0, 500.0, 500.0, 500.0];
18293            let band = arc_mesh_band(&converted).expect("arc must qualify");
18294            let mut vertices = Vec::new();
18295            let mut indices = Vec::new();
18296            let segments = emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
18297                .expect("arc must mesh");
18298            let boundary_count = if closed { segments } else { segments + 1 };
18299            assert_eq!(
18300                vertices.len(),
18301                2 * boundary_count,
18302                "closed={closed}: every boundary owns exactly one (inner, outer) pair"
18303            );
18304            assert_eq!(indices.len(), 6 * segments);
18305            // Emission order is boundary order: boundary j's pair is
18306            // (2j, 2j + 1). Each segment must reference its own boundary and
18307            // its successor's — modulo the count exactly when closed.
18308            for j in 0..segments {
18309                let jb = (j + 1) % boundary_count;
18310                let (in_a, out_a) = (2 * j as u32, 2 * j as u32 + 1);
18311                let (in_b, out_b) = (2 * jb as u32, 2 * jb as u32 + 1);
18312                assert_eq!(
18313                    indices[6 * j..6 * j + 6],
18314                    [in_a, out_a, out_b, in_a, out_b, in_b],
18315                    "closed={closed}: segment {j} must share its boundary pairs"
18316                );
18317            }
18318            if closed {
18319                // The wrap made concrete: the final segment indexes boundary
18320                // zero's vertices.
18321                assert_eq!(indices[6 * segments - 1], 0);
18322            }
18323            // Inner vertices ride the dilated inner radius, outer vertices
18324            // the pushed-out chord radius — sanity that pairs are ordered
18325            // (inner, outer).
18326            for pair in vertices.chunks_exact(2) {
18327                let radius = |v: &MeshVertex| {
18328                    let dx = v.position[0] - 250.0;
18329                    let dy = v.position[1] - 250.0;
18330                    (dx * dx + dy * dy).sqrt()
18331                };
18332                assert!(radius(&pair[0]) < radius(&pair[1]));
18333            }
18334        }
18335    }
18336
18337    /// The private-vertex arm of the indexed topology: under the real
18338    /// tight-AABB quad the pushed-out chord vertices near the box edges get
18339    /// clipped, and those trapezoids must fan over vertices of their own —
18340    /// appended after the shared block, carrying clip-plane coordinates —
18341    /// while untouched diagonal trapezoids still share boundary pairs.
18342    #[cfg(not(target_arch = "wasm32"))]
18343    #[test]
18344    fn arc_mesh_clipped_segments_fan_over_private_vertices() {
18345        use cranpose_ui_graphics::ArcGeometry;
18346        let arc = ArcGeometry::new(
18347            Point::new(250.0, 250.0),
18348            80.0,
18349            100.0,
18350            0.0,
18351            cranpose_ui_graphics::TAU,
18352            StrokeCap::Round,
18353        );
18354        let converted = converted_arc_shape(arc, 1.0);
18355        let band = arc_mesh_band(&converted).expect("ring must qualify");
18356        let mut vertices = Vec::new();
18357        let mut indices = Vec::new();
18358        emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
18359            .expect("ring must mesh");
18360        // Sharing must actually happen: a shared boundary vertex is used by
18361        // both of its trapezoids' fans (at least three triangle references).
18362        let mut uses = vec![0usize; vertices.len()];
18363        for &index in &indices {
18364            uses[index as usize] += 1;
18365        }
18366        assert!(
18367            uses.iter().any(|&count| count >= 3),
18368            "some boundary vertices must be shared across trapezoids"
18369        );
18370        // Clipping must actually happen, and clipped polygons index private
18371        // vertices lying bitwise ON the quad box (the clipper writes the
18372        // bound coordinate exactly; boundary vertices never touch the box —
18373        // inner ones sit strictly inside, pushed-out outer ones strictly
18374        // outside near the extremes, where they are clipped).
18375        let [left, top, ..] = converted.quad01;
18376        let [.., right, bottom] = converted.quad23;
18377        let clipped: Vec<&MeshVertex> = vertices
18378            .iter()
18379            .filter(|vertex| {
18380                let [x, y] = vertex.position;
18381                x == left || x == right || y == top || y == bottom
18382            })
18383            .collect();
18384        assert!(
18385            !clipped.is_empty(),
18386            "the tight box must clip the pushed-out chord vertices"
18387        );
18388        // Fewer unique vertices than the non-indexed emitter's
18389        // three-per-triangle — the amplification this change removes.
18390        assert!(
18391            vertices.len() < indices.len(),
18392            "{} unique vertices should undercut {} triangle corners",
18393            vertices.len(),
18394            indices.len()
18395        );
18396    }
18397
18398    #[cfg(not(target_arch = "wasm32"))]
18399    #[test]
18400    fn arc_mesh_budget_overflow_falls_back_to_whole_slot_passthrough() {
18401        use cranpose_ui_graphics::ArcGeometry;
18402        // 100 large full rings mesh at the 64-segment ceiling (well over
18403        // 4 KB of vertices + indices each), far past the byte budget
18404        // max(100 * ~960 B, ~80 KB) — the builder must refuse the whole
18405        // slot rather than truncate.
18406        let arc = ArcGeometry::new(
18407            Point::new(2000.0, 2000.0),
18408            1690.0,
18409            1710.0,
18410            0.0,
18411            cranpose_ui_graphics::TAU,
18412            StrokeCap::Round,
18413        );
18414        let converted = converted_arc_shape(arc, 1.0);
18415        let shapes = vec![converted; 100];
18416        assert!(build_arc_mesh_vertices(&shapes).is_none());
18417    }
18418
18419    #[cfg(not(target_arch = "wasm32"))]
18420    #[test]
18421    fn shape_batch_limits_follow_uniform_binding_size() {
18422        // With a 160-byte ShapeData, even a desktop-class 64 KiB binding can no
18423        // longer hold the full compile-time cap: 65536 / 160 = 409 < 768.
18424        let desktop_shapes = 65536 / std::mem::size_of::<ShapeData>();
18425        assert_eq!(desktop_shapes, 409);
18426        assert_eq!(
18427            ShapeBatchLimits::desktop(),
18428            ShapeBatchLimits {
18429                max_shapes_per_batch: desktop_shapes.min(MAX_SHAPES_PER_BATCH),
18430                max_gradient_stops: MAX_GRADIENT_STOPS,
18431                storage: false,
18432            }
18433        );
18434
18435        // The 16 KiB downlevel/GLES minimum must shrink batches to fit:
18436        // 16384 / 160-byte ShapeData = 102 shapes, 16384 / 32-byte stop = 512.
18437        let downlevel = ShapeBatchLimits::for_uniform_binding_size(16384);
18438        assert_eq!(downlevel.max_shapes_per_batch, 16384 / 160);
18439        assert_eq!(downlevel.max_shapes_per_batch, 102);
18440        assert_eq!(downlevel.max_gradient_stops, 512.min(MAX_GRADIENT_STOPS));
18441        assert!(downlevel.max_shapes_per_batch * std::mem::size_of::<ShapeData>() <= 16384);
18442        assert!(downlevel.max_gradient_stops * std::mem::size_of::<GradientStop>() <= 16384);
18443
18444        // Degenerate limits must not produce zero-sized buffers.
18445        let tiny = ShapeBatchLimits::for_uniform_binding_size(1);
18446        assert_eq!(tiny.max_shapes_per_batch, 1);
18447        assert_eq!(tiny.max_gradient_stops, 1);
18448    }
18449
18450    #[test]
18451    fn storage_shape_batch_limits_uncap_the_batch_and_start_small() {
18452        // A typical 128 MiB storage binding hits the compile-time ceilings,
18453        // not the device limit: one batch holds the whole scene.
18454        let storage = ShapeBatchLimits::for_storage_binding_size(128 << 20);
18455        assert!(storage.storage);
18456        assert_eq!(storage.max_shapes_per_batch, MAX_SHAPES_PER_STORAGE_BATCH);
18457        assert_eq!(
18458            storage.max_gradient_stops,
18459            MAX_GRADIENT_STOPS_PER_STORAGE_BATCH
18460        );
18461
18462        // The buffers must not be allocated at the multi-megabyte ceiling up
18463        // front; they start small and grow on demand.
18464        assert_eq!(
18465            storage.initial_shape_capacity(),
18466            INITIAL_STORAGE_BATCH_CAPACITY
18467        );
18468        assert_eq!(
18469            storage.initial_gradient_capacity(),
18470            INITIAL_STORAGE_BATCH_CAPACITY
18471        );
18472        assert_eq!(
18473            storage.data_binding_type(),
18474            wgpu::BufferBindingType::Storage { read_only: true }
18475        );
18476        assert!(storage
18477            .data_buffer_usage()
18478            .contains(wgpu::BufferUsages::STORAGE));
18479
18480        // Uniform mode keeps its start-at-the-cap invariant: a uniform
18481        // binding smaller than the shader's fixed array fails validation.
18482        let uniform = ShapeBatchLimits::desktop();
18483        assert_eq!(
18484            uniform.initial_shape_capacity(),
18485            uniform.max_shapes_per_batch
18486        );
18487        assert_eq!(
18488            uniform.initial_gradient_capacity(),
18489            uniform.max_gradient_stops
18490        );
18491        assert_eq!(
18492            uniform.data_binding_type(),
18493            wgpu::BufferBindingType::Uniform
18494        );
18495        assert!(uniform
18496            .data_buffer_usage()
18497            .contains(wgpu::BufferUsages::UNIFORM));
18498    }
18499
18500    #[test]
18501    fn storage_shape_shader_swaps_the_arrays_to_runtime_sized_storage() {
18502        let source = shape_shader_source(ShapeBatchLimits::for_storage_binding_size(128 << 20));
18503        assert!(
18504            source.contains("var<storage, read> shape_data: array<ShapeData>;"),
18505            "storage-mode shader must declare a runtime-sized shape array"
18506        );
18507        assert!(
18508            source.contains("var<storage, read> gradient_stops: array<GradientStop>;"),
18509            "storage-mode shader must declare a runtime-sized gradient array"
18510        );
18511        assert!(
18512            !source.contains("var<uniform> shape_data"),
18513            "the uniform shape declaration must be fully replaced"
18514        );
18515        assert!(
18516            !source.contains("var<uniform> gradient_stops"),
18517            "the uniform gradient declaration must be fully replaced"
18518        );
18519        assert!(
18520            source.contains("var<storage, read> paint: array<vec4<f32>>;"),
18521            "storage-mode shader must declare the retained paint array"
18522        );
18523        assert!(
18524            source.contains("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)"),
18525            "storage-mode shader must read paint under the paint_select flag"
18526        );
18527        assert!(
18528            source.contains("fn vs_mesh("),
18529            "the storage rewrite must leave the retained-mesh vertex entry intact"
18530        );
18531        assert!(
18532            source.contains("fn vs_shape_instanced("),
18533            "the storage rewrite must leave the instanced-quad vertex entry intact"
18534        );
18535        assert_eq!(
18536            source
18537                .matches("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)")
18538                .count(),
18539            3,
18540            "vs_main, vs_shape_instanced and vs_mesh must all read paint under \
18541             the paint_select flag (meshless retained draws ride the instanced \
18542             entry when the selection is latched on)"
18543        );
18544
18545        // The storage variant is what native devices actually compile; it
18546        // must be valid WGSL, not just textually plausible.
18547        let module = naga::front::wgsl::parse_str(&source)
18548            .expect("storage-mode shape shader must parse as WGSL");
18549        naga::valid::Validator::new(
18550            naga::valid::ValidationFlags::all(),
18551            naga::valid::Capabilities::all(),
18552        )
18553        .validate(&module)
18554        .expect("storage-mode shape shader must validate for WebGPU");
18555    }
18556
18557    #[test]
18558    fn uniform_shape_shader_keeps_the_in_record_color_and_no_paint_binding() {
18559        // The base text serves WebGL-class uniform devices, which can bind
18560        // no storage buffers: the paint array and its select must exist only
18561        // in the storage-mode rewrite.
18562        for source in [
18563            Cow::Borrowed(shaders::SHADER),
18564            shape_shader_source(ShapeBatchLimits::desktop()),
18565        ] {
18566            assert!(
18567                !source.contains("paint: array"),
18568                "the uniform variant must not declare a paint array"
18569            );
18570            assert!(
18571                source.contains("output.color = shape.color;"),
18572                "the uniform variant must read the color from ShapeData \
18573                 (this literal is also what `shape_shader_source` rewrites)"
18574            );
18575            assert!(
18576                source.contains("paint_select: f32"),
18577                "SimilarityTransform must name the flag field in both \
18578                 variants; the Rust mirror is Pod and uploads raw bytes"
18579            );
18580        }
18581    }
18582
18583    #[test]
18584    fn shipped_shape_shader_array_length_fits_the_downlevel_uniform_floor() {
18585        // The wasm build uses `shaders::SHADER` verbatim, so its declared array
18586        // length is simultaneously the wasm batch cap and the WebGL binding
18587        // size. It must fit the 16 KiB floor exactly.
18588        assert!(
18589            shaders::SHADER.contains("array<ShapeData, 102>"),
18590            "shape.wgsl array length must stay in sync with \
18591             `shape_shader_source`'s replace string and MAX_SHAPES_PER_BATCH"
18592        );
18593        assert!(102 * std::mem::size_of::<ShapeData>() <= 16384);
18594        assert!(103 * std::mem::size_of::<ShapeData>() > 16384);
18595    }
18596
18597    #[test]
18598    fn glyph_atlas_doubles_on_overflow_and_stops_at_the_device_ceiling() {
18599        // Every overflow buys one doubling, so an app that needs the old fixed
18600        // 4096 atlas reaches it in three resets and then stays there.
18601        assert_eq!(
18602            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MIN_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
18603            1024
18604        );
18605        assert_eq!(
18606            next_glyph_atlas_size(2048, TEXT_GLYPH_ATLAS_MAX_SIZE),
18607            TEXT_GLYPH_ATLAS_MAX_SIZE
18608        );
18609        assert_eq!(
18610            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MAX_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
18611            TEXT_GLYPH_ATLAS_MAX_SIZE
18612        );
18613
18614        // A device that only grants `downlevel_defaults()`'s 2048 caps the
18615        // growth there rather than failing to create the texture.
18616        assert_eq!(next_glyph_atlas_size(1024, 2048), 2048);
18617        assert_eq!(next_glyph_atlas_size(2048, 2048), 2048);
18618
18619        // Never zero and never wrapping, whatever the ceiling turns out to be.
18620        assert_eq!(next_glyph_atlas_size(u32::MAX, 4096), 4096);
18621        assert_eq!(next_glyph_atlas_size(0, 0), 1);
18622    }
18623
18624    #[test]
18625    fn glyph_atlas_uv_rect_normalizes_against_the_atlas_it_was_placed_in() {
18626        // The atlas grows, so a UV is only meaningful together with the size of
18627        // the texture the entry came from. Reading the size off a constant is
18628        // what would make a grown atlas sample the wrong glyph.
18629        let entry = GlyphAtlasEntry {
18630            x: 128,
18631            y: 256,
18632            width: 16,
18633            height: 32,
18634        };
18635
18636        let small = glyph_atlas_uv_rect(entry, 512);
18637        let large = glyph_atlas_uv_rect(entry, 4096);
18638
18639        assert_eq!(small.min, [128.0 / 512.0, 256.0 / 512.0]);
18640        assert_eq!(large.min, [128.0 / 4096.0, 256.0 / 4096.0]);
18641        assert_eq!(small.max, [144.0 / 512.0, 288.0 / 512.0]);
18642        assert_eq!(large.max, [144.0 / 4096.0, 288.0 / 4096.0]);
18643    }
18644
18645    #[test]
18646    fn native_shape_shader_source_uses_native_batch_limits() {
18647        let limits = ShapeBatchLimits::desktop();
18648        let source = shape_shader_source(limits);
18649
18650        assert!(source.contains(&format!(
18651            "array<ShapeData, {}>",
18652            limits.max_shapes_per_batch
18653        )));
18654        assert!(source.contains(&format!(
18655            "array<GradientStop, {}>",
18656            limits.max_gradient_stops
18657        )));
18658        // Sanity: the substitution actually fired rather than silently leaving
18659        // the downlevel literal in place.
18660        assert!(!source.contains("array<ShapeData, 146>"));
18661    }
18662
18663    #[test]
18664    fn stroked_and_arc_shapes_batch_together_with_fills() {
18665        // Strokes and arcs ride the same pipeline, the same ShapeData array and
18666        // the same blend state as fills, so a run of mixed shapes must stay a
18667        // single batch. If they ever split the batch, a polar UI built from
18668        // hundreds of arcs would pay a draw call per arc — precisely the cost
18669        // this primitive exists to remove.
18670        let fill = test_shape(0, BlendMode::SrcOver);
18671        let mut stroked = test_shape(1, BlendMode::SrcOver);
18672        stroked.stroke = Some(
18673            cranpose_ui_graphics::Stroke::new(3.0)
18674                .with_cap(StrokeCap::Round)
18675                .with_join(StrokeJoin::Bevel),
18676        );
18677        let mut arc = test_shape(2, BlendMode::SrcOver);
18678        arc.arc = Some(cranpose_ui_graphics::ArcGeometry::new(
18679            Point::new(4.0, 4.0),
18680            2.0,
18681            4.0,
18682            0.0,
18683            1.0,
18684            StrokeCap::Round,
18685        ));
18686        let trailing_fill = test_shape(3, BlendMode::SrcOver);
18687
18688        assert!(!fill.has_stroke_or_arc());
18689        assert!(stroked.has_stroke_or_arc());
18690        assert!(arc.has_stroke_or_arc());
18691        assert!(!trailing_fill.has_stroke_or_arc());
18692
18693        let shapes = vec![fill, stroked, arc, trailing_fill];
18694        let ordered_items: Vec<_> = (0..shapes.len())
18695            .map(|index| (index, SegmentDrawItem::Shape(index)))
18696            .collect();
18697        let images = Vec::new();
18698
18699        let commands: Vec<_> = SegmentCommandIter::new(
18700            &ordered_items,
18701            &shapes,
18702            &images,
18703            ShapeBatchLimits::desktop(),
18704        )
18705        .collect();
18706
18707        assert_eq!(
18708            commands,
18709            vec![SegmentRenderCommand::DrawChunk(chunk(&[
18710                SegmentBatchPlan::Shape {
18711                    start: 0,
18712                    end: 4,
18713                    blend_mode: BlendMode::SrcOver,
18714                }
18715            ]))],
18716            "mixed fill/stroke/arc runs must stay one batch"
18717        );
18718    }
18719
18720    #[cfg(not(target_arch = "wasm32"))]
18721    #[test]
18722    fn native_segment_fusion_budget_allows_small_interleaved_chunks() {
18723        let ordered_items = vec![
18724            (0, SegmentDrawItem::Shape(0)),
18725            (1, SegmentDrawItem::Image(0)),
18726            (2, SegmentDrawItem::Text(0)),
18727            (3, SegmentDrawItem::Shape(1)),
18728        ];
18729        let shapes = vec![
18730            test_shape(0, BlendMode::SrcOver),
18731            test_shape(3, BlendMode::DstOut),
18732        ];
18733        let segment = chunk(&[
18734            SegmentBatchPlan::Shape {
18735                start: 0,
18736                end: 1,
18737                blend_mode: BlendMode::SrcOver,
18738            },
18739            SegmentBatchPlan::Image {
18740                start: 1,
18741                end: 2,
18742                blend_mode: BlendMode::SrcOver,
18743            },
18744            SegmentBatchPlan::Text { start: 2, end: 3 },
18745            SegmentBatchPlan::Shape {
18746                start: 3,
18747                end: 4,
18748                blend_mode: BlendMode::DstOut,
18749            },
18750        ]);
18751
18752        let budget = native_segment_fusion_budget(
18753            &ordered_items,
18754            &shapes,
18755            &segment,
18756            ShapeBatchLimits::desktop(),
18757        )
18758        .expect("budget should be valid")
18759        .expect("chunk should fit native fusion budget");
18760
18761        assert_eq!(
18762            budget,
18763            NativeSegmentFusionBudget {
18764                shape_count: 2,
18765                gradient_stop_count: 0,
18766            }
18767        );
18768    }
18769
18770    #[cfg(not(target_arch = "wasm32"))]
18771    #[test]
18772    fn native_segment_fusion_budget_rejects_shape_uniform_overflow() {
18773        let ordered_items: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
18774            .map(|index| (index, SegmentDrawItem::Shape(index)))
18775            .collect();
18776        let shapes: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
18777            .map(|index| test_shape(index, BlendMode::SrcOver))
18778            .collect();
18779        let segment = chunk(&[
18780            SegmentBatchPlan::Shape {
18781                start: 0,
18782                end: MAX_SHAPES_PER_BATCH,
18783                blend_mode: BlendMode::SrcOver,
18784            },
18785            SegmentBatchPlan::Shape {
18786                start: MAX_SHAPES_PER_BATCH,
18787                end: MAX_SHAPES_PER_BATCH + 1,
18788                blend_mode: BlendMode::SrcOver,
18789            },
18790        ]);
18791
18792        let budget = native_segment_fusion_budget(
18793            &ordered_items,
18794            &shapes,
18795            &segment,
18796            ShapeBatchLimits::desktop(),
18797        )
18798        .expect("valid plan");
18799
18800        assert_eq!(budget, None);
18801    }
18802
18803    #[cfg(not(target_arch = "wasm32"))]
18804    #[test]
18805    fn native_segment_fusion_budget_rejects_gradient_uniform_overflow() {
18806        let ordered_items = vec![(0, SegmentDrawItem::Shape(0))];
18807        let mut shape = test_shape(0, BlendMode::SrcOver);
18808        shape.brush = Brush::linear_gradient(vec![Color::BLACK; MAX_GRADIENT_STOPS + 1]);
18809        let shapes = vec![shape];
18810        let segment = chunk(&[SegmentBatchPlan::Shape {
18811            start: 0,
18812            end: 1,
18813            blend_mode: BlendMode::SrcOver,
18814        }]);
18815
18816        let budget = native_segment_fusion_budget(
18817            &ordered_items,
18818            &shapes,
18819            &segment,
18820            ShapeBatchLimits::desktop(),
18821        )
18822        .expect("valid plan");
18823
18824        assert_eq!(budget, None);
18825    }
18826
18827    #[cfg(not(target_arch = "wasm32"))]
18828    #[test]
18829    fn native_segment_fusion_partitions_shape_uniform_overflow() {
18830        // The uniform batch cap is derived from the device binding size and
18831        // the 112-byte ShapeData, not from the compile-time ceiling.
18832        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
18833        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
18834            .map(|index| (index, SegmentDrawItem::Shape(index)))
18835            .collect();
18836        let shapes: Vec<_> = (0..=desktop_batch_cap)
18837            .map(|index| test_shape(index, BlendMode::SrcOver))
18838            .collect();
18839        let segment = chunk(&[
18840            SegmentBatchPlan::Shape {
18841                start: 0,
18842                end: desktop_batch_cap,
18843                blend_mode: BlendMode::SrcOver,
18844            },
18845            SegmentBatchPlan::Shape {
18846                start: desktop_batch_cap,
18847                end: desktop_batch_cap + 1,
18848                blend_mode: BlendMode::SrcOver,
18849            },
18850        ]);
18851
18852        let partitions = native_segment_fusion_partitions(
18853            &ordered_items,
18854            &shapes,
18855            &segment,
18856            ShapeBatchLimits::desktop(),
18857        )
18858        .expect("valid plan")
18859        .expect("overflowing segment should be partitionable");
18860
18861        assert_eq!(partitions.len(), 2);
18862        assert_eq!(
18863            partitions[0],
18864            NativeSegmentFusionPartition {
18865                chunk: chunk(&[SegmentBatchPlan::Shape {
18866                    start: 0,
18867                    end: desktop_batch_cap,
18868                    blend_mode: BlendMode::SrcOver,
18869                }]),
18870                budget: NativeSegmentFusionBudget {
18871                    shape_count: desktop_batch_cap,
18872                    gradient_stop_count: 0,
18873                },
18874            }
18875        );
18876        assert_eq!(
18877            partitions[1],
18878            NativeSegmentFusionPartition {
18879                chunk: chunk(&[SegmentBatchPlan::Shape {
18880                    start: desktop_batch_cap,
18881                    end: desktop_batch_cap + 1,
18882                    blend_mode: BlendMode::SrcOver,
18883                }]),
18884                budget: NativeSegmentFusionBudget {
18885                    shape_count: 1,
18886                    gradient_stop_count: 0,
18887                },
18888            }
18889        );
18890    }
18891
18892    #[cfg(not(target_arch = "wasm32"))]
18893    #[test]
18894    fn native_segment_fusion_partitions_gradient_uniform_overflow() {
18895        const STOPS_PER_SHAPE: usize = MAX_GRADIENT_STOPS / 2;
18896        let ordered_items = vec![
18897            (0, SegmentDrawItem::Shape(0)),
18898            (1, SegmentDrawItem::Shape(1)),
18899            (2, SegmentDrawItem::Shape(2)),
18900        ];
18901        let mut shapes = Vec::new();
18902        for index in 0..3 {
18903            let mut shape = test_shape(index, BlendMode::SrcOver);
18904            shape.brush = Brush::linear_gradient(vec![Color::BLACK; STOPS_PER_SHAPE]);
18905            shapes.push(shape);
18906        }
18907        let segment = chunk(&[SegmentBatchPlan::Shape {
18908            start: 0,
18909            end: 3,
18910            blend_mode: BlendMode::SrcOver,
18911        }]);
18912
18913        let partitions = native_segment_fusion_partitions(
18914            &ordered_items,
18915            &shapes,
18916            &segment,
18917            ShapeBatchLimits::desktop(),
18918        )
18919        .expect("valid plan")
18920        .expect("overflowing gradient segment should be partitionable");
18921
18922        assert_eq!(partitions.len(), 2);
18923        assert_eq!(
18924            partitions[0],
18925            NativeSegmentFusionPartition {
18926                chunk: chunk(&[SegmentBatchPlan::Shape {
18927                    start: 0,
18928                    end: 2,
18929                    blend_mode: BlendMode::SrcOver,
18930                }]),
18931                budget: NativeSegmentFusionBudget {
18932                    shape_count: 2,
18933                    gradient_stop_count: MAX_GRADIENT_STOPS,
18934                },
18935            }
18936        );
18937        assert_eq!(
18938            partitions[1],
18939            NativeSegmentFusionPartition {
18940                chunk: chunk(&[SegmentBatchPlan::Shape {
18941                    start: 2,
18942                    end: 3,
18943                    blend_mode: BlendMode::SrcOver,
18944                }]),
18945                budget: NativeSegmentFusionBudget {
18946                    shape_count: 1,
18947                    gradient_stop_count: STOPS_PER_SHAPE,
18948                },
18949            }
18950        );
18951    }
18952
18953    #[cfg(not(target_arch = "wasm32"))]
18954    #[test]
18955    fn native_segment_fusion_accepts_layer_composite_chunks() {
18956        let ordered_items = vec![
18957            (0, SegmentDrawItem::Shape(0)),
18958            (1, SegmentDrawItem::Composite(0)),
18959            (2, SegmentDrawItem::ShaderComposite(0)),
18960            (3, SegmentDrawItem::Shape(1)),
18961        ];
18962        let shapes = vec![
18963            test_shape(0, BlendMode::SrcOver),
18964            test_shape(1, BlendMode::SrcOver),
18965        ];
18966        let segment = chunk(&[
18967            SegmentBatchPlan::Shape {
18968                start: 0,
18969                end: 1,
18970                blend_mode: BlendMode::SrcOver,
18971            },
18972            SegmentBatchPlan::Composite { start: 1, end: 2 },
18973            SegmentBatchPlan::ShaderComposite { start: 2, end: 3 },
18974            SegmentBatchPlan::Shape {
18975                start: 3,
18976                end: 4,
18977                blend_mode: BlendMode::SrcOver,
18978            },
18979        ]);
18980
18981        let partitions = native_segment_fusion_partitions(
18982            &ordered_items,
18983            &shapes,
18984            &segment,
18985            ShapeBatchLimits::desktop(),
18986        )
18987        .expect("valid plan")
18988        .expect("composites are drawable inside the native fused pass");
18989
18990        assert_eq!(
18991            partitions,
18992            vec![NativeSegmentFusionPartition {
18993                chunk: segment,
18994                budget: NativeSegmentFusionBudget {
18995                    shape_count: 2,
18996                    gradient_stop_count: 0,
18997                },
18998            }],
18999            "layer composites and shader composites must preserve order without forcing separate render passes"
19000        );
19001    }
19002
19003    #[cfg(not(target_arch = "wasm32"))]
19004    #[test]
19005    fn native_segment_fusion_partitions_preserve_non_shape_order_at_budget_boundary() {
19006        // The uniform batch cap is derived from the device binding size and
19007        // the 112-byte ShapeData, not from the compile-time ceiling.
19008        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
19009        let ordered_items: Vec<_> = (0..desktop_batch_cap)
19010            .map(|index| (index, SegmentDrawItem::Shape(index)))
19011            .chain([
19012                (desktop_batch_cap, SegmentDrawItem::Image(0)),
19013                (
19014                    desktop_batch_cap + 1,
19015                    SegmentDrawItem::Shape(desktop_batch_cap),
19016                ),
19017            ])
19018            .collect();
19019        let shapes: Vec<_> = (0..=desktop_batch_cap)
19020            .map(|index| test_shape(index, BlendMode::SrcOver))
19021            .collect();
19022        let segment = chunk(&[
19023            SegmentBatchPlan::Shape {
19024                start: 0,
19025                end: desktop_batch_cap,
19026                blend_mode: BlendMode::SrcOver,
19027            },
19028            SegmentBatchPlan::Image {
19029                start: desktop_batch_cap,
19030                end: desktop_batch_cap + 1,
19031                blend_mode: BlendMode::SrcOver,
19032            },
19033            SegmentBatchPlan::Shape {
19034                start: desktop_batch_cap + 1,
19035                end: desktop_batch_cap + 2,
19036                blend_mode: BlendMode::SrcOver,
19037            },
19038        ]);
19039
19040        let partitions = native_segment_fusion_partitions(
19041            &ordered_items,
19042            &shapes,
19043            &segment,
19044            ShapeBatchLimits::desktop(),
19045        )
19046        .expect("valid plan")
19047        .expect("overflowing segment should be partitionable");
19048
19049        assert_eq!(partitions.len(), 2);
19050        assert_eq!(
19051            partitions[0].chunk,
19052            chunk(&[
19053                SegmentBatchPlan::Shape {
19054                    start: 0,
19055                    end: desktop_batch_cap,
19056                    blend_mode: BlendMode::SrcOver,
19057                },
19058                SegmentBatchPlan::Image {
19059                    start: desktop_batch_cap,
19060                    end: desktop_batch_cap + 1,
19061                    blend_mode: BlendMode::SrcOver,
19062                },
19063            ])
19064        );
19065        assert_eq!(
19066            partitions[1].chunk,
19067            chunk(&[SegmentBatchPlan::Shape {
19068                start: desktop_batch_cap + 1,
19069                end: desktop_batch_cap + 2,
19070                blend_mode: BlendMode::SrcOver,
19071            }])
19072        );
19073    }
19074
19075    #[test]
19076    fn segment_command_iter_keeps_repeated_batch_kinds_in_one_chunk() {
19077        let ordered_items = vec![
19078            (0, SegmentDrawItem::Shape(0)),
19079            (1, SegmentDrawItem::Image(0)),
19080            (2, SegmentDrawItem::Shape(1)),
19081        ];
19082        let shapes = vec![
19083            test_shape(0, BlendMode::SrcOver),
19084            test_shape(2, BlendMode::DstOut),
19085        ];
19086        let images = vec![test_image(1, BlendMode::SrcOver)];
19087
19088        let commands: Vec<_> = SegmentCommandIter::new(
19089            &ordered_items,
19090            &shapes,
19091            &images,
19092            ShapeBatchLimits::desktop(),
19093        )
19094        .collect();
19095
19096        assert_eq!(
19097            commands,
19098            vec![SegmentRenderCommand::DrawChunk(chunk(&[
19099                SegmentBatchPlan::Shape {
19100                    start: 0,
19101                    end: 1,
19102                    blend_mode: BlendMode::SrcOver,
19103                },
19104                SegmentBatchPlan::Image {
19105                    start: 1,
19106                    end: 2,
19107                    blend_mode: BlendMode::SrcOver,
19108                },
19109                SegmentBatchPlan::Shape {
19110                    start: 2,
19111                    end: 3,
19112                    blend_mode: BlendMode::DstOut,
19113                },
19114            ]))]
19115        );
19116    }
19117
19118    #[test]
19119    fn segment_command_iter_splits_contiguous_shape_runs_at_uniform_batch_limit() {
19120        // The uniform batch cap is derived from the device binding size and
19121        // the 112-byte ShapeData, not from the compile-time ceiling.
19122        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
19123        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
19124            .map(|index| (index, SegmentDrawItem::Shape(index)))
19125            .collect();
19126        let shapes: Vec<_> = (0..=desktop_batch_cap)
19127            .map(|index| test_shape(index, BlendMode::SrcOver))
19128            .collect();
19129        let images = Vec::new();
19130
19131        let commands: Vec<_> = SegmentCommandIter::new(
19132            &ordered_items,
19133            &shapes,
19134            &images,
19135            ShapeBatchLimits::desktop(),
19136        )
19137        .collect();
19138
19139        assert_eq!(
19140            commands,
19141            vec![SegmentRenderCommand::DrawChunk(chunk(&[
19142                SegmentBatchPlan::Shape {
19143                    start: 0,
19144                    end: desktop_batch_cap,
19145                    blend_mode: BlendMode::SrcOver,
19146                },
19147                SegmentBatchPlan::Shape {
19148                    start: desktop_batch_cap,
19149                    end: desktop_batch_cap + 1,
19150                    blend_mode: BlendMode::SrcOver,
19151                },
19152            ]))]
19153        );
19154    }
19155
19156    #[test]
19157    fn segment_command_iter_keeps_shadows_as_explicit_boundaries() {
19158        let ordered_items = vec![
19159            (0, SegmentDrawItem::Shape(0)),
19160            (1, SegmentDrawItem::Shadow(0)),
19161            (2, SegmentDrawItem::Image(0)),
19162            (3, SegmentDrawItem::Text(0)),
19163        ];
19164        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
19165        let images = vec![test_image(2, BlendMode::SrcOver)];
19166
19167        let commands: Vec<_> = SegmentCommandIter::new(
19168            &ordered_items,
19169            &shapes,
19170            &images,
19171            ShapeBatchLimits::desktop(),
19172        )
19173        .collect();
19174
19175        assert_eq!(
19176            commands,
19177            vec![
19178                SegmentRenderCommand::DrawChunk(chunk(&[SegmentBatchPlan::Shape {
19179                    start: 0,
19180                    end: 1,
19181                    blend_mode: BlendMode::SrcOver,
19182                }])),
19183                SegmentRenderCommand::Shadow(0),
19184                SegmentRenderCommand::DrawChunk(chunk(&[
19185                    SegmentBatchPlan::Image {
19186                        start: 2,
19187                        end: 3,
19188                        blend_mode: BlendMode::SrcOver,
19189                    },
19190                    SegmentBatchPlan::Text { start: 3, end: 4 },
19191                ])),
19192            ]
19193        );
19194    }
19195
19196    #[test]
19197    fn staged_buffer_uploads_align_new_copies_to_copy_buffer_alignment() {
19198        let mut uploads = StagedBufferUploads::default();
19199        uploads.bytes.extend_from_slice(&[1, 2]);
19200
19201        uploads.stage(UploadTarget::ImageIndex, &[3, 4, 5, 6]);
19202
19203        assert_eq!(uploads.bytes, vec![1, 2, 0, 0, 3, 4, 5, 6]);
19204        assert_eq!(
19205            uploads.copies,
19206            vec![PendingBufferCopy {
19207                source_offset: 4,
19208                target_offset: 0,
19209                size: 4,
19210                target: UploadTarget::ImageIndex,
19211            }]
19212        );
19213    }
19214
19215    #[test]
19216    fn staged_buffer_uploads_ignore_empty_payloads() {
19217        let mut uploads = StagedBufferUploads::default();
19218
19219        uploads.stage(UploadTarget::Uniform, &[]);
19220
19221        assert!(uploads.is_empty());
19222        assert!(uploads.bytes.is_empty());
19223    }
19224
19225    #[test]
19226    fn staged_buffer_uploads_return_exact_payload_slice_for_copy() {
19227        let mut uploads = StagedBufferUploads::default();
19228        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
19229        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
19230
19231        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
19232        assert_eq!(uploads.payload_for_copy(uploads.copies[1]), &[5, 6, 7, 8]);
19233    }
19234
19235    #[test]
19236    fn staged_buffer_uploads_record_destination_offsets() {
19237        let mut uploads = StagedBufferUploads::default();
19238
19239        uploads.stage_at(UploadTarget::ImageIndex, 256, &[1, 2, 3, 4]);
19240
19241        assert_eq!(uploads.copies[0].target_offset, 256);
19242        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
19243    }
19244
19245    #[test]
19246    fn staged_buffer_uploads_truncate_restores_previous_state() {
19247        let mut uploads = StagedBufferUploads::default();
19248        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
19249        let bytes_len = uploads.bytes.len();
19250        let copies_len = uploads.copies.len();
19251        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
19252
19253        uploads.truncate(bytes_len, copies_len);
19254
19255        assert_eq!(uploads.bytes, vec![1, 2, 3, 4]);
19256        assert_eq!(uploads.copies.len(), 1);
19257    }
19258
19259    #[test]
19260    fn inner_shadow_composite_mask_uses_fill_shape_and_scale() {
19261        let mut fill = test_shape(0, BlendMode::SrcOver);
19262        fill.local_rect = Rect {
19263            x: 10.0,
19264            y: 12.0,
19265            width: 40.0,
19266            height: 20.0,
19267        };
19268        fill.shape = Some(RoundedCornerShape::uniform(6.0));
19269
19270        let cutout = test_shape(1, BlendMode::DstOut);
19271        let shadow = test_shadow_draw(vec![
19272            (fill, BlendMode::SrcOver),
19273            (cutout, BlendMode::DstOut),
19274        ]);
19275
19276        let mask = inner_shadow_composite_mask(&shadow, 1.5).expect("inner mask expected");
19277        assert_eq!(mask.rect, [15.0, 18.0, 60.0, 30.0]);
19278        assert_eq!(mask.radii, [9.0, 9.0, 9.0, 9.0]);
19279    }
19280
19281    #[test]
19282    fn inner_shadow_composite_mask_is_none_without_dst_out() {
19283        let fill = test_shape(0, BlendMode::SrcOver);
19284        let shadow = test_shadow_draw(vec![(fill, BlendMode::SrcOver)]);
19285        assert!(inner_shadow_composite_mask(&shadow, 1.0).is_none());
19286    }
19287
19288    #[test]
19289    fn render_effect_support_matrix_covers_all_variants() {
19290        let blur = RenderEffect::blur(4.0);
19291        let offset = RenderEffect::offset(2.0, 3.0);
19292        let shader = RenderEffect::runtime_shader(cranpose_ui_graphics::RuntimeShader::new(
19293            r#"
19294            @group(0) @binding(0) var input_texture: texture_2d<f32>;
19295            @group(0) @binding(1) var input_sampler: sampler;
19296            @group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
19297            struct VertexOutput {
19298                @builtin(position) position: vec4<f32>,
19299                @location(0) uv: vec2<f32>,
19300            }
19301            @vertex
19302            fn fullscreen_vs(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
19303                var output: VertexOutput;
19304                let x = f32(i32(vertex_index & 1u) * 2 - 1);
19305                let y = f32(i32(vertex_index >> 1u) * 2 - 1);
19306                output.uv = vec2<f32>(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
19307                output.position = vec4<f32>(x, y, 0.0, 1.0);
19308                return output;
19309            }
19310            @fragment
19311            fn effect_fs(input: VertexOutput) -> @location(0) vec4<f32> {
19312                return textureSample(input_texture, input_sampler, input.uv);
19313            }
19314            "#,
19315        ));
19316        let chain = blur.clone().then(offset.clone());
19317
19318        assert!(is_render_effect_supported(&blur));
19319        assert!(is_render_effect_supported(&offset));
19320        assert!(is_render_effect_supported(&shader));
19321        assert!(is_render_effect_supported(&chain));
19322    }
19323
19324    #[test]
19325    fn clip_to_bounds_propagates_visual_clip_to_all_descendant_shapes() {
19326        // Simulates: root → clip_to_bounds container → child with shapes above/below clip
19327        // All shapes inside the clip_to_bounds container must have a clip set.
19328        let container_local_bounds = Rect {
19329            x: 0.0,
19330            y: 0.0,
19331            width: 800.0,
19332            height: 500.0,
19333        };
19334        // Container is placed at y=50 in parent space via transform_to_parent
19335        let container_clip_in_parent = Rect {
19336            x: 0.0,
19337            y: 50.0,
19338            width: 800.0,
19339            height: 500.0,
19340        };
19341
19342        // Shape that extends above the clip boundary (scroll content scrolled up)
19343        let shape_above = RenderNode::Primitive(PrimitiveEntry {
19344            phase: PrimitivePhase::BeforeChildren,
19345            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19346                primitive: DrawPrimitive::Rect {
19347                    rect: Rect {
19348                        x: 10.0,
19349                        y: -30.0,
19350                        width: 100.0,
19351                        height: 40.0,
19352                    },
19353                    brush: Brush::solid(Color::WHITE),
19354                    stroke: None,
19355                },
19356                clip: None,
19357            }),
19358        });
19359
19360        // Shape within the clip boundary
19361        let shape_inside = RenderNode::Primitive(PrimitiveEntry {
19362            phase: PrimitivePhase::BeforeChildren,
19363            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19364                primitive: DrawPrimitive::Rect {
19365                    rect: Rect {
19366                        x: 10.0,
19367                        y: 100.0,
19368                        width: 100.0,
19369                        height: 40.0,
19370                    },
19371                    brush: Brush::solid(Color::WHITE),
19372                    stroke: None,
19373                },
19374                clip: None,
19375            }),
19376        });
19377
19378        // Shape below the clip boundary (scroll content below viewport)
19379        let shape_below = RenderNode::Primitive(PrimitiveEntry {
19380            phase: PrimitivePhase::BeforeChildren,
19381            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19382                primitive: DrawPrimitive::Rect {
19383                    rect: Rect {
19384                        x: 10.0,
19385                        y: 600.0,
19386                        width: 100.0,
19387                        height: 40.0,
19388                    },
19389                    brush: Brush::solid(Color::WHITE),
19390                    stroke: None,
19391                },
19392                clip: None,
19393            }),
19394        });
19395
19396        // Content child layer (represents scroll content, translated up by scroll offset)
19397        let mut content_layer = test_layer(
19398            Rect {
19399                x: 0.0,
19400                y: 0.0,
19401                width: 800.0,
19402                height: 1000.0,
19403            },
19404            vec![shape_above, shape_inside, shape_below],
19405        );
19406        content_layer.transform_to_parent = ProjectiveTransform::translation(0.0, -30.0);
19407        content_layer.translated_content_context = true;
19408
19409        // Clip container (e.g. TabContent with clip_to_bounds)
19410        let mut clip_container = test_layer(
19411            container_local_bounds,
19412            vec![RenderNode::Layer(Box::new(content_layer))],
19413        );
19414        clip_container.clip_to_bounds = true;
19415        clip_container.transform_to_parent = ProjectiveTransform::translation(0.0, 50.0);
19416
19417        // Root
19418        let root = test_layer(
19419            Rect {
19420                x: 0.0,
19421                y: 0.0,
19422                width: 800.0,
19423                height: 600.0,
19424            },
19425            vec![RenderNode::Layer(Box::new(clip_container))],
19426        );
19427
19428        let mut rect_cache = HashMap::new();
19429        let mut requirements_cache = HashMap::new();
19430        let collected =
19431            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19432
19433        assert_eq!(
19434            collected.scene.shapes.len(),
19435            3,
19436            "all three shapes should be flattened into the scene"
19437        );
19438
19439        for (i, shape) in collected.scene.shapes.iter().enumerate() {
19440            assert!(
19441                shape.clip.is_some(),
19442                "shape {} at rect {:?} must have a clip from clip_to_bounds container, but clip is None",
19443                i,
19444                shape.rect
19445            );
19446            let clip = shape.clip.unwrap();
19447            assert_eq!(
19448                clip, container_clip_in_parent,
19449                "shape {} clip should match the clip_to_bounds container bounds in parent space",
19450                i
19451            );
19452        }
19453    }
19454
19455    #[test]
19456    fn clip_to_bounds_culls_child_layers_outside_boundary() {
19457        // Reproduces the out-of-clip rendering bug: a child layer with
19458        // graphics_layer.clip=true (e.g. from rounded_surface()) positioned
19459        // entirely below the parent's clip_to_bounds boundary must be culled.
19460        // Before the fix, resolve_clip returned None for non-overlapping rects,
19461        // which downstream code interpreted as "no clipping" instead of "fully clipped",
19462        // causing invisible content to render everywhere.
19463
19464        let clip_container_bounds = Rect {
19465            x: 0.0,
19466            y: 0.0,
19467            width: 800.0,
19468            height: 500.0,
19469        };
19470
19471        let shape_in_card = RenderNode::Primitive(PrimitiveEntry {
19472            phase: PrimitivePhase::BeforeChildren,
19473            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19474                primitive: DrawPrimitive::Rect {
19475                    rect: Rect {
19476                        x: 0.0,
19477                        y: 0.0,
19478                        width: 300.0,
19479                        height: 80.0,
19480                    },
19481                    brush: Brush::solid(Color::WHITE),
19482                    stroke: None,
19483                },
19484                clip: None,
19485            }),
19486        });
19487
19488        // Card layer with graphics_layer.clip=true, positioned BELOW the clip boundary
19489        let mut card_outside = crate::test_support::layer_node(
19490            Rect {
19491                x: 0.0,
19492                y: 0.0,
19493                width: 300.0,
19494                height: 80.0,
19495            },
19496            ProjectiveTransform::identity(),
19497            GraphicsLayer {
19498                clip: true,
19499                ..GraphicsLayer::default()
19500            },
19501            vec![shape_in_card.clone()],
19502        );
19503        card_outside.transform_to_parent = ProjectiveTransform::translation(10.0, 600.0);
19504
19505        // Card layer with graphics_layer.clip=true, positioned INSIDE the clip boundary
19506        let mut card_inside = crate::test_support::layer_node(
19507            Rect {
19508                x: 0.0,
19509                y: 0.0,
19510                width: 300.0,
19511                height: 80.0,
19512            },
19513            ProjectiveTransform::identity(),
19514            GraphicsLayer {
19515                clip: true,
19516                ..GraphicsLayer::default()
19517            },
19518            vec![shape_in_card],
19519        );
19520        card_inside.transform_to_parent = ProjectiveTransform::translation(10.0, 100.0);
19521
19522        // Content layer holding both cards
19523        let content = test_layer(
19524            Rect {
19525                x: 0.0,
19526                y: 0.0,
19527                width: 800.0,
19528                height: 1000.0,
19529            },
19530            vec![
19531                RenderNode::Layer(Box::new(card_inside)),
19532                RenderNode::Layer(Box::new(card_outside)),
19533            ],
19534        );
19535
19536        // Clip container
19537        let mut clip_container = test_layer(
19538            clip_container_bounds,
19539            vec![RenderNode::Layer(Box::new(content))],
19540        );
19541        clip_container.clip_to_bounds = true;
19542
19543        // Root
19544        let root = test_layer(
19545            Rect {
19546                x: 0.0,
19547                y: 0.0,
19548                width: 800.0,
19549                height: 600.0,
19550            },
19551            vec![RenderNode::Layer(Box::new(clip_container))],
19552        );
19553
19554        let mut rect_cache = HashMap::new();
19555        let mut requirements_cache = HashMap::new();
19556        let collected =
19557            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19558
19559        assert_eq!(
19560            collected.scene.shapes.len(),
19561            1,
19562            "only the card inside the clip boundary should produce shapes; \
19563             the card outside must be culled entirely"
19564        );
19565
19566        let shape = &collected.scene.shapes[0];
19567        assert!(
19568            shape.clip.is_some(),
19569            "the visible card's shape must have a clip from clip_to_bounds"
19570        );
19571    }
19572
19573    #[test]
19574    fn flattened_layer_shadow_z_index_is_below_content() {
19575        // Shadow must render behind content. When a child layer with shadow_elevation
19576        // is flattened (no isolation), its shadow z-index must be lower than any
19577        // content z-index so shadow draws render first.
19578        let shape = RenderNode::Primitive(PrimitiveEntry {
19579            phase: PrimitivePhase::BeforeChildren,
19580            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19581                primitive: DrawPrimitive::Rect {
19582                    rect: Rect {
19583                        x: 0.0,
19584                        y: 0.0,
19585                        width: 100.0,
19586                        height: 100.0,
19587                    },
19588                    brush: Brush::solid(Color::WHITE),
19589                    stroke: None,
19590                },
19591                clip: None,
19592            }),
19593        });
19594
19595        let child_bounds = Rect {
19596            x: 0.0,
19597            y: 0.0,
19598            width: 100.0,
19599            height: 100.0,
19600        };
19601
19602        let child = crate::test_support::layer_node(
19603            child_bounds,
19604            ProjectiveTransform::translation(50.0, 50.0),
19605            GraphicsLayer {
19606                shadow_elevation: 20.0,
19607                ..GraphicsLayer::default()
19608            },
19609            vec![shape],
19610        );
19611
19612        let root = test_layer(
19613            Rect {
19614                x: 0.0,
19615                y: 0.0,
19616                width: 800.0,
19617                height: 600.0,
19618            },
19619            vec![RenderNode::Layer(Box::new(child))],
19620        );
19621
19622        let mut rect_cache = HashMap::new();
19623        let mut requirements_cache = HashMap::new();
19624        let collected =
19625            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19626
19627        assert!(
19628            !collected.scene.shadow_draws.is_empty(),
19629            "shadow_elevation > 0 must produce shadow draws"
19630        );
19631        let max_shadow_z = collected
19632            .scene
19633            .shadow_draws
19634            .iter()
19635            .map(|s| s.z_index)
19636            .max()
19637            .unwrap();
19638        let min_content_z = collected
19639            .scene
19640            .shapes
19641            .iter()
19642            .map(|s| s.z_index)
19643            .min()
19644            .unwrap();
19645        assert!(
19646            max_shadow_z < min_content_z,
19647            "shadow z-index ({}) must be less than content z-index ({}); \
19648             shadows must render behind their content",
19649            max_shadow_z,
19650            min_content_z
19651        );
19652    }
19653
19654    /// One retained bundle op key with the fields the invalidation tests
19655    /// vary; the rest stay representative constants.
19656    #[cfg(not(target_arch = "wasm32"))]
19657    fn bundle_op(slot: u32, epoch: Option<u64>, first: u32, last: u32) -> RetainedBundleOpKey {
19658        RetainedBundleOpKey {
19659            slot,
19660            capture_epoch: epoch,
19661            first,
19662            last,
19663            retained_index: slot,
19664            has_mesh: false,
19665        }
19666    }
19667
19668    #[cfg(not(target_arch = "wasm32"))]
19669    fn bundle_key(ops: &[RetainedBundleOpKey]) -> RetainedBundleKey {
19670        RetainedBundleKey { ops: ops.to_vec() }
19671    }
19672
19673    /// The same stretch on consecutive frames reuses its bundle: one
19674    /// rebuild, then cached executes.
19675    #[cfg(not(target_arch = "wasm32"))]
19676    #[test]
19677    fn retained_bundle_cache_reuses_stable_keys() {
19678        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
19679        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
19680        let key = bundle_key(&ops);
19681
19682        assert!(!cache.hit(&key), "empty cache must miss");
19683        cache.insert(key.clone(), 111);
19684        assert_eq!(cache.get(&key), Some(&111));
19685        cache.end_frame();
19686
19687        for _ in 0..3 {
19688            assert!(cache.hit(&bundle_key(&ops)), "stable key must stay cached");
19689            cache.end_frame();
19690        }
19691        assert_eq!(cache.stats(), (1, 3), "one rebuild, three cached executes");
19692    }
19693
19694    /// Recapture (epoch bump), span reorder, count change, range change and
19695    /// slot release each change the key, so a stale bundle can never satisfy
19696    /// the lookup.
19697    #[cfg(not(target_arch = "wasm32"))]
19698    #[test]
19699    fn retained_bundle_cache_invalidates_on_any_op_change() {
19700        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
19701        let variants: [Vec<RetainedBundleOpKey>; 5] = [
19702            // Recaptured slot 3: same id, bumped epoch.
19703            vec![bundle_op(3, Some(8), 0, 40), bundle_op(5, Some(9), 4, 12)],
19704            // Reordered stretch.
19705            vec![bundle_op(5, Some(9), 4, 12), bundle_op(3, Some(7), 0, 40)],
19706            // Op count changed.
19707            vec![bundle_op(3, Some(7), 0, 40)],
19708            // Draw range changed.
19709            vec![bundle_op(3, Some(7), 0, 41), bundle_op(5, Some(9), 4, 12)],
19710            // Slot 5 released: epoch gone.
19711            vec![bundle_op(3, Some(7), 0, 40), bundle_op(5, None, 4, 12)],
19712        ];
19713        for changed in variants {
19714            let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
19715            cache.insert(bundle_key(&ops), 111);
19716            cache.end_frame();
19717            assert!(
19718                !cache.hit(&RetainedBundleKey {
19719                    ops: changed.clone()
19720                }),
19721                "changed key {changed:?} must not reuse the stale bundle"
19722            );
19723        }
19724    }
19725
19726    /// Entries a frame does not use are evicted at its end — bundles pin
19727    /// slot buffers, so unused ones must not accumulate — and `clear` (the
19728    /// slot-release path) empties the cache outright.
19729    #[cfg(not(target_arch = "wasm32"))]
19730    #[test]
19731    fn retained_bundle_cache_evicts_unused_entries() {
19732        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
19733        let stale = bundle_key(&[bundle_op(1, Some(1), 0, 6)]);
19734        let live = bundle_key(&[bundle_op(2, Some(2), 0, 6)]);
19735        cache.insert(stale.clone(), 1);
19736        cache.insert(live.clone(), 2);
19737        cache.end_frame();
19738
19739        assert!(cache.hit(&live));
19740        cache.end_frame();
19741
19742        assert!(
19743            !cache.hit(&stale),
19744            "entry unused for a frame must have been evicted"
19745        );
19746        assert!(cache.hit(&live), "used entry must survive eviction");
19747
19748        cache.clear();
19749        assert!(!cache.hit(&live), "clear must drop every entry");
19750    }
19751}