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;
18#[cfg(test)]
19use crate::normalized_scene::{
20    build_scene_window, collect_layer_contents, collect_layer_contents_with_translation_context,
21    filtered_effect_layer_index, scene_bounds, SceneWindowSource,
22};
23#[cfg(test)]
24use crate::normalized_scene::{estimate_layer_surface_rect, motion_stable_capture_bounds};
25use crate::normalized_scene::{translate_quad, ChildLayerComposite, CollectedLayer};
26use crate::offscreen::OffscreenTarget;
27use crate::rect_to_quad;
28use crate::scene::{
29    BackdropLayer, CompositorScene, DrawOp, DrawOpKind, DrawShape, EffectLayer, ImageDraw,
30    RetainedDraw, ShadowDraw, SimilarityTransform, SnapAnchor, TextDraw,
31};
32use crate::shaders;
33#[cfg(test)]
34use crate::surface_executor::surface_target_size;
35use crate::surface_executor::{
36    apply_backdrop_layer_to_target as execute_apply_backdrop_layer_to_target,
37    axis_aligned_quad_rect, backdrop_underlay_is_covered_by_local_content,
38    canonicalize_device_coordinate, canonicalized_scaled_quad, canonicalized_scaled_rect,
39    composite_surface_to_view as execute_composite_surface_to_view, device_pixel_bounds_for_rect,
40    offscreen_byte_size, render_effect_layer_to_target as execute_render_effect_layer_to_target,
41    render_layer_surface as execute_render_layer_surface,
42    render_root_direct as execute_render_root_direct, root_direct_scene_events_are_supported,
43    scaled_quad, snap_delta_for_anchor, snap_motion_stable_dest_quad,
44    translation_stable_anchored_device_pixel_bounds, DevicePixelBounds, LayerSurfaceTexture,
45    SurfaceExecutionBackend,
46};
47#[cfg(test)]
48use crate::surface_executor::{clamp_effect_surface_scale, visible_layer_rect};
49#[cfg(test)]
50use crate::surface_plan::root_can_render_directly_cached;
51#[cfg(test)]
52use crate::surface_plan::{
53    composite_sample_mode_for_effect_layer, composite_sample_mode_for_requirements,
54    direct_translation, effect_layer_target_scale, layer_contains_descendant_backdrop,
55    layer_surface_requirements, layer_surface_requirements_cached, layer_surface_scale,
56    layer_surface_target_scale, layer_uses_external_backdrop_input, TranslatedContentAxes,
57};
58use crate::surface_plan::{LayerSurfaceRequest, TranslationRenderContext};
59#[cfg(test)]
60use crate::surface_requirements::SurfaceRequirement;
61use crate::surface_requirements::SurfaceRequirementSet;
62use crate::DebugCpuAllocationStats;
63use bytemuck::{Pod, Zeroable};
64#[cfg(any(not(target_arch = "wasm32"), test))]
65use cranpose_core::collections::map::HashMap;
66use cranpose_core::{hash::default as default_hash, NodeId};
67use cranpose_render_common::bounded_lru_cache::BoundedLruCache;
68use cranpose_render_common::geometry::blur_extent_margin;
69use cranpose_render_common::graph::quad_bounds;
70#[cfg(test)]
71use cranpose_render_common::graph::{
72    CachePolicy, LayerNode, PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform,
73    RenderNode,
74};
75use cranpose_render_common::raster_cache::LayerRasterCacheKey;
76#[cfg(test)]
77use cranpose_render_common::raster_cache::ScaleBucket;
78use cranpose_render_common::software_text_raster::{
79    collect_solid_text_atlas_run, measure_text_with_font,
80    rasterize_annotated_text_to_image_with_glyph_cache, rasterize_text_to_image_with_glyph_cache,
81    SoftwareGlyphAtlasGlyph, SoftwareGlyphAtlasKey, SoftwareGlyphAtlasPlacement,
82    SoftwareGlyphAtlasRunGlyph, SoftwareGlyphRasterCache, SoftwareTextFontSet,
83};
84#[cfg(test)]
85use cranpose_ui_graphics::GraphicsLayer;
86use cranpose_ui_graphics::{
87    BlendMode, Brush, Color, ColorFilter, FxHasher, ImageBitmap, ImageSampling, Point, Rect,
88    RenderEffect, RenderHash, RuntimeShader, StrokeCap, StrokeJoin, TileMode,
89};
90use std::borrow::Cow;
91use std::cell::Cell;
92use std::hash::{Hash, Hasher};
93use std::ops::Range;
94use std::rc::Rc;
95#[cfg(not(target_arch = "wasm32"))]
96use std::sync::atomic::{AtomicUsize, Ordering};
97use std::sync::{mpsc, Arc};
98use std::time::Duration;
99use web_time::Instant;
100
101use crate::gpu_stats;
102use crate::gpu_stats::gpu_stats_enabled;
103use crate::pipeline::push_layer_shadow;
104
105/// Must equal the `array<ShapeData, N>` literal in `shape.wgsl`: on wasm the
106/// shader source is used verbatim, so a larger batch cap here would index past
107/// the declared array. 102 x 160-byte ShapeData = 16320 bytes, the most that
108/// fits WebGL's 16 KiB uniform-binding floor.
109#[cfg(target_arch = "wasm32")]
110const MAX_SHAPES_PER_BATCH: usize = 102;
111#[cfg(not(target_arch = "wasm32"))]
112const MAX_SHAPES_PER_BATCH: usize = 768;
113#[cfg(target_arch = "wasm32")]
114const MAX_GRADIENT_STOPS: usize = 256;
115#[cfg(not(target_arch = "wasm32"))]
116const MAX_GRADIENT_STOPS: usize = 1024;
117
118/// Per-pass ceilings when the shape and gradient arrays live in storage
119/// buffers instead of uniforms. These are not hardware limits — storage
120/// bindings are hundreds of megabytes everywhere — they bound worst-case
121/// buffer growth: 65 536 shapes is a 7 MiB shape buffer and a 12 MiB vertex
122/// buffer, far past any real scene, while still forcing a batch split before
123/// a pathological one can ask for gigabytes.
124#[cfg(not(target_arch = "wasm32"))]
125const MAX_SHAPES_PER_STORAGE_BATCH: usize = 1 << 16;
126#[cfg(not(target_arch = "wasm32"))]
127const MAX_GRADIENT_STOPS_PER_STORAGE_BATCH: usize = 1 << 16;
128
129/// How many shapes/stops the storage-mode buffers start out sized for. In
130/// uniform mode the initial capacity must equal the cap (a uniform binding
131/// smaller than the shader's fixed-length array fails validation), but a
132/// runtime-sized storage array binds at any size, so start small and let
133/// `ensure_capacity` double toward the cap as scenes demand.
134#[cfg(not(target_arch = "wasm32"))]
135const INITIAL_STORAGE_BATCH_CAPACITY: usize = 1024;
136
137/// Shape/gradient batch capacities derived from the actual device limits.
138///
139/// Where storage buffers are available (any real Vulkan/Metal/D3D device, and
140/// GL only when it exposes SSBOs to fragment shaders) the arrays are bound as
141/// read-only storage and a whole scene fits one batch. Otherwise they fall
142/// back to uniform arrays: the compile-time `MAX_*` constants assume
143/// desktop-class 64 KiB uniform bindings, while Android downlevel and
144/// GLES-class devices may only offer the 16 KiB spec minimum; sizing the
145/// buffers (and the matching WGSL array lengths) past
146/// `max_uniform_buffer_binding_size` makes the very first "Shape Bind Group"
147/// fail validation and aborts the app.
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149struct ShapeBatchLimits {
150    max_shapes_per_batch: usize,
151    max_gradient_stops: usize,
152    storage: bool,
153}
154
155impl ShapeBatchLimits {
156    fn for_device(device: &wgpu::Device) -> Self {
157        let limits = device.limits();
158        #[cfg(not(target_arch = "wasm32"))]
159        if limits.max_storage_buffers_per_shader_stage >= 2 {
160            return Self::for_storage_binding_size(limits.max_storage_buffer_binding_size);
161        }
162        Self::for_uniform_binding_size(limits.max_uniform_buffer_binding_size)
163    }
164
165    fn for_uniform_binding_size(max_uniform_buffer_binding_size: u64) -> Self {
166        let binding = max_uniform_buffer_binding_size as usize;
167        Self {
168            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
169                .clamp(1, MAX_SHAPES_PER_BATCH),
170            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
171                .clamp(1, MAX_GRADIENT_STOPS),
172            storage: false,
173        }
174    }
175
176    #[cfg(not(target_arch = "wasm32"))]
177    fn for_storage_binding_size(max_storage_buffer_binding_size: u64) -> Self {
178        let binding = max_storage_buffer_binding_size as usize;
179        Self {
180            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
181                .clamp(1, MAX_SHAPES_PER_STORAGE_BATCH),
182            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
183                .clamp(1, MAX_GRADIENT_STOPS_PER_STORAGE_BATCH),
184            storage: true,
185        }
186    }
187
188    fn initial_shape_capacity(&self) -> usize {
189        #[cfg(not(target_arch = "wasm32"))]
190        if self.storage {
191            return self
192                .max_shapes_per_batch
193                .min(INITIAL_STORAGE_BATCH_CAPACITY);
194        }
195        self.max_shapes_per_batch
196    }
197
198    fn initial_gradient_capacity(&self) -> usize {
199        #[cfg(not(target_arch = "wasm32"))]
200        if self.storage {
201            return self.max_gradient_stops.min(INITIAL_STORAGE_BATCH_CAPACITY);
202        }
203        self.max_gradient_stops
204    }
205
206    fn data_buffer_usage(&self) -> wgpu::BufferUsages {
207        if self.storage {
208            wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST
209        } else {
210            wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST
211        }
212    }
213
214    fn data_binding_type(&self) -> wgpu::BufferBindingType {
215        if self.storage {
216            wgpu::BufferBindingType::Storage { read_only: true }
217        } else {
218            wgpu::BufferBindingType::Uniform
219        }
220    }
221
222    #[cfg(test)]
223    fn desktop() -> Self {
224        Self::for_uniform_binding_size(wgpu::Limits::default().max_uniform_buffer_binding_size)
225    }
226}
227#[cfg(target_arch = "wasm32")]
228const HARD_MAX_BUFFER_MB: usize = 64; // Maximum 64MB per buffer (image vertex/index only)
229const MAX_SHADOW_SURFACE_CACHE_ITEMS: usize = 512;
230// Sized for HiDPI: a 4K fractional-scale screen full of shadowed panels needs
231// ~10-15 rasters of 4-12MB each; a 64MB budget made the large entries evict
232// each other every frame during scroll, re-blurring tens of megapixels.
233const MAX_SHADOW_SURFACE_CACHE_BYTES: u64 = 192 * 1024 * 1024;
234const MAX_TEXT_IMAGE_CACHE_ITEMS: usize = 1024;
235const MAX_TEXT_GLYPH_MASK_CACHE_ITEMS: usize = 8192;
236const MAX_TEXT_GLYPH_ATLAS_ITEMS: usize = 8192;
237const MAX_TEXT_GLYPH_RUN_CACHE_ITEMS: usize = 1024;
238#[cfg(not(target_arch = "wasm32"))]
239const MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS: usize = 1024;
240#[cfg(not(target_arch = "wasm32"))]
241const MIN_RETAINED_TEXT_GLYPH_QUADS: usize = 192;
242#[cfg(not(target_arch = "wasm32"))]
243const OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS: f64 = 0.75;
244#[cfg(not(target_arch = "wasm32"))]
245const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES: usize = 2;
246#[cfg(not(target_arch = "wasm32"))]
247const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS: usize = 160;
248#[cfg(not(target_arch = "wasm32"))]
249const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS: usize = 160;
250/// Side length the glyph atlas starts at, and the one it doubles towards.
251///
252/// The atlas is square and `R8Unorm`, so the maximum is a 16 MiB texture. That
253/// was also the starting size until it became the single largest resource the
254/// renderer allocated: a 454x454 watch face draws a couple of hundred distinct
255/// glyphs and needs well under a megabyte of them, but paid the full 16 MiB at
256/// renderer construction, before a single glyph had been rastered. Starting at
257/// `MIN` and doubling on overflow (see `TextGlyphAtlas::reset`) costs at most
258/// three extra resets for a workload that genuinely needs the large atlas —
259/// which then behaves exactly as the fixed 4096 atlas did — and costs a
260/// text-light screen 256 KiB instead of 16 MiB, permanently.
261const TEXT_GLYPH_ATLAS_MIN_SIZE: u32 = 512;
262const TEXT_GLYPH_ATLAS_MAX_SIZE: u32 = 4096;
263const TEXT_GLYPH_ATLAS_PADDING: u32 = 1;
264const MAX_TEXT_LINE_INDEX_CACHE_ITEMS: usize = 512;
265const MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER: usize = 2;
266const MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES: usize = 128;
267const CACHE_MISS_WARMUP_FRAMES: u8 = 1;
268pub(crate) const CLEAR_COLOR: wgpu::Color = wgpu::Color {
269    r: cranpose_render_common::FRAME_CLEAR_COLOR[0] as f64,
270    g: cranpose_render_common::FRAME_CLEAR_COLOR[1] as f64,
271    b: cranpose_render_common::FRAME_CLEAR_COLOR[2] as f64,
272    a: cranpose_render_common::FRAME_CLEAR_COLOR[3] as f64,
273};
274#[cfg(not(target_arch = "wasm32"))]
275const INITIAL_UPLOAD_BUFFER_BYTES: u64 = 4 * 1024;
276#[cfg(not(target_arch = "wasm32"))]
277const INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS: usize = 128;
278const MAX_TEXTURE_CACHE_ITEMS: usize = 256;
279/// Byte ceiling for `image_texture_cache` (see `CachedImageTexture::bytes`).
280/// Generous enough for a screenful of full-page images plus thumbnails;
281/// small enough that a camera preview stream can never pin gigabytes.
282const MAX_IMAGE_TEXTURE_CACHE_BYTES: usize = 256 * 1024 * 1024;
283const RETAINED_STAGED_UPLOAD_BYTES: usize = 256 * 1024;
284const RETAINED_STAGED_UPLOAD_COPIES: usize = 128;
285pub(crate) const RETAINED_LAYER_REQUIREMENTS_CAPACITY: usize = 512;
286const DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS: f64 = 4.0;
287#[cfg(not(target_arch = "wasm32"))]
288static SEGMENT_DIAG_LINES: AtomicUsize = AtomicUsize::new(0);
289// Reclaim oversized text scratch allocations only after a meaningful 4x collapse
290// from a previously large frame; smaller swings are left alone to avoid churn.
291
292fn wgpu_render_stage_telemetry_threshold_ms() -> Option<f64> {
293    static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
294    *THRESHOLD_MS.get_or_init(|| {
295        let explicit = std::env::var("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY_MS")
296            .ok()
297            .and_then(|value| value.parse::<f64>().ok())
298            .filter(|value| value.is_finite() && *value >= 0.0);
299        explicit.or_else(|| {
300            std::env::var_os("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY")
301                .is_some()
302                .then_some(DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS)
303        })
304    })
305}
306
307pub(crate) fn instant_ms(start: Instant, end: Instant) -> f64 {
308    end.duration_since(start).as_secs_f64() * 1000.0
309}
310
311pub(crate) fn should_log_wgpu_render_stage(start: Instant, end: Instant) -> Option<f64> {
312    let threshold_ms = wgpu_render_stage_telemetry_threshold_ms()?;
313    let total_ms = instant_ms(start, end);
314    (total_ms >= threshold_ms).then_some(total_ms)
315}
316
317fn admit_layer_surface_cache_miss_impl(
318    key: &LayerRasterCacheKey,
319    observed_scene_range_misses: &mut BoundedLruCache<LayerRasterCacheKey, ()>,
320) -> bool {
321    if !key.is_scene_range() {
322        return true;
323    }
324    if observed_scene_range_misses.contains(key) {
325        return true;
326    }
327    observed_scene_range_misses.put(*key, ());
328    false
329}
330
331#[cfg(test)]
332fn first_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
333    let mut observed_scene_range_misses =
334        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
335    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
336}
337
338#[cfg(test)]
339fn repeated_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
340    let mut observed_scene_range_misses =
341        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
342    let _ = admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses);
343    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
344}
345
346pub static PRESENTED_FRAMES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
347
348pub fn frames_presented() -> u64 {
349    PRESENTED_FRAMES.load(std::sync::atomic::Ordering::Relaxed)
350}
351
352fn frame_stats_need_warmup_frame(snapshot: &gpu_stats::FrameStatsSnapshot) -> bool {
353    snapshot.layer_cache_misses > 0
354        || snapshot.shadow_shape_cache_misses > 0
355        || snapshot.text_image_cache_misses > 0
356        || snapshot.text_glyph_atlas_misses > 0
357}
358
359fn update_frame_warmup_budget(pending_frames: &mut u8, snapshot: &gpu_stats::FrameStatsSnapshot) {
360    if *pending_frames > 0 {
361        *pending_frames = pending_frames.saturating_sub(1);
362    } else if frame_stats_need_warmup_frame(snapshot) {
363        *pending_frames = CACHE_MISS_WARMUP_FRAMES;
364    }
365}
366
367fn text_atlas_fallback_diag_enabled() -> bool {
368    cranpose_core::env_flag!("CRANPOSE_TEXT_ATLAS_FALLBACK_DIAG")
369}
370
371fn text_glyph_run_diag_enabled() -> bool {
372    cranpose_core::env_flag!("CRANPOSE_TEXT_GLYPH_RUN_DIAG")
373}
374
375fn root_direct_diag_enabled() -> bool {
376    cranpose_core::env_flag!("CRANPOSE_ROOT_DIRECT_DIAG")
377}
378
379fn scene_layer_events_precede_z(scene: &CompositorScene, z_index: usize) -> bool {
380    scene
381        .effect_layers
382        .iter()
383        .any(|layer| layer.z_start < z_index && 0 < layer.z_end)
384        || scene
385            .backdrop_layers
386            .iter()
387            .any(|layer| layer.z_index < z_index)
388}
389
390fn direct_root_child_can_be_replayed_into_later_underlay(child: &ChildLayerComposite) -> bool {
391    child.backdrop.is_none()
392        && !child.has_effect
393        && child.shadow_draws.is_empty()
394        && axis_aligned_quad_rect(child.dest_quad).is_some()
395}
396
397fn rects_overlap(a: Rect, b: Rect) -> bool {
398    let a_right = a.x + a.width;
399    let a_bottom = a.y + a.height;
400    let b_right = b.x + b.width;
401    let b_bottom = b.y + b.height;
402    a.x < b_right && b.x < a_right && a.y < b_bottom && b.y < a_bottom
403}
404
405pub(crate) fn direct_root_child_underlays_are_supported(collected: &CollectedLayer) -> bool {
406    for (child_index, child) in collected.child_layers.iter().enumerate() {
407        if child.backdrop.is_some() {
408            if root_direct_diag_enabled() {
409                log::warn!(
410                    "[root-direct-diag] reject self-backdrop child node={:?}",
411                    child.node_id
412                );
413            }
414            return false;
415        }
416        if child.needs_nested_underlay {
417            let Some(dest_rect) = axis_aligned_quad_rect(child.dest_quad) else {
418                if root_direct_diag_enabled() {
419                    log::warn!(
420                        "[root-direct-diag] reject projective underlay child node={:?}",
421                        child.node_id
422                    );
423                }
424                return false;
425            };
426            let translation_only = (dest_rect.width - child.logical_rect.width).abs() <= 0.001
427                && (dest_rect.height - child.logical_rect.height).abs() <= 0.001;
428            let unsupported_preceding_child_layer = collected.child_layers[..child_index]
429                .iter()
430                .any(|preceding| {
431                    if direct_root_child_can_be_replayed_into_later_underlay(preceding) {
432                        return false;
433                    }
434                    axis_aligned_quad_rect(preceding.dest_quad)
435                        .is_none_or(|preceding_rect| rects_overlap(preceding_rect, dest_rect))
436                });
437            let preceding_scene_events =
438                scene_layer_events_precede_z(&collected.scene, child.z_index);
439            if unsupported_preceding_child_layer || preceding_scene_events || !translation_only {
440                if root_direct_diag_enabled() {
441                    log::warn!(
442                        "[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})",
443                        child.node_id,
444                        unsupported_preceding_child_layer,
445                        preceding_scene_events,
446                        translation_only,
447                        dest_rect.x,
448                        dest_rect.y,
449                        dest_rect.width,
450                        dest_rect.height,
451                        child.logical_rect.x,
452                        child.logical_rect.y,
453                        child.logical_rect.width,
454                        child.logical_rect.height
455                    );
456                }
457                return false;
458            }
459        }
460    }
461    true
462}
463
464#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
465struct ShadowSurfaceCacheKey {
466    content_hash: u64,
467    pixel_size: [u32; 2],
468    root_scale_bits: u32,
469    blur_radius_bits: u32,
470}
471
472struct CachedShadowSurface {
473    target: Rc<OffscreenTarget>,
474    byte_size: u64,
475}
476
477struct CachedShadowComposite {
478    source: Rc<OffscreenTarget>,
479    scissor: Option<(u32, u32, u32, u32)>,
480    rounded_mask: Option<RoundedCompositeMask>,
481    dest_viewport: Option<(f32, f32, f32, f32)>,
482}
483
484impl CachedShadowComposite {
485    fn batch_item(&self) -> CompositeBatchItem<'_> {
486        CompositeBatchItem {
487            source: &self.source,
488            alpha: 1.0,
489            scissor: self.scissor,
490            rounded_mask: self.rounded_mask,
491            blend_mode: BlendMode::SrcOver,
492            dest_viewport: self.dest_viewport,
493            source_viewport: None,
494            sample_mode: CompositeSampleMode::Nearest,
495        }
496    }
497}
498
499#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
500struct TextImageCacheKey(u64);
501
502struct CachedTextImage {
503    image: ImageBitmap,
504}
505
506#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
507struct TextGlyphRunCacheKey(u64);
508
509#[derive(Clone, Copy)]
510struct CachedTextGlyphQuad {
511    x: i32,
512    y: i32,
513    width: usize,
514    height: usize,
515    color: (f32, f32, f32, f32),
516    uv: ImageUvRect,
517}
518
519struct CachedTextGlyphRun {
520    glyphs: Rc<[SoftwareGlyphAtlasPlacement]>,
521    quads: Option<Rc<[CachedTextGlyphQuad]>>,
522    atlas_generation: u64,
523}
524
525const TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER: f32 = 2.0;
526
527#[cfg(not(target_arch = "wasm32"))]
528struct CachedGpuTextGlyphRun {
529    vertex_buffer: wgpu::Buffer,
530    index_buffer: wgpu::Buffer,
531    index_count: u32,
532    atlas_generation: u64,
533}
534
535#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
536struct TextLineIndexCacheKey(usize);
537
538struct CachedTextLineIndex {
539    text: std::sync::Weak<cranpose_ui::text::RenderString>,
540    len: usize,
541    starts: Rc<[usize]>,
542}
543
544struct TextLineIndexCache {
545    entries: BoundedLruCache<TextLineIndexCacheKey, CachedTextLineIndex>,
546}
547
548impl TextLineIndexCache {
549    fn new(capacity: usize) -> Self {
550        Self {
551            entries: BoundedLruCache::with_capacity_at_least_one(capacity),
552        }
553    }
554
555    fn line_starts(&mut self, text: &Arc<cranpose_ui::text::RenderString>) -> Rc<[usize]> {
556        let key = TextLineIndexCacheKey(Arc::as_ptr(text) as usize);
557        if let Some(cached) = self.entries.get(&key) {
558            if cached.len == text.text.len()
559                && cached
560                    .text
561                    .upgrade()
562                    .is_some_and(|cached_text| Arc::ptr_eq(&cached_text, text))
563            {
564                return cached.starts.clone();
565            }
566        }
567
568        let starts = Rc::<[usize]>::from(line_start_offsets(text.text.as_str()));
569        self.entries.put(
570            key,
571            CachedTextLineIndex {
572                text: Arc::downgrade(text),
573                len: text.text.len(),
574                starts: starts.clone(),
575            },
576        );
577        starts
578    }
579}
580
581#[derive(Clone, Copy, Debug, PartialEq)]
582struct ShapeShadowSurfacePlan {
583    source_device_bounds: DevicePixelBounds,
584    processing_scissor: Option<(u32, u32, u32, u32)>,
585    pixel_radius: f32,
586}
587
588#[derive(Default)]
589struct RendererWarningState {
590    unsupported_effect_reported: Cell<bool>,
591}
592
593impl RendererWarningState {
594    fn warn_unsupported_effect_once(&self) {
595        if !self.unsupported_effect_reported.replace(true) {
596            log::warn!(
597                "WGPU renderer received an unsupported RenderEffect variant; falling back to passthrough compositing"
598            );
599        }
600    }
601}
602
603fn is_blend_mode_supported(mode: BlendMode) -> bool {
604    matches!(mode, BlendMode::SrcOver | BlendMode::DstOut)
605}
606
607fn blend_state_for_mode(mode: BlendMode) -> wgpu::BlendState {
608    match mode {
609        BlendMode::DstOut => wgpu::BlendState {
610            color: wgpu::BlendComponent {
611                src_factor: wgpu::BlendFactor::Zero,
612                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
613                operation: wgpu::BlendOperation::Add,
614            },
615            alpha: wgpu::BlendComponent {
616                src_factor: wgpu::BlendFactor::Zero,
617                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
618                operation: wgpu::BlendOperation::Add,
619            },
620        },
621        _ => wgpu::BlendState::ALPHA_BLENDING,
622    }
623}
624
625fn supported_blend_mode(mode: BlendMode) -> BlendMode {
626    if is_blend_mode_supported(mode) {
627        return mode;
628    }
629
630    BlendMode::SrcOver
631}
632
633fn direct_shader_composite_viewport(
634    alpha: f32,
635    blend_mode: BlendMode,
636    dest_viewport: Option<(f32, f32, f32, f32)>,
637    sample_mode: CompositeSampleMode,
638    source_size: (u32, u32),
639) -> Option<(f32, f32, f32, f32)> {
640    if alpha != 1.0 || supported_blend_mode(blend_mode) != BlendMode::SrcOver {
641        return None;
642    }
643    let viewport = dest_viewport?;
644    if viewport.2 <= 0.0 || viewport.3 <= 0.0 {
645        return None;
646    }
647    match sample_mode {
648        CompositeSampleMode::Linear | CompositeSampleMode::Nearest => Some(viewport),
649        CompositeSampleMode::Box4
650            if shader_composite_preserves_source_pixel_grid(viewport, source_size) =>
651        {
652            Some(viewport)
653        }
654        CompositeSampleMode::Box4 => None,
655    }
656}
657
658fn shader_composite_preserves_source_pixel_grid(
659    viewport: (f32, f32, f32, f32),
660    source_size: (u32, u32),
661) -> bool {
662    const EPSILON: f32 = 0.01;
663    let (x, y, width, height) = viewport;
664    let (source_width, source_height) = source_size;
665    (x - x.round()).abs() <= EPSILON
666        && (y - y.round()).abs() <= EPSILON
667        && (width - source_width as f32).abs() <= EPSILON
668        && (height - source_height as f32).abs() <= EPSILON
669}
670
671type DirectShaderTailComposite<'a> = (&'a RenderEffect, &'a RuntimeShader, (f32, f32, f32, f32));
672
673fn direct_shader_tail_composite(
674    effect: &RenderEffect,
675    alpha: f32,
676    blend_mode: BlendMode,
677    dest_viewport: Option<(f32, f32, f32, f32)>,
678    sample_mode: CompositeSampleMode,
679    source_size: (u32, u32),
680) -> Option<DirectShaderTailComposite<'_>> {
681    let viewport = direct_shader_composite_viewport(
682        alpha,
683        blend_mode,
684        dest_viewport,
685        sample_mode,
686        source_size,
687    )?;
688    let RenderEffect::Chain { first, second } = effect else {
689        return None;
690    };
691    let RenderEffect::Shader { shader } = second.as_ref() else {
692        return None;
693    };
694    Some((first.as_ref(), shader, viewport))
695}
696
697fn hash_f32_for_cache<H: Hasher>(value: f32, state: &mut H) {
698    value.to_bits().hash(state);
699}
700
701fn hash_text_raster_geometry_for_cache<H: Hasher>(
702    rect: Rect,
703    static_text_motion: bool,
704    state: &mut H,
705) {
706    hash_f32_for_cache(rect.width, state);
707    hash_f32_for_cache(rect.height, state);
708    static_text_motion.hash(state);
709    if !static_text_motion {
710        hash_f32_for_cache(rect.x.fract(), state);
711        hash_f32_for_cache(rect.y.fract(), state);
712    }
713}
714
715fn text_raster_geometry_for_draw(
716    text_draw: &TextDraw,
717    root_scale: f32,
718) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
719    if text_draw.text.is_empty()
720        || text_draw.rect.width <= 0.0
721        || text_draw.rect.height <= 0.0
722        || !root_scale.is_finite()
723        || root_scale <= 0.0
724    {
725        return None;
726    }
727
728    let text_scale = text_draw.scale * root_scale;
729    if !text_scale.is_finite() || text_scale <= 0.0 {
730        return None;
731    }
732
733    let static_text_motion = text_draw
734        .text_style
735        .paragraph_style
736        .text_motion
737        .unwrap_or(cranpose_ui::text::TextMotion::Static)
738        == cranpose_ui::text::TextMotion::Static;
739    let snap_delta = text_draw
740        .snap_anchor
741        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
742        .unwrap_or_default();
743    let logical_rect = text_draw.rect.translate(snap_delta.x, snap_delta.y);
744    // Clips are resolved in scene space from their own layer ancestry. A draw
745    // item's raster snap must never move a fixed ancestor clip.
746    let clip = text_draw.clip;
747    let mut raster_rect = Rect {
748        x: logical_rect.x * root_scale,
749        y: logical_rect.y * root_scale,
750        width: logical_rect.width * root_scale,
751        height: logical_rect.height * root_scale,
752    };
753    if text_draw.snap_anchor.is_some() {
754        raster_rect.x = canonicalize_device_coordinate(raster_rect.x);
755        raster_rect.y = canonicalize_device_coordinate(raster_rect.y);
756    }
757    if static_text_motion {
758        raster_rect.x = raster_rect.x.round();
759        raster_rect.y = raster_rect.y.round();
760    }
761    raster_rect.width = raster_rect.width.ceil().max(1.0);
762    raster_rect.height = raster_rect.height.ceil().max(1.0);
763    Some((
764        logical_rect,
765        raster_rect,
766        clip,
767        text_scale,
768        static_text_motion,
769    ))
770}
771
772fn text_draw_is_visible_in_viewport(
773    logical_rect: Rect,
774    clip: Option<Rect>,
775    viewport: ViewportUniformParams,
776    root_scale: f32,
777) -> bool {
778    draw_rect_is_visible_in_viewport(logical_rect, clip, viewport, root_scale)
779}
780
781fn text_draw_should_prewarm_in_viewport(
782    logical_rect: Rect,
783    clip: Option<Rect>,
784    viewport: ViewportUniformParams,
785    root_scale: f32,
786) -> bool {
787    if !root_scale.is_finite() || root_scale <= 0.0 {
788        return false;
789    }
790    let viewport_rect = Rect {
791        x: viewport.offset[0] / root_scale,
792        y: viewport.offset[1] / root_scale,
793        width: viewport.width as f32 / root_scale,
794        height: viewport.height as f32 / root_scale,
795    };
796    let margin_x = viewport_rect.width * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
797    let margin_y = viewport_rect.height * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
798    let prewarm_viewport = expand_rect(viewport_rect, margin_x, margin_y);
799    let prewarm_rect = match clip {
800        Some(clip) => expand_rect(clip, margin_x, margin_y).intersect(prewarm_viewport),
801        None => Some(prewarm_viewport),
802    };
803    prewarm_rect.is_some_and(|rect| logical_rect.intersect(rect).is_some())
804}
805
806fn expand_rect(rect: Rect, margin_x: f32, margin_y: f32) -> Rect {
807    Rect {
808        x: rect.x - margin_x,
809        y: rect.y - margin_y,
810        width: rect.width + margin_x * 2.0,
811        height: rect.height + margin_y * 2.0,
812    }
813}
814
815fn draw_rect_is_visible_in_viewport(
816    rect: Rect,
817    clip: Option<Rect>,
818    viewport: ViewportUniformParams,
819    root_scale: f32,
820) -> bool {
821    if !root_scale.is_finite() || root_scale <= 0.0 {
822        return false;
823    }
824    let viewport_rect = Rect {
825        x: viewport.offset[0] / root_scale,
826        y: viewport.offset[1] / root_scale,
827        width: viewport.width as f32 / root_scale,
828        height: viewport.height as f32 / root_scale,
829    };
830    let visible_rect = match clip {
831        Some(clip) => clip.intersect(viewport_rect),
832        None => Some(viewport_rect),
833    };
834    visible_rect.is_some_and(|visible| rect.intersect(visible).is_some())
835}
836
837fn shape_draw_is_visible_in_viewport(
838    shape: &DrawShape,
839    viewport: ViewportUniformParams,
840    root_scale: f32,
841) -> bool {
842    let snap_delta = shape
843        .snap_anchor
844        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
845        .unwrap_or_default();
846    let rect = quad_bounds(translate_quad(shape.quad, snap_delta));
847    let clip = shape.clip;
848    draw_rect_is_visible_in_viewport(rect, clip, viewport, root_scale)
849}
850
851fn cached_text_glyph_quad(
852    glyph: &SoftwareGlyphAtlasPlacement,
853    entry: GlyphAtlasEntry,
854    atlas_size: u32,
855) -> CachedTextGlyphQuad {
856    CachedTextGlyphQuad {
857        x: glyph.x,
858        y: glyph.y,
859        width: glyph.width,
860        height: glyph.height,
861        color: (
862            glyph.color.0.clamp(0.0, 1.0),
863            glyph.color.1.clamp(0.0, 1.0),
864            glyph.color.2.clamp(0.0, 1.0),
865            glyph.color.3.clamp(0.0, 1.0),
866        ),
867        uv: glyph_atlas_uv_rect(entry, atlas_size),
868    }
869}
870
871fn append_cached_text_glyph_quad(
872    source_raster_rect: Rect,
873    quad: &CachedTextGlyphQuad,
874    image_vertices: &mut Vec<Vertex>,
875    image_indices: &mut Vec<u32>,
876) -> bool {
877    if quad.width == 0 || quad.height == 0 || quad.color.3 <= 0.0 {
878        return false;
879    }
880
881    let base_vertex = image_vertices.len() as u32;
882    image_indices.extend_from_slice(&[
883        base_vertex,
884        base_vertex + 1,
885        base_vertex + 2,
886        base_vertex + 2,
887        base_vertex + 1,
888        base_vertex + 3,
889    ]);
890
891    let x0 = source_raster_rect.x + quad.x as f32;
892    let y0 = source_raster_rect.y + quad.y as f32;
893    let x1 = x0 + quad.width as f32;
894    let y1 = y0 + quad.height as f32;
895    let color = [quad.color.0, quad.color.1, quad.color.2, quad.color.3];
896
897    image_vertices.extend_from_slice(&[
898        Vertex {
899            position: [x0, y0],
900            color,
901            uv: [quad.uv.min[0], quad.uv.min[1]],
902            uv_bounds: quad.uv.sample_bounds,
903        },
904        Vertex {
905            position: [x1, y0],
906            color,
907            uv: [quad.uv.max[0], quad.uv.min[1]],
908            uv_bounds: quad.uv.sample_bounds,
909        },
910        Vertex {
911            position: [x0, y1],
912            color,
913            uv: [quad.uv.min[0], quad.uv.max[1]],
914            uv_bounds: quad.uv.sample_bounds,
915        },
916        Vertex {
917            position: [x1, y1],
918            color,
919            uv: [quad.uv.max[0], quad.uv.max[1]],
920            uv_bounds: quad.uv.sample_bounds,
921        },
922    ]);
923    true
924}
925
926fn cached_text_glyph_quad_logical_rect(
927    source_raster_rect: Rect,
928    quad: &CachedTextGlyphQuad,
929    root_scale: f32,
930) -> Option<Rect> {
931    if !root_scale.is_finite() || root_scale <= 0.0 {
932        return None;
933    }
934    Some(Rect {
935        x: (source_raster_rect.x + quad.x as f32) / root_scale,
936        y: (source_raster_rect.y + quad.y as f32) / root_scale,
937        width: quad.width as f32 / root_scale,
938        height: quad.height as f32 / root_scale,
939    })
940}
941
942fn cached_text_glyph_quad_is_visible_in_viewport(
943    source_raster_rect: Rect,
944    quad: &CachedTextGlyphQuad,
945    clip: Option<Rect>,
946    viewport: ViewportUniformParams,
947    root_scale: f32,
948) -> bool {
949    cached_text_glyph_quad_logical_rect(source_raster_rect, quad, root_scale)
950        .is_some_and(|rect| draw_rect_is_visible_in_viewport(rect, clip, viewport, root_scale))
951}
952
953#[derive(Clone, Copy, Debug, Eq, PartialEq)]
954enum TextGlyphDrawAction {
955    DrawVisible,
956    PrewarmOffscreen,
957    Skip,
958}
959
960fn text_glyph_draw_action(
961    is_visible: bool,
962    is_prewarm_candidate: bool,
963    allow_offscreen_prewarm: bool,
964) -> TextGlyphDrawAction {
965    if is_visible {
966        TextGlyphDrawAction::DrawVisible
967    } else if allow_offscreen_prewarm && is_prewarm_candidate {
968        TextGlyphDrawAction::PrewarmOffscreen
969    } else {
970        TextGlyphDrawAction::Skip
971    }
972}
973
974#[cfg(not(target_arch = "wasm32"))]
975fn should_use_retained_text_glyph_run(quads_len: usize, clip: Option<Rect>) -> bool {
976    clip.is_none() && quads_len >= MIN_RETAINED_TEXT_GLYPH_QUADS
977}
978
979#[cfg(not(target_arch = "wasm32"))]
980fn offscreen_text_glyph_prewarm_work_is_bounded(
981    cached_glyphs: Option<usize>,
982    text_len: usize,
983) -> bool {
984    match cached_glyphs {
985        Some(glyphs) => glyphs <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS,
986        None => text_len <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
987    }
988}
989
990#[cfg(not(target_arch = "wasm32"))]
991fn offscreen_text_glyph_prewarm_budget_exhausted(
992    start: Instant,
993    admitted_candidates: usize,
994) -> bool {
995    admitted_candidates >= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES
996        || instant_ms(start, Instant::now()) >= OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS
997}
998
999fn text_draws_for_ordered_range<'a>(
1000    ordered_items: &'a [(usize, SegmentDrawItem)],
1001    texts: &'a [TextDraw],
1002    start: usize,
1003    end: usize,
1004) -> Result<impl Iterator<Item = &'a TextDraw>, String> {
1005    let range_items = ordered_items
1006        .get(start..end)
1007        .ok_or_else(|| format!("text batch range {start}..{end} is outside ordered draw items"))?;
1008    for (_, item) in range_items {
1009        match item {
1010            SegmentDrawItem::Text(text_index) if *text_index < texts.len() => {}
1011            SegmentDrawItem::Text(text_index) => {
1012                return Err(format!(
1013                    "text batch references missing text draw index: {text_index}"
1014                ));
1015            }
1016            _ => return Err(format!("text batch contains non-text draw item: {item:?}")),
1017        }
1018    }
1019
1020    Ok(range_items.iter().filter_map(move |(_, item)| match item {
1021        SegmentDrawItem::Text(text_index) => texts.get(*text_index),
1022        _ => None,
1023    }))
1024}
1025
1026/// Shadow geometry is hashed in device pixels quantized to 1/16 px so rigid
1027/// translations reuse the cached blurred raster. The cached surface is
1028/// composited one-to-one with texel-exact sampling; translation may not change
1029/// either the blur or its sampling phase.
1030const SHADOW_CACHE_DEVICE_QUANT: f32 = 16.0;
1031
1032fn hash_shadow_device_offset<H: Hasher>(value: f32, origin: f32, root_scale: f32, state: &mut H) {
1033    let quantized = ((value - origin) * root_scale * SHADOW_CACHE_DEVICE_QUANT).round();
1034    (quantized as i64).hash(state);
1035}
1036
1037fn hash_shadow_device_rect<H: Hasher>(
1038    rect: Rect,
1039    origin_x: f32,
1040    origin_y: f32,
1041    root_scale: f32,
1042    state: &mut H,
1043) {
1044    hash_shadow_device_offset(rect.x, origin_x, root_scale, state);
1045    hash_shadow_device_offset(rect.y, origin_y, root_scale, state);
1046    hash_shadow_device_offset(rect.width, 0.0, root_scale, state);
1047    hash_shadow_device_offset(rect.height, 0.0, root_scale, state);
1048}
1049
1050fn hash_shape_shadow_item<H: Hasher>(
1051    shape: &DrawShape,
1052    blend_mode: BlendMode,
1053    origin_x: f32,
1054    origin_y: f32,
1055    root_scale: f32,
1056    state: &mut H,
1057) {
1058    hash_shadow_device_rect(shape.rect, origin_x, origin_y, root_scale, state);
1059    hash_shadow_device_rect(shape.local_rect, origin_x, origin_y, root_scale, state);
1060    for point in shape.quad {
1061        hash_shadow_device_offset(point[0], origin_x, root_scale, state);
1062        hash_shadow_device_offset(point[1], origin_y, root_scale, state);
1063    }
1064    match shape.snap_anchor {
1065        Some(anchor) => {
1066            1u8.hash(state);
1067            hash_shadow_device_offset(anchor.origin.x, origin_x, root_scale, state);
1068            hash_shadow_device_offset(anchor.origin.y, origin_y, root_scale, state);
1069            hash_f32_for_cache(anchor.device_pixel_step, state);
1070        }
1071        None => 0u8.hash(state),
1072    }
1073    shape.brush.render_hash().hash(state);
1074    match shape.shape {
1075        Some(corner_shape) => {
1076            1u8.hash(state);
1077            corner_shape.radii().render_hash().hash(state);
1078        }
1079        None => 0u8.hash(state),
1080    }
1081    match shape.clip {
1082        Some(clip) => {
1083            1u8.hash(state);
1084            hash_shadow_device_rect(clip, origin_x, origin_y, root_scale, state);
1085        }
1086        None => 0u8.hash(state),
1087    }
1088    blend_mode.hash(state);
1089    shape.blend_mode.hash(state);
1090}
1091
1092fn shape_shadow_content_hash(shapes: &[(DrawShape, BlendMode)], root_scale: f32) -> u64 {
1093    let mut hasher = FxHasher::default();
1094    // Anchor the hash to the shapes' own (unfloored) bounds so rigid translation
1095    // cancels out exactly. Anchoring to floored device-pixel bounds would leak
1096    // the device subpixel phase into the hash and defeat the cache at
1097    // fractional display scales.
1098    let origin = shape_shadow_bounds(shapes).unwrap_or(Rect {
1099        x: 0.0,
1100        y: 0.0,
1101        width: 0.0,
1102        height: 0.0,
1103    });
1104
1105    shapes.len().hash(&mut hasher);
1106    for (shape, blend_mode) in shapes {
1107        hash_shape_shadow_item(
1108            shape,
1109            *blend_mode,
1110            origin.x,
1111            origin.y,
1112            root_scale,
1113            &mut hasher,
1114        );
1115    }
1116    hasher.finish()
1117}
1118
1119fn shape_shadow_surface_cache_key(
1120    shapes: &[(DrawShape, BlendMode)],
1121    device_bounds: DevicePixelBounds,
1122    pixel_radius: f32,
1123    root_scale: f32,
1124) -> Option<ShadowSurfaceCacheKey> {
1125    (root_scale.is_finite() && root_scale > 0.0).then(|| ShadowSurfaceCacheKey {
1126        content_hash: shape_shadow_content_hash(shapes, root_scale),
1127        pixel_size: [device_bounds.width, device_bounds.height],
1128        root_scale_bits: root_scale.to_bits(),
1129        blur_radius_bits: pixel_radius.to_bits(),
1130    })
1131}
1132
1133fn shape_shadow_bounds(shapes: &[(DrawShape, BlendMode)]) -> Option<Rect> {
1134    shapes
1135        .iter()
1136        .map(|(shape, _)| shape.rect)
1137        .reduce(|a, b| Rect {
1138            x: a.x.min(b.x),
1139            y: a.y.min(b.y),
1140            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1141            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1142        })
1143}
1144
1145fn shared_shape_shadow_snap_anchor(shapes: &[(DrawShape, BlendMode)]) -> Option<SnapAnchor> {
1146    let anchor = shapes.first()?.0.snap_anchor?;
1147    shapes
1148        .iter()
1149        .all(|(shape, _)| shape.snap_anchor == Some(anchor))
1150        .then_some(anchor)
1151}
1152
1153fn shadow_draw_bounds(shadow: &ShadowDraw) -> Option<Rect> {
1154    shadow
1155        .shapes
1156        .iter()
1157        .map(|(shape, _)| shape.rect)
1158        .chain(shadow.texts.iter().map(|text| text.rect))
1159        .reduce(|a, b| Rect {
1160            x: a.x.min(b.x),
1161            y: a.y.min(b.y),
1162            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1163            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1164        })
1165}
1166
1167fn shadow_draw_may_render(
1168    shadow: &ShadowDraw,
1169    width: u32,
1170    height: u32,
1171    root_scale: f32,
1172    max_texture_dim: u32,
1173) -> bool {
1174    if shadow.texts.is_empty() && !shadow.shapes.is_empty() && shadow.blur_radius > 0.0 {
1175        return shape_shadow_surface_plan(
1176            &shadow.shapes,
1177            shadow.clip,
1178            shadow.blur_radius,
1179            width,
1180            height,
1181            root_scale,
1182            max_texture_dim,
1183        )
1184        .is_some();
1185    }
1186
1187    let Some(bounds) = shadow_draw_bounds(shadow) else {
1188        return false;
1189    };
1190    let blur_margin = blur_extent_margin(shadow.blur_radius);
1191    let mut visible_bounds = Rect {
1192        x: bounds.x - blur_margin,
1193        y: bounds.y - blur_margin,
1194        width: bounds.width + blur_margin * 2.0,
1195        height: bounds.height + blur_margin * 2.0,
1196    };
1197    if let Some(clip) = shadow.clip {
1198        let clip_expanded = Rect {
1199            x: clip.x - blur_margin,
1200            y: clip.y - blur_margin,
1201            width: clip.width + blur_margin * 2.0,
1202            height: clip.height + blur_margin * 2.0,
1203        };
1204        let Some(intersection) = visible_bounds.intersect(clip_expanded) else {
1205            return false;
1206        };
1207        visible_bounds = intersection;
1208    }
1209
1210    scissor_rect_for_rect(visible_bounds, root_scale, width, height).is_some()
1211}
1212
1213fn shape_shadow_surface_plan(
1214    shapes: &[(DrawShape, BlendMode)],
1215    clip: Option<Rect>,
1216    blur_radius: f32,
1217    width: u32,
1218    height: u32,
1219    root_scale: f32,
1220    max_texture_dim: u32,
1221) -> Option<ShapeShadowSurfacePlan> {
1222    let shape_bounds = shape_shadow_bounds(shapes)?;
1223    let blur_margin = blur_extent_margin(blur_radius);
1224    let source_blur_bounds = Rect {
1225        x: shape_bounds.x - blur_margin,
1226        y: shape_bounds.y - blur_margin,
1227        width: shape_bounds.width + blur_margin * 2.0,
1228        height: shape_bounds.height + blur_margin * 2.0,
1229    };
1230
1231    let mut visible_blur_bounds = source_blur_bounds;
1232    if let Some(clip) = clip {
1233        let clip_expanded = Rect {
1234            x: clip.x - blur_margin,
1235            y: clip.y - blur_margin,
1236            width: clip.width + blur_margin * 2.0,
1237            height: clip.height + blur_margin * 2.0,
1238        };
1239        visible_blur_bounds = visible_blur_bounds.intersect(clip_expanded)?;
1240    }
1241
1242    let processing_scissor = scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
1243    processing_scissor?;
1244    let visible_device_bounds =
1245        device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)?;
1246    let source_device_bounds = translation_stable_anchored_device_pixel_bounds(
1247        source_blur_bounds,
1248        shared_shape_shadow_snap_anchor(shapes),
1249        root_scale,
1250        max_texture_dim,
1251    )
1252    .unwrap_or(visible_device_bounds);
1253
1254    Some(ShapeShadowSurfacePlan {
1255        source_device_bounds,
1256        processing_scissor,
1257        pixel_radius: blur_radius * root_scale,
1258    })
1259}
1260
1261fn is_render_effect_supported(effect: &RenderEffect) -> bool {
1262    match effect {
1263        RenderEffect::Blur { .. } => true,
1264        RenderEffect::Offset { .. } => true,
1265        RenderEffect::Shader { .. } => true,
1266        RenderEffect::Chain { first, second } => {
1267            is_render_effect_supported(first) && is_render_effect_supported(second)
1268        }
1269    }
1270}
1271
1272fn resolve_gradient_point(origin: f32, extent: f32, value: f32) -> f32 {
1273    if value.is_finite() {
1274        origin + value
1275    } else if value.is_sign_positive() {
1276        origin + extent
1277    } else {
1278        origin
1279    }
1280}
1281
1282fn gradient_tile_mode_value(tile_mode: TileMode) -> u32 {
1283    match tile_mode {
1284        TileMode::Clamp => 0,
1285        TileMode::Repeated => 1,
1286        TileMode::Mirror => 2,
1287        TileMode::Decal => 3,
1288    }
1289}
1290
1291#[cfg(not(target_arch = "wasm32"))]
1292fn shape_shader_source(batch_limits: ShapeBatchLimits) -> Cow<'static, str> {
1293    // These literals must stay in sync with `shape.wgsl`; a mismatch makes
1294    // the substitution silently no-op and leaves the shader sized for the
1295    // downlevel floor.
1296    if batch_limits.storage {
1297        return Cow::Owned(
1298            shaders::SHADER
1299                .replace(
1300                    "var<uniform> shape_data: array<ShapeData, 102>;",
1301                    "var<storage, read> shape_data: array<ShapeData>;",
1302                )
1303                .replace(
1304                    "var<uniform> gradient_stops: array<GradientStop, 256>;",
1305                    // Also inject the retained-paint array here: one mutable
1306                    // color per shape, read when `similarity.paint_select`
1307                    // is set, so recolor patches upload 16-byte colors
1308                    // instead of whole ShapeData records. The base text
1309                    // never declares it — uniform-mode devices cannot bind
1310                    // storage and never host retained slots.
1311                    "var<storage, read> gradient_stops: array<GradientStop>;\n\n\
1312                     @group(1) @binding(3)\n\
1313                     var<storage, read> paint: array<vec4<f32>>;",
1314                )
1315                .replace(
1316                    "output.color = shape.color;",
1317                    "output.color = \
1318                     select(shape.color, paint[shape_idx], similarity.paint_select > 0.5);",
1319                ),
1320        );
1321    }
1322    Cow::Owned(
1323        shaders::SHADER
1324            .replace(
1325                "array<ShapeData, 102>",
1326                &format!("array<ShapeData, {}>", batch_limits.max_shapes_per_batch),
1327            )
1328            .replace(
1329                "array<GradientStop, 256>",
1330                &format!("array<GradientStop, {}>", batch_limits.max_gradient_stops),
1331            ),
1332    )
1333}
1334
1335#[cfg(target_arch = "wasm32")]
1336fn shape_shader_source(_batch_limits: ShapeBatchLimits) -> Cow<'static, str> {
1337    Cow::Borrowed(shaders::SHADER)
1338}
1339
1340fn create_shape_pipeline(
1341    device: &wgpu::Device,
1342    surface_format: wgpu::TextureFormat,
1343    uniform_layout: &wgpu::BindGroupLayout,
1344    shape_layout: &wgpu::BindGroupLayout,
1345    blend_mode: BlendMode,
1346    batch_limits: ShapeBatchLimits,
1347) -> wgpu::RenderPipeline {
1348    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1349        label: Some("Shape Shader"),
1350        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1351    });
1352
1353    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1354        label: Some("Render Pipeline Layout"),
1355        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1356        immediate_size: 0,
1357    });
1358
1359    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1360        label: Some("Render Pipeline"),
1361        layout: Some(&pipeline_layout),
1362        vertex: wgpu::VertexState {
1363            module: &shader,
1364            entry_point: Some("vs_main"),
1365            compilation_options: wgpu::PipelineCompilationOptions::default(),
1366            // No vertex buffer: `vs_main` pulls quad corners from ShapeData
1367            // by `vertex_index`.
1368            buffers: &[],
1369        },
1370        fragment: Some(wgpu::FragmentState {
1371            module: &shader,
1372            entry_point: Some("fs_main"),
1373            compilation_options: wgpu::PipelineCompilationOptions::default(),
1374            targets: &[Some(wgpu::ColorTargetState {
1375                format: surface_format,
1376                blend: Some(blend_state_for_mode(blend_mode)),
1377                write_mask: wgpu::ColorWrites::ALL,
1378            })],
1379        }),
1380        primitive: wgpu::PrimitiveState {
1381            topology: wgpu::PrimitiveTopology::TriangleList,
1382            strip_index_format: None,
1383            front_face: wgpu::FrontFace::Ccw,
1384            cull_mode: None,
1385            unclipped_depth: false,
1386            polygon_mode: wgpu::PolygonMode::Fill,
1387            conservative: false,
1388        },
1389        depth_stencil: None,
1390        multisample: wgpu::MultisampleState::default(),
1391        multiview_mask: None,
1392        cache: None,
1393    })
1394}
1395
1396/// Storage-mode pipeline for retained slots that captured a conservative arc
1397/// mesh: `vs_mesh` consumes `{position, uv, shape_idx}` vertices instead of
1398/// expanding six corners per shape. Fragment stage, bind group layouts
1399/// (including the dynamic-offset similarity binding and the retained paint
1400/// binding) and the SrcOver blend are exactly the ones the quad-expansion retained
1401/// path uses — only the vertex fetch differs.
1402#[cfg(not(target_arch = "wasm32"))]
1403fn create_mesh_shape_pipeline(
1404    device: &wgpu::Device,
1405    surface_format: wgpu::TextureFormat,
1406    uniform_layout: &wgpu::BindGroupLayout,
1407    shape_layout: &wgpu::BindGroupLayout,
1408    batch_limits: ShapeBatchLimits,
1409) -> wgpu::RenderPipeline {
1410    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1411        label: Some("Shape Mesh Shader"),
1412        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1413    });
1414
1415    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1416        label: Some("Mesh Render Pipeline Layout"),
1417        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1418        immediate_size: 0,
1419    });
1420
1421    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1422        label: Some("Retained Mesh Pipeline"),
1423        layout: Some(&pipeline_layout),
1424        vertex: wgpu::VertexState {
1425            module: &shader,
1426            entry_point: Some("vs_mesh"),
1427            compilation_options: wgpu::PipelineCompilationOptions::default(),
1428            buffers: &[MeshVertex::desc()],
1429        },
1430        fragment: Some(wgpu::FragmentState {
1431            module: &shader,
1432            entry_point: Some("fs_main"),
1433            compilation_options: wgpu::PipelineCompilationOptions::default(),
1434            targets: &[Some(wgpu::ColorTargetState {
1435                format: surface_format,
1436                blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1437                write_mask: wgpu::ColorWrites::ALL,
1438            })],
1439        }),
1440        primitive: wgpu::PrimitiveState {
1441            topology: wgpu::PrimitiveTopology::TriangleList,
1442            strip_index_format: None,
1443            front_face: wgpu::FrontFace::Ccw,
1444            cull_mode: None,
1445            unclipped_depth: false,
1446            polygon_mode: wgpu::PolygonMode::Fill,
1447            conservative: false,
1448        },
1449        depth_stencil: None,
1450        multisample: wgpu::MultisampleState::default(),
1451        multiview_mask: None,
1452        cache: None,
1453    })
1454}
1455
1456/// Storage-mode pipeline for ordinary shape batches drawn as instanced
1457/// indexed quads (`vs_shape_instanced`): four vertex executions per shape
1458/// through the static `[0, 1, 2, 2, 1, 3]` index buffer instead of six
1459/// unindexed corner expansions. Everything but the vertex entry point is
1460/// exactly `create_shape_pipeline` — same fragment stage, same layouts,
1461/// same blend per mode — so a draw-time fallback to `vs_main` (the
1462/// `CRANPOSE_INSTANCED_QUADS=0` kill switch) changes nothing else.
1463#[cfg(not(target_arch = "wasm32"))]
1464fn create_instanced_shape_pipeline(
1465    device: &wgpu::Device,
1466    surface_format: wgpu::TextureFormat,
1467    uniform_layout: &wgpu::BindGroupLayout,
1468    shape_layout: &wgpu::BindGroupLayout,
1469    blend_mode: BlendMode,
1470    batch_limits: ShapeBatchLimits,
1471) -> wgpu::RenderPipeline {
1472    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1473        label: Some("Shape Instanced Shader"),
1474        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1475    });
1476
1477    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1478        label: Some("Instanced Render Pipeline Layout"),
1479        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1480        immediate_size: 0,
1481    });
1482
1483    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1484        label: Some("Instanced Render Pipeline"),
1485        layout: Some(&pipeline_layout),
1486        vertex: wgpu::VertexState {
1487            module: &shader,
1488            entry_point: Some("vs_shape_instanced"),
1489            compilation_options: wgpu::PipelineCompilationOptions::default(),
1490            // No vertex buffer: like `vs_main`, the corners come from
1491            // ShapeData; only the shape index source differs
1492            // (`instance_index` instead of `vertex_index / 6`).
1493            buffers: &[],
1494        },
1495        fragment: Some(wgpu::FragmentState {
1496            module: &shader,
1497            entry_point: Some("fs_main"),
1498            compilation_options: wgpu::PipelineCompilationOptions::default(),
1499            targets: &[Some(wgpu::ColorTargetState {
1500                format: surface_format,
1501                blend: Some(blend_state_for_mode(blend_mode)),
1502                write_mask: wgpu::ColorWrites::ALL,
1503            })],
1504        }),
1505        primitive: wgpu::PrimitiveState {
1506            topology: wgpu::PrimitiveTopology::TriangleList,
1507            strip_index_format: None,
1508            front_face: wgpu::FrontFace::Ccw,
1509            cull_mode: None,
1510            unclipped_depth: false,
1511            polygon_mode: wgpu::PolygonMode::Fill,
1512            conservative: false,
1513        },
1514        depth_stencil: None,
1515        multisample: wgpu::MultisampleState::default(),
1516        multiview_mask: None,
1517        cache: None,
1518    })
1519}
1520
1521fn create_image_pipeline(
1522    device: &wgpu::Device,
1523    surface_format: wgpu::TextureFormat,
1524    uniform_layout: &wgpu::BindGroupLayout,
1525    image_layout: &wgpu::BindGroupLayout,
1526    blend_mode: BlendMode,
1527) -> wgpu::RenderPipeline {
1528    let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1529        label: Some("Image Shader"),
1530        source: wgpu::ShaderSource::Wgsl(shaders::IMAGE_SHADER.into()),
1531    });
1532
1533    let image_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1534        label: Some("Image Pipeline Layout"),
1535        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1536        immediate_size: 0,
1537    });
1538
1539    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1540        label: Some("Image Pipeline"),
1541        layout: Some(&image_pipeline_layout),
1542        vertex: wgpu::VertexState {
1543            module: &image_shader,
1544            entry_point: Some("image_vs_main"),
1545            compilation_options: wgpu::PipelineCompilationOptions::default(),
1546            buffers: &[Vertex::desc()],
1547        },
1548        fragment: Some(wgpu::FragmentState {
1549            module: &image_shader,
1550            entry_point: Some("image_fs_main"),
1551            compilation_options: wgpu::PipelineCompilationOptions::default(),
1552            targets: &[Some(wgpu::ColorTargetState {
1553                format: surface_format,
1554                blend: Some(blend_state_for_mode(blend_mode)),
1555                write_mask: wgpu::ColorWrites::ALL,
1556            })],
1557        }),
1558        primitive: wgpu::PrimitiveState {
1559            topology: wgpu::PrimitiveTopology::TriangleList,
1560            strip_index_format: None,
1561            front_face: wgpu::FrontFace::Ccw,
1562            cull_mode: None,
1563            unclipped_depth: false,
1564            polygon_mode: wgpu::PolygonMode::Fill,
1565            conservative: false,
1566        },
1567        depth_stencil: None,
1568        multisample: wgpu::MultisampleState::default(),
1569        multiview_mask: None,
1570        cache: None,
1571    })
1572}
1573
1574fn create_glyph_atlas_pipeline(
1575    device: &wgpu::Device,
1576    surface_format: wgpu::TextureFormat,
1577    uniform_layout: &wgpu::BindGroupLayout,
1578    image_layout: &wgpu::BindGroupLayout,
1579) -> wgpu::RenderPipeline {
1580    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1581        label: Some("Glyph Atlas Shader"),
1582        source: wgpu::ShaderSource::Wgsl(shaders::GLYPH_ATLAS_SHADER.into()),
1583    });
1584
1585    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1586        label: Some("Glyph Atlas Pipeline Layout"),
1587        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1588        immediate_size: 0,
1589    });
1590
1591    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1592        label: Some("Glyph Atlas Pipeline"),
1593        layout: Some(&pipeline_layout),
1594        vertex: wgpu::VertexState {
1595            module: &shader,
1596            entry_point: Some("glyph_atlas_vs_main"),
1597            compilation_options: wgpu::PipelineCompilationOptions::default(),
1598            buffers: &[Vertex::desc()],
1599        },
1600        fragment: Some(wgpu::FragmentState {
1601            module: &shader,
1602            entry_point: Some("glyph_atlas_fs_main"),
1603            compilation_options: wgpu::PipelineCompilationOptions::default(),
1604            targets: &[Some(wgpu::ColorTargetState {
1605                format: surface_format,
1606                blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1607                write_mask: wgpu::ColorWrites::ALL,
1608            })],
1609        }),
1610        primitive: wgpu::PrimitiveState {
1611            topology: wgpu::PrimitiveTopology::TriangleList,
1612            strip_index_format: None,
1613            front_face: wgpu::FrontFace::Ccw,
1614            cull_mode: None,
1615            unclipped_depth: false,
1616            polygon_mode: wgpu::PolygonMode::Fill,
1617            conservative: false,
1618        },
1619        depth_stencil: None,
1620        multisample: wgpu::MultisampleState::default(),
1621        multiview_mask: None,
1622        cache: None,
1623    })
1624}
1625
1626#[repr(C)]
1627#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1628struct Vertex {
1629    position: [f32; 2],
1630    color: [f32; 4],
1631    uv: [f32; 2],
1632    uv_bounds: [f32; 4],
1633}
1634
1635impl Vertex {
1636    const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
1637        0 => Float32x2,
1638        1 => Float32x4,
1639        2 => Float32x2,
1640        3 => Float32x4
1641    ];
1642
1643    fn desc() -> wgpu::VertexBufferLayout<'static> {
1644        wgpu::VertexBufferLayout {
1645            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
1646            step_mode: wgpu::VertexStepMode::Vertex,
1647            attributes: &Self::ATTRIBS,
1648        }
1649    }
1650}
1651
1652#[repr(C)]
1653#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1654struct Uniforms {
1655    viewport: [f32; 2],
1656    viewport_offset: [f32; 2],
1657}
1658
1659/// Mirror of `struct ShapeData` in `shape.wgsl`. Field order and sizes must
1660/// match exactly: 10 x 16 bytes = 160 bytes, every member 16-byte aligned as
1661/// the uniform address space requires. The quad corners and vertex color ride
1662/// in here because the shape pipeline has no vertex buffer: the vertex shader
1663/// pulls all six corners of a shape straight from this struct.
1664#[repr(C)]
1665#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1666struct ShapeData {
1667    rect: [f32; 4], // x, y, width, height
1668    /// Rects: top_left, top_right, bottom_left, bottom_right corner radii.
1669    /// Arcs: (sin, cos) of the mid angle and of the half sweep — the shader's
1670    /// per-shape trig, precomputed so `sdf_arc_band` needs none per fragment.
1671    radii: [f32; 4],
1672    gradient_params: [f32; 4], // linear: start.xy,end.xy; radial: center.xy,radius,unused
1673    clip_rect: [f32; 4],       // clip_x, clip_y, clip_width, clip_height (0,0,0,0 = no clip)
1674    /// stroke width, packed flags (see [`pack_shape_flags`]), arc outer radius,
1675    /// arc inner radius. All zero for a plain fill.
1676    stroke_params: [f32; 4],
1677    /// arc center.xy, start angle, sweep angle (radians, 0 = +X, clockwise).
1678    arc_params: [f32; 4],
1679    /// Device-space quad corners 0 (xy) and 1 (zw).
1680    quad01: [f32; 4],
1681    /// Device-space quad corners 2 (xy) and 3 (zw).
1682    quad23: [f32; 4],
1683    /// Vertex color: the solid brush color, or the first gradient stop.
1684    color: [f32; 4],
1685    brush_type: u32,         // 0=solid, 1=linear_gradient, 2=radial_gradient
1686    gradient_start: u32,     // Starting index in gradient buffer
1687    gradient_count: u32,     // Number of gradient stops
1688    gradient_tile_mode: u32, // 0=Clamp, 1=Repeated, 2=Mirror, 3=Decal
1689}
1690
1691/// Shape kinds understood by `shape.wgsl`.
1692const SHAPE_KIND_FILL: u32 = 0;
1693const SHAPE_KIND_STROKE: u32 = 1;
1694const SHAPE_KIND_ARC: u32 = 2;
1695
1696fn stroke_cap_code(cap: StrokeCap) -> u32 {
1697    match cap {
1698        StrokeCap::Butt => 0,
1699        StrokeCap::Round => 1,
1700        StrokeCap::Square => 2,
1701    }
1702}
1703
1704fn stroke_join_code(join: StrokeJoin) -> u32 {
1705    match join {
1706        StrokeJoin::Miter => 0,
1707        StrokeJoin::Round => 1,
1708        StrokeJoin::Bevel => 2,
1709    }
1710}
1711
1712/// Packs kind/cap/join into the single float `ShapeData::stroke_params[1]`.
1713///
1714/// Three 2-bit fields fit in one f32 exactly (integers below 2^24 are exact),
1715/// which keeps `ShapeData` a slot smaller than it would be if each field got
1716/// its own float — batch capacity is set by this size on uniform backends.
1717fn pack_shape_flags(kind: u32, cap: StrokeCap, join: StrokeJoin) -> f32 {
1718    ((kind & 3) | (stroke_cap_code(cap) << 2) | (stroke_join_code(join) << 4)) as f32
1719}
1720
1721/// Whether a batch conversion fans out is decided by measurement — see
1722/// [`crate::cost_tuner::CostTuner`]. The floor of 256 matters: a device
1723/// whose uniform binding caps batches at ~409 shapes never crossed the old
1724/// fixed threshold of 512, so conversion ran serial on exactly the class of
1725/// hardware (watch-grade in-order cores) where fanning out pays most. The
1726/// 400 µs cheap floor keeps a big phone core, which clears such a batch in
1727/// well under that, from ever paying for a spawn wave.
1728#[cfg(not(target_arch = "wasm32"))]
1729static SHAPE_CONVERT_TUNER: crate::cost_tuner::CostTuner =
1730    crate::cost_tuner::CostTuner::new("shape-convert", 256, 400_000);
1731
1732#[cfg(not(target_arch = "wasm32"))]
1733pub(crate) fn shape_convert_worker_count() -> usize {
1734    static WORKERS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
1735    *WORKERS.get_or_init(|| {
1736        let cpus = std::thread::available_parallelism()
1737            .map(|count| count.get())
1738            .unwrap_or(1);
1739        let workers = cpus.clamp(1, 4);
1740        // One line per process: on devices whose scheduler confines the
1741        // process (affinity masks, cpusets), this is the number that
1742        // explains why fan-out stages stayed serial.
1743        log::info!("[shape-convert] fan-out width {workers} (available parallelism {cpus})");
1744        workers
1745    })
1746}
1747
1748#[cfg(target_arch = "wasm32")]
1749pub(crate) fn shape_convert_worker_count() -> usize {
1750    1
1751}
1752
1753fn shape_gradient_stop_count(shape: &DrawShape) -> usize {
1754    match &shape.brush {
1755        Brush::Solid(_) => 0,
1756        Brush::LinearGradient { colors, .. }
1757        | Brush::RadialGradient { colors, .. }
1758        | Brush::SweepGradient { colors, .. } => colors.len(),
1759    }
1760}
1761
1762/// Converts one [`DrawShape`] into its GPU representation, writing into
1763/// pre-sized slots so a batch can convert in parallel across disjoint
1764/// sub-slices. `gradient_start` is the shape's global offset into the batch
1765/// gradient buffer; `gradient_out` is exactly its span of that buffer.
1766fn convert_shape_into_slots(
1767    shape: &DrawShape,
1768    root_scale: f32,
1769    gradient_start: u32,
1770    shape_out: &mut ShapeData,
1771    gradient_out: &mut [GradientStop],
1772) {
1773    let snap_delta = shape
1774        .snap_anchor
1775        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
1776        .unwrap_or_default();
1777    let local_rect = shape.local_rect.translate(snap_delta.x, snap_delta.y);
1778    let quad = translate_quad(shape.quad, snap_delta);
1779    // Clips are resolved in scene space from their own layer ancestry. A draw
1780    // item's raster snap must never move a fixed ancestor clip.
1781    let clip = shape.clip;
1782    let canonicalize = shape.snap_anchor.is_some();
1783    let device_local_rect = if canonicalize {
1784        canonicalized_scaled_rect(local_rect, root_scale)
1785    } else {
1786        Rect {
1787            x: local_rect.x * root_scale,
1788            y: local_rect.y * root_scale,
1789            width: local_rect.width * root_scale,
1790            height: local_rect.height * root_scale,
1791        }
1792    };
1793    let device_quad = if canonicalize {
1794        canonicalized_scaled_quad(quad, root_scale)
1795    } else {
1796        scaled_quad(quad, root_scale)
1797    };
1798    let canonicalize_brush_coordinate = |value| {
1799        if canonicalize {
1800            canonicalize_device_coordinate(value)
1801        } else {
1802            value
1803        }
1804    };
1805
1806    // Clip rect (scaled to physical pixels)
1807    let clip_rect = if let Some(clip) = clip {
1808        let device_clip = if canonicalize {
1809            canonicalized_scaled_rect(clip, root_scale)
1810        } else {
1811            Rect {
1812                x: clip.x * root_scale,
1813                y: clip.y * root_scale,
1814                width: clip.width * root_scale,
1815                height: clip.height * root_scale,
1816            }
1817        };
1818        [
1819            device_clip.x,
1820            device_clip.y,
1821            device_clip.width,
1822            device_clip.height,
1823        ]
1824    } else {
1825        [0.0, 0.0, 0.0, 0.0]
1826    };
1827
1828    // Gradient parameters
1829    let mut fill_gradient_entries = |colors: &[Color], stops: Option<&[f32]>| {
1830        let count = colors.len();
1831        let explicit_stops = stops.filter(|values| values.len() == count);
1832        for (index, color) in colors.iter().enumerate() {
1833            let position = explicit_stops
1834                .map(|values| values[index])
1835                .unwrap_or_else(|| {
1836                    if count <= 1 {
1837                        0.0
1838                    } else {
1839                        index as f32 / (count - 1) as f32
1840                    }
1841                });
1842            gradient_out[index] = GradientStop {
1843                color: [color.r(), color.g(), color.b(), color.a()],
1844                position: [position, 0.0, 0.0, 0.0],
1845            };
1846        }
1847        count as u32
1848    };
1849    let mut gradient_params = [0.0f32; 4];
1850    let (brush_type, gradient_count, gradient_tile_mode) = match &shape.brush {
1851        Brush::Solid(_) => (0u32, 0u32, gradient_tile_mode_value(TileMode::Clamp)),
1852        Brush::LinearGradient {
1853            colors,
1854            stops,
1855            start,
1856            end,
1857            tile_mode,
1858        } => {
1859            let count = fill_gradient_entries(colors, stops.as_deref());
1860            gradient_params = [
1861                canonicalize_brush_coordinate(resolve_gradient_point(
1862                    device_local_rect.x,
1863                    device_local_rect.width,
1864                    start.x * root_scale,
1865                )),
1866                canonicalize_brush_coordinate(resolve_gradient_point(
1867                    device_local_rect.y,
1868                    device_local_rect.height,
1869                    start.y * root_scale,
1870                )),
1871                canonicalize_brush_coordinate(resolve_gradient_point(
1872                    device_local_rect.x,
1873                    device_local_rect.width,
1874                    end.x * root_scale,
1875                )),
1876                canonicalize_brush_coordinate(resolve_gradient_point(
1877                    device_local_rect.y,
1878                    device_local_rect.height,
1879                    end.y * root_scale,
1880                )),
1881            ];
1882            (1u32, count, gradient_tile_mode_value(*tile_mode))
1883        }
1884        Brush::RadialGradient {
1885            colors,
1886            stops,
1887            center,
1888            radius,
1889            tile_mode,
1890        } => {
1891            let count = fill_gradient_entries(colors, stops.as_deref());
1892            gradient_params = [
1893                canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
1894                canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
1895                (radius * root_scale).max(f32::EPSILON),
1896                0.0,
1897            ];
1898            (2u32, count, gradient_tile_mode_value(*tile_mode))
1899        }
1900        Brush::SweepGradient {
1901            colors,
1902            stops,
1903            center,
1904        } => {
1905            let count = fill_gradient_entries(colors, stops.as_deref());
1906            gradient_params = [
1907                canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
1908                canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
1909                0.0,
1910                0.0,
1911            ];
1912            (3u32, count, gradient_tile_mode_value(TileMode::Clamp))
1913        }
1914    };
1915
1916    // A stroked rect/round-rect was emitted with `local_rect` already
1917    // inflated by half the stroke width, so corner radii must resolve
1918    // against the geometry that was actually asked for, not the
1919    // inflated box. The shader shrinks `half_size` by the same amount.
1920    let stroke_outset = shape
1921        .stroke
1922        .map(|stroke| stroke.half_width())
1923        .unwrap_or(0.0);
1924    let geometry_width = (local_rect.width - stroke_outset * 2.0).max(0.0);
1925    let geometry_height = (local_rect.height - stroke_outset * 2.0).max(0.0);
1926
1927    let radii = if let Some(arc) = shape.arc {
1928        // Arcs never carry corner radii, so this slot ships the shader's
1929        // per-shape trig instead: (sin, cos) of the sweep's mid angle and of
1930        // the half sweep. Computing these here — once per shape — is what
1931        // lets `sdf_arc_band` run without a single transcendental per
1932        // fragment. A full ring is the common case (dots, particles) and
1933        // `ArcGeometry::new` normalizes it to start 0 / sweep TAU, whose
1934        // values are exact constants; the half-sweep sine is pinned to
1935        // non-negative just like the shader used to, so a closed ring keeps
1936        // its seam-free (0, -1) form.
1937        if arc.sweep_angle >= cranpose_ui_graphics::TAU && arc.start_angle == 0.0 {
1938            [0.0, -1.0, 0.0, -1.0]
1939        } else {
1940            let half_sweep = arc.sweep_angle.clamp(0.0, cranpose_ui_graphics::TAU) * 0.5;
1941            let (mid_sin, mid_cos) = (arc.start_angle + half_sweep).sin_cos();
1942            let (half_sin, half_cos) = half_sweep.sin_cos();
1943            [mid_sin, mid_cos, half_sin.max(0.0), half_cos]
1944        }
1945    } else if let Some(rounded) = shape.shape {
1946        let resolved = rounded.resolve(geometry_width, geometry_height);
1947        [
1948            resolved.top_left * root_scale,
1949            resolved.top_right * root_scale,
1950            resolved.bottom_left * root_scale,
1951            resolved.bottom_right * root_scale,
1952        ]
1953    } else {
1954        [0.0, 0.0, 0.0, 0.0]
1955    };
1956
1957    let device_rect = [
1958        device_local_rect.x,
1959        device_local_rect.y,
1960        device_local_rect.width,
1961        device_local_rect.height,
1962    ];
1963
1964    // Stroke/arc parameters ride in the same ShapeData and the same
1965    // pipeline as fills, so a stroked or arc shape never splits a
1966    // batch.
1967    let (stroke_params, arc_params) = match (shape.arc, shape.stroke) {
1968        (Some(arc), _) => (
1969            [
1970                0.0,
1971                pack_shape_flags(SHAPE_KIND_ARC, arc.cap, StrokeJoin::Miter),
1972                arc.outer_radius * root_scale,
1973                arc.inner_radius * root_scale,
1974            ],
1975            [
1976                (arc.center.x + snap_delta.x) * root_scale,
1977                (arc.center.y + snap_delta.y) * root_scale,
1978                arc.start_angle,
1979                arc.sweep_angle,
1980            ],
1981        ),
1982        (None, Some(stroke)) => (
1983            [
1984                stroke.width.max(0.0) * root_scale,
1985                pack_shape_flags(SHAPE_KIND_STROKE, stroke.cap, stroke.join),
1986                0.0,
1987                0.0,
1988            ],
1989            [0.0; 4],
1990        ),
1991        (None, None) => (
1992            [
1993                0.0,
1994                pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
1995                0.0,
1996                0.0,
1997            ],
1998            [0.0; 4],
1999        ),
2000    };
2001
2002    let color = match &shape.brush {
2003        Brush::Solid(c) => [c.r(), c.g(), c.b(), c.a()],
2004        Brush::LinearGradient { colors, .. } => {
2005            let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2006            [first.r(), first.g(), first.b(), first.a()]
2007        }
2008        Brush::RadialGradient { colors, .. } | Brush::SweepGradient { colors, .. } => {
2009            let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2010            [first.r(), first.g(), first.b(), first.a()]
2011        }
2012    };
2013
2014    *shape_out = ShapeData {
2015        rect: device_rect,
2016        radii,
2017        gradient_params,
2018        clip_rect,
2019        stroke_params,
2020        arc_params,
2021        quad01: [
2022            device_quad[0][0],
2023            device_quad[0][1],
2024            device_quad[1][0],
2025            device_quad[1][1],
2026        ],
2027        quad23: [
2028            device_quad[2][0],
2029            device_quad[2][1],
2030            device_quad[3][0],
2031            device_quad[3][1],
2032        ],
2033        color,
2034        brush_type,
2035        gradient_start,
2036        gradient_count,
2037        gradient_tile_mode,
2038    };
2039}
2040
2041/// `CRANPOSE_QUAD_AREA_DIAG=1` prints, per shape batch, how many device
2042/// pixels the emitted quads cover — split into arc quads, the true arc band
2043/// coverage inside them, and everything else. Fill cost is the product of
2044/// fragment count and shader cost, and this is the fragment-count half: it
2045/// is how the MEGA scene's ~10x overdraw (and the ~50% of arc-quad area that
2046/// the SDF discards) was measured.
2047fn quad_area_diag_enabled() -> bool {
2048    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2049    *ENABLED.get_or_init(|| std::env::var_os("CRANPOSE_QUAD_AREA_DIAG").is_some())
2050}
2051
2052/// Converts a batch of shapes into pre-sized output slices, fanning the work
2053/// across scoped threads when the batch is large enough to pay for spawns.
2054/// The outputs may be scratch vectors or mapped GPU staging memory; each
2055/// shape writes only its own disjoint slots, so chunked `split_at_mut`
2056/// hand-off keeps the parallel path free of any synchronization.
2057fn convert_shapes_into_outputs(
2058    shape_refs: &[&DrawShape],
2059    gradient_offsets: &[u32],
2060    root_scale: f32,
2061    shape_data_out: &mut [ShapeData],
2062    gradients_out: &mut [GradientStop],
2063) {
2064    let shape_count = shape_refs.len();
2065    #[cfg(not(target_arch = "wasm32"))]
2066    let convert_started = Instant::now();
2067    #[cfg(not(target_arch = "wasm32"))]
2068    let parallel =
2069        SHAPE_CONVERT_TUNER.choose_parallel(shape_count) && shape_convert_worker_count() > 1;
2070    if quad_area_diag_enabled() {
2071        let quad_area = |q: [[f32; 2]; 4]| {
2072            // Shoelace over the quad polygon TL, TR, BR, BL (corners 0,1,3,2).
2073            let poly = [q[0], q[1], q[3], q[2]];
2074            let mut twice = 0.0f64;
2075            for i in 0..4 {
2076                let a = poly[i];
2077                let b = poly[(i + 1) % 4];
2078                twice += a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64;
2079            }
2080            twice.abs() * 0.5
2081        };
2082        let mut arc_quad = 0.0f64; // quad px of arc shapes
2083        let mut arc_band = 0.0f64; // true band coverage of those arcs
2084        let mut arc_count = 0usize;
2085        let mut ring_count = 0usize;
2086        let mut other_quad = 0.0f64;
2087        let mut other_count = 0usize;
2088        // Largest non-arc quads: (area, index) so the tail of the diag can
2089        // name what the aggregate "other" fill actually is.
2090        let mut top_other: Vec<(f64, usize)> = Vec::new();
2091        for (index, shape) in shape_refs.iter().enumerate() {
2092            let area = quad_area(shape.quad);
2093            if let Some(arc) = shape.arc {
2094                arc_quad += area;
2095                arc_count += 1;
2096                if arc.sweep_angle >= cranpose_ui_graphics::TAU {
2097                    ring_count += 1;
2098                }
2099                let ra = arc.mid_radius() as f64;
2100                let rb = arc.half_thickness() as f64;
2101                arc_band +=
2102                    arc.sweep_angle as f64 * ra * (2.0 * rb) + std::f64::consts::PI * rb * rb;
2103            } else {
2104                other_quad += area;
2105                other_count += 1;
2106                top_other.push((area, index));
2107            }
2108        }
2109        let scale2 = (root_scale as f64) * (root_scale as f64);
2110        eprintln!(
2111            "[quad-area] arcs={arc_count} (rings={ring_count}) arc_quad_px={:.0} arc_band_px={:.0} | other={other_count} other_px={:.0}",
2112            arc_quad * scale2,
2113            arc_band * scale2,
2114            other_quad * scale2,
2115        );
2116        top_other.sort_by(|a, b| b.0.total_cmp(&a.0));
2117        for &(area, index) in top_other.iter().take(4) {
2118            let shape = shape_refs[index];
2119            let brush = match &shape.brush {
2120                cranpose_ui_graphics::Brush::Solid(color) => format!("solid a={:.2}", color.3),
2121                cranpose_ui_graphics::Brush::LinearGradient { colors, .. } => {
2122                    format!("linear n={}", colors.len())
2123                }
2124                cranpose_ui_graphics::Brush::RadialGradient { colors, .. } => {
2125                    format!("radial n={}", colors.len())
2126                }
2127                cranpose_ui_graphics::Brush::SweepGradient { colors, .. } => {
2128                    format!("sweep n={}", colors.len())
2129                }
2130            };
2131            eprintln!(
2132                "[quad-area]   top other: {:.0}px {}x{} at ({:.0},{:.0}) {} shape={} stroke={} clip={} blend={:?} z={}",
2133                area * scale2,
2134                shape.rect.width.round(),
2135                shape.rect.height.round(),
2136                shape.rect.x,
2137                shape.rect.y,
2138                brush,
2139                shape.shape.is_some(),
2140                shape.stroke.is_some(),
2141                shape.clip.is_some(),
2142                shape.blend_mode,
2143                shape.z_index,
2144            );
2145        }
2146    }
2147    #[cfg(target_arch = "wasm32")]
2148    let parallel = false;
2149    let workers = if parallel {
2150        shape_convert_worker_count()
2151    } else {
2152        1
2153    };
2154    if workers <= 1 {
2155        for (idx, shape) in shape_refs.iter().enumerate() {
2156            let gradient_start = gradient_offsets[idx];
2157            let gradient_end = gradient_offsets[idx + 1];
2158            convert_shape_into_slots(
2159                shape,
2160                root_scale,
2161                gradient_start,
2162                &mut shape_data_out[idx],
2163                &mut gradients_out[gradient_start as usize..gradient_end as usize],
2164            );
2165        }
2166        #[cfg(not(target_arch = "wasm32"))]
2167        SHAPE_CONVERT_TUNER.record(
2168            false,
2169            shape_count,
2170            convert_started.elapsed().as_nanos() as u64,
2171        );
2172        return;
2173    }
2174
2175    let chunk_len = shape_count.div_ceil(workers);
2176    let mut shape_data_rest = shape_data_out;
2177    let mut gradients_rest = gradients_out;
2178    std::thread::scope(|scope| {
2179        let mut chunk_start = 0usize;
2180        while chunk_start < shape_count {
2181            let chunk_end = (chunk_start + chunk_len).min(shape_count);
2182            let count = chunk_end - chunk_start;
2183            let gradient_base = gradient_offsets[chunk_start];
2184            let gradient_span = (gradient_offsets[chunk_end] - gradient_base) as usize;
2185            let (shape_data_chunk, rest) = std::mem::take(&mut shape_data_rest).split_at_mut(count);
2186            shape_data_rest = rest;
2187            let (gradient_chunk, rest) =
2188                std::mem::take(&mut gradients_rest).split_at_mut(gradient_span);
2189            gradients_rest = rest;
2190            let chunk_refs = &shape_refs[chunk_start..chunk_end];
2191            let chunk_offsets = &gradient_offsets[chunk_start..=chunk_end];
2192            let mut convert_chunk = move || {
2193                for (j, shape) in chunk_refs.iter().enumerate() {
2194                    let gradient_start = chunk_offsets[j];
2195                    let local_start = (gradient_start - gradient_base) as usize;
2196                    let local_end = (chunk_offsets[j + 1] - gradient_base) as usize;
2197                    convert_shape_into_slots(
2198                        shape,
2199                        root_scale,
2200                        gradient_start,
2201                        &mut shape_data_chunk[j],
2202                        &mut gradient_chunk[local_start..local_end],
2203                    );
2204                }
2205            };
2206            if chunk_end == shape_count {
2207                // The caller would only block at the scope join; converting
2208                // the final chunk inline puts that time to work and saves a
2209                // spawn.
2210                convert_chunk();
2211            } else {
2212                scope.spawn(convert_chunk);
2213            }
2214            chunk_start = chunk_end;
2215        }
2216    });
2217    #[cfg(not(target_arch = "wasm32"))]
2218    SHAPE_CONVERT_TUNER.record(
2219        true,
2220        shape_count,
2221        convert_started.elapsed().as_nanos() as u64,
2222    );
2223}
2224
2225#[repr(C)]
2226#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2227struct GradientStop {
2228    color: [f32; 4],
2229    position: [f32; 4],
2230}
2231
2232/// How many replay slots the shared transform buffer holds. Each slot's
2233/// transform lives at `slot * REPLAY_TRANSFORM_STRIDE`, aligned for the
2234/// strictest uniform-offset requirement any backend reports.
2235#[cfg(not(target_arch = "wasm32"))]
2236const MAX_REPLAY_SLOTS: u32 = 128;
2237#[cfg(not(target_arch = "wasm32"))]
2238const REPLAY_TRANSFORM_STRIDE: u64 = 256;
2239
2240/// One retained replay batch: converted shape slots captured on an earlier
2241/// frame, kept on the GPU and re-drawn each frame under the similarity
2242/// transform staged at `transform_offset`.
2243///
2244/// The immutable `ShapeData` and gradient buffers hold no handle here:
2245/// nothing addresses them after capture, and `bind_group` keeps them alive.
2246#[cfg(not(target_arch = "wasm32"))]
2247struct ReplaySlot {
2248    /// One `vec4<f32>` color per shape — the mutable paint the shader reads
2249    /// under `paint_select`, split out so recolor patches upload 16 bytes
2250    /// per shape while the 160-byte `ShapeData` stays immutable on the GPU
2251    /// from capture to release.
2252    paint_buffer: wgpu::Buffer,
2253    bind_group: wgpu::BindGroup,
2254    shape_count: u32,
2255    /// CPU mirror of the paint buffer. Recolor patches apply here first
2256    /// and upload as one contiguous span per slot per frame — MEGA's
2257    /// twinkle field recolors ~1.7k dots a frame, and that many individual
2258    /// copy commands stall a mobile GPU for longer than the spans' extra
2259    /// bytes ever could.
2260    paint_mirror: Vec<[f32; 4]>,
2261    /// Conservative capture-space arc/ring mesh, built once at capture.
2262    /// `None` when the kill switch is off, the slot meshed no arcs, or the
2263    /// vertex budget overflowed — those slots replay through the quad-expansion
2264    /// six-vertices-per-shape path.
2265    mesh: Option<ReplaySlotMesh>,
2266    /// Which capture created this slot's buffers, from the store's global
2267    /// monotone counter. Retained bundle keys carry it so a slot id that is
2268    /// released and recaptured — new bind group, new buffers, same id — can
2269    /// never be drawn through a bundle recorded against the old capture.
2270    capture_epoch: u64,
2271}
2272
2273/// Vertex geometry a retained slot replays instead of per-shape quads: arc
2274/// bands get trapezoid strips covering only their antialiasing footprint,
2275/// every other shape gets a passthrough pair of triangles identical to the
2276/// quad expansion. See [`build_arc_mesh_vertices`].
2277#[cfg(not(target_arch = "wasm32"))]
2278struct ReplaySlotMesh {
2279    vertex_buffer: wgpu::Buffer,
2280    /// `u32` triangle-list indices into `vertex_buffer`: band-boundary
2281    /// vertices are emitted once and shared by both adjacent trapezoids, so
2282    /// per-arc vertex-shader work drops from ~30 executions to the unique
2283    /// boundary vertices (~10-14) — the amplification that made the
2284    /// non-indexed mesh SLOWER than plain quads on the watch's Adreno 702.
2285    index_buffer: wgpu::Buffer,
2286    /// Prefix table, `shape_count + 1` entries: shape `i`'s triangles occupy
2287    /// indices `index_prefix[i]..index_prefix[i + 1]`, so a retained span
2288    /// draws `index_prefix[first]..index_prefix[first + count]` — one
2289    /// `draw_indexed` per op, identical shape order, z untouched.
2290    index_prefix: Vec<u32>,
2291}
2292
2293/// Vertex of a retained slot's conservative arc mesh: capture-device-space
2294/// position, the uv reproducing `vs_main`'s affine rect map at that position,
2295/// and the shape index standing in for `vertex_index / 6`.
2296#[cfg(not(target_arch = "wasm32"))]
2297#[repr(C)]
2298#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2299struct MeshVertex {
2300    position: [f32; 2],
2301    uv: [f32; 2],
2302    shape_idx: u32,
2303}
2304
2305#[cfg(not(target_arch = "wasm32"))]
2306impl MeshVertex {
2307    const ATTRIBS: [wgpu::VertexAttribute; 3] =
2308        wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Uint32];
2309
2310    fn desc() -> wgpu::VertexBufferLayout<'static> {
2311        wgpu::VertexBufferLayout {
2312            array_stride: std::mem::size_of::<MeshVertex>() as wgpu::BufferAddress,
2313            step_mode: wgpu::VertexStepMode::Vertex,
2314            attributes: &Self::ATTRIBS,
2315        }
2316    }
2317}
2318
2319/// Kill switch, mirroring `command_feed_enabled`: default ON,
2320/// `CRANPOSE_ARC_MESH=0` (or the `debug.cranpose.arc_mesh` property on
2321/// Android) makes the next capture skip mesh building entirely, so a device
2322/// A/B needs no rebuild. Read per capture — captures are rare.
2323#[cfg(not(target_arch = "wasm32"))]
2324fn arc_mesh_enabled() -> bool {
2325    // Opt-in (CRANPOSE_ARC_MESH=1 / debug.cranpose.arc_mesh): the Gate 0
2326    // off-charger watch A/B measured the non-indexed mesh 5-7 fps SLOWER
2327    // than plain quads on the Adreno 702 — the 4-6x vertex amplification
2328    // outweighs the fragment savings on a small binning GPU (big desktop
2329    // GPUs and the at-vsync-ceiling Huawei masked it). Default returns to
2330    // quad expansion until indexed band-boundary geometry removes the
2331    // amplification; then the A/B is repeated.
2332    matches!(std::env::var("CRANPOSE_ARC_MESH").as_deref(), Ok(v) if v != "0")
2333}
2334
2335/// Dilation applied to the band's half-thickness before meshing, in capture
2336/// device pixels. The fragment SDF feathers over ±0.5 px
2337/// (`smoothstep(-0.5, 0.5, dist)`), so every pixel the shader keeps sits
2338/// within 0.5 px of the band; the other 0.5 px absorbs f32 slop between this
2339/// builder's trig and the converted shape's precomputed (sin, cos) pairs.
2340#[cfg(not(target_arch = "wasm32"))]
2341const ARC_MESH_MARGIN: f32 = 1.0;
2342
2343/// Chord overshoot budget in pixels: the segment count is chosen so pushing
2344/// outer edges tangent-outside the dilated outer circle overshoots it by
2345/// about this much at the chord ends.
2346#[cfg(not(target_arch = "wasm32"))]
2347const ARC_MESH_OVERSHOOT: f32 = 2.0;
2348
2349#[cfg(not(target_arch = "wasm32"))]
2350const ARC_MESH_MIN_SEGMENTS: usize = 4;
2351#[cfg(not(target_arch = "wasm32"))]
2352const ARC_MESH_MAX_SEGMENTS: usize = 64;
2353
2354/// Per-slot geometry budget in BYTES: 48 vertex-equivalents (~1 KB) per
2355/// shape, floored for tiny slots so a single huge ring still fits. The
2356/// non-indexed mesh spent this entirely on 20-byte vertices; the indexed
2357/// mesh counts vertices AND 4-byte indices against the same byte ceiling,
2358/// which indexed geometry fits with more headroom (MEGA's retained arcs
2359/// drop from ~30 vertices ≈ 600 B to ~12 unique vertices + ~30 indices
2360/// ≈ 360 B). Overflow falls back to whole-slot passthrough WITH a warning —
2361/// truncating silently would break the containment invariant.
2362#[cfg(not(target_arch = "wasm32"))]
2363const ARC_MESH_BUDGET_BYTES_PER_SHAPE: usize = 48 * std::mem::size_of::<MeshVertex>();
2364#[cfg(not(target_arch = "wasm32"))]
2365const ARC_MESH_BUDGET_FLOOR_BYTES: usize = 4096 * std::mem::size_of::<MeshVertex>();
2366
2367/// The budget-relevant size of an indexed mesh: what the GPU buffers will
2368/// actually hold.
2369#[cfg(not(target_arch = "wasm32"))]
2370fn arc_mesh_bytes(vertices: usize, indices: usize) -> usize {
2371    vertices * std::mem::size_of::<MeshVertex>() + indices * std::mem::size_of::<u32>()
2372}
2373
2374/// Band parameters of a captured arc that qualifies for a conservative mesh:
2375/// solid brush, no clip, and a quad that is exactly — tolerance zero — the
2376/// axis-aligned box of its rect. Everything else returns `None` and passes
2377/// through as today's two quad triangles.
2378#[cfg(not(target_arch = "wasm32"))]
2379struct ArcMeshBand {
2380    center: [f32; 2],
2381    inner: f32,
2382    outer: f32,
2383    start: f32,
2384    sweep: f32,
2385}
2386
2387#[cfg(not(target_arch = "wasm32"))]
2388fn arc_mesh_band(shape: &ShapeData) -> Option<ArcMeshBand> {
2389    // Mirror the fragment shader's flag decode (`u32(max(x, 0.0))`).
2390    let flags = shape.stroke_params[1].max(0.0) as u32;
2391    if flags & 3 != SHAPE_KIND_ARC {
2392        return None;
2393    }
2394    // Solid brushes only: gradients also derive from `rect_pos` and would
2395    // mesh in principle, but the hot retained scenes are solid and a narrow
2396    // gate keeps the byte-exactness surface small.
2397    if shape.brush_type != 0 {
2398        return None;
2399    }
2400    // A live clip is a hard `world_pos` comparison in the fragment shader.
2401    // Meshed arcs interpolate `world_pos` across different triangles than
2402    // the quad would, and one ulp of difference at the clip boundary flips
2403    // whole pixels — clipped arcs pass through untouched.
2404    if shape.clip_rect[2] > 0.0 && shape.clip_rect[3] > 0.0 {
2405        return None;
2406    }
2407    let [_, _, w, h] = shape.rect;
2408    if !(w > 0.0 && h > 0.0) {
2409        return None;
2410    }
2411    // The quad must be an axis-aligned box, tolerance zero: the mesh is
2412    // clipped to the quad's own corners, so as long as the quad IS a box its
2413    // rasterized pixel set equals the mesh clip region and the tight-AABB
2414    // tangent-point crop is reproduced exactly. (Comparing against `rect`
2415    // instead is an over-tight gate: under a non-dyadic root scale
2416    // `(x + w) * s` differs from `x * s + w * s` by an ulp and every arc
2417    // fell back to passthrough — observed on the Huawei at scale 2.75.)
2418    let [left, top, right, _] = shape.quad01;
2419    let [bl_x, bottom, br_x, br_y] = shape.quad23;
2420    let axis_aligned = shape.quad01[3] == top
2421        && bl_x == left
2422        && br_x == right
2423        && br_y == bottom
2424        && left < right
2425        && top < bottom;
2426    if !axis_aligned {
2427        return None;
2428    }
2429    let center = [shape.arc_params[0], shape.arc_params[1]];
2430    let start = shape.arc_params[2];
2431    let sweep = shape.arc_params[3];
2432    let outer = shape.stroke_params[2];
2433    let inner = shape.stroke_params[3];
2434    let finite = center[0].is_finite()
2435        && center[1].is_finite()
2436        && start.is_finite()
2437        && sweep.is_finite()
2438        && outer.is_finite()
2439        && inner.is_finite();
2440    if !finite || outer <= 0.0 || sweep <= 0.0 {
2441        return None;
2442    }
2443    Some(ArcMeshBand {
2444        center,
2445        inner,
2446        outer,
2447        start,
2448        sweep,
2449    })
2450}
2451
2452/// Emits the quad `vs_main` would expand for this shape as four shared
2453/// vertices plus the index pattern (0, 1, 2)(2, 1, 3) — the identical corner
2454/// order, corner uvs and positions straight from the captured quad, so a
2455/// passthrough shape rasterizes bit-identically to the quad-expansion
2456/// indexless path while spending four vertex executions instead of six.
2457#[cfg(not(target_arch = "wasm32"))]
2458fn emit_passthrough_quad(
2459    shape: &ShapeData,
2460    shape_idx: u32,
2461    vertices: &mut Vec<MeshVertex>,
2462    indices: &mut Vec<u32>,
2463) {
2464    let base = vertices.len() as u32;
2465    let corners = [
2466        ([shape.quad01[0], shape.quad01[1]], [0.0, 0.0]),
2467        ([shape.quad01[2], shape.quad01[3]], [1.0, 0.0]),
2468        ([shape.quad23[0], shape.quad23[1]], [0.0, 1.0]),
2469        ([shape.quad23[2], shape.quad23[3]], [1.0, 1.0]),
2470    ];
2471    for (position, uv) in corners {
2472        vertices.push(MeshVertex {
2473            position,
2474            uv,
2475            shape_idx,
2476        });
2477    }
2478    indices.extend([0u32, 1, 2, 2, 1, 3].map(|corner| base + corner));
2479}
2480
2481/// One Sutherland–Hodgman pass against an axis-aligned half-plane.
2482///
2483/// Two properties the byte-exactness bar depends on:
2484/// * the clipped coordinate is set to `bound` EXACTLY rather than recomputed
2485///   through `p + t * (q - p)`, so every clipped polygon's boundary lies
2486///   bitwise on the clip line;
2487/// * the intersection is computed on the lexicographically ordered endpoint
2488///   pair, so the shared radial edge of two adjacent trapezoids — traversed
2489///   in opposite directions — clips to bitwise-identical points, keeping the
2490///   strip watertight (no pixel shaded twice or missed along the seam).
2491#[cfg(not(target_arch = "wasm32"))]
2492fn clip_polygon_axis(
2493    input: &[[f32; 2]],
2494    axis: usize,
2495    bound: f32,
2496    keep_at_most: bool,
2497    output: &mut Vec<[f32; 2]>,
2498) {
2499    output.clear();
2500    let inside = |p: [f32; 2]| {
2501        if keep_at_most {
2502            p[axis] <= bound
2503        } else {
2504            p[axis] >= bound
2505        }
2506    };
2507    let intersect = |a: [f32; 2], b: [f32; 2]| {
2508        let (p, q) = if (b[0], b[1]) < (a[0], a[1]) {
2509            (b, a)
2510        } else {
2511            (a, b)
2512        };
2513        let t = (bound - p[axis]) / (q[axis] - p[axis]);
2514        let mut point = [0.0f32; 2];
2515        point[axis] = bound;
2516        point[1 - axis] = p[1 - axis] + t * (q[1 - axis] - p[1 - axis]);
2517        point
2518    };
2519    for (index, &current) in input.iter().enumerate() {
2520        let previous = input[(index + input.len() - 1) % input.len()];
2521        match (inside(previous), inside(current)) {
2522            (true, true) => output.push(current),
2523            (true, false) => output.push(intersect(previous, current)),
2524            (false, true) => {
2525                output.push(intersect(previous, current));
2526                output.push(current);
2527            }
2528            (false, false) => {}
2529        }
2530    }
2531}
2532
2533/// Emits the conservative trapezoid-strip mesh for one qualifying arc band.
2534///
2535/// CONTAINMENT INVARIANT (the byte-exactness bar): the union of emitted
2536/// triangles is a superset of `{ p in the capture quad's box :
2537/// sdf_arc_band(p) <= 0.5 }` — every pixel the fragment shader would keep.
2538/// Over-inclusion is free (the SDF discards those pixels identically to
2539/// today's quad); only under-inclusion can diverge, and
2540/// `arc_mesh_contains_every_band_pixel` checks it never happens.
2541///
2542/// Geometry: outer vertices ride at `Ro / cos(step / 2)` so every chord is
2543/// tangent-outside the dilated outer circle; inner vertices ride at the
2544/// dilated inner radius, whose chords lie inside the hole. Cap coverage is
2545/// bounded by the round-cap disc about the band endpoint (butt/square caps
2546/// only cut that disc with planes — see `sdf_arc_band`), so padding the
2547/// angular range by the disc's angular half-extent contains every cap. Each
2548/// trapezoid is clipped to the quad box and fan-triangulated IN INDEX SPACE:
2549/// a trapezoid the clipper left untouched shares its two boundary vertices
2550/// with each neighbor through the index list (closed rings wrap the sharing
2551/// modulo the boundary count), so the strip is watertight by construction —
2552/// the seam edge is one vertex pair, not two bitwise-equal copies — and the
2553/// per-arc vertex count collapses from three-per-triangle to the unique
2554/// boundary vertices. Clipped trapezoids cannot share boundary vertices (the
2555/// clipper rewrote them), so their fan vertices are appended PRIVATELY after
2556/// the shared block and indexed directly; seams against neighbors still hold
2557/// because a boundary edge either survives the clip on both sides
2558/// bitwise-identically (same input edge, same planes, same float ops — see
2559/// `clip_polygon_axis`) or is cut on both sides identically. Triangles are
2560/// emitted in exact segment order either way, so the indexed mesh's
2561/// primitive stream is triangle-for-triangle the one the non-indexed
2562/// emitter produced.
2563///
2564/// Returns the emitted segment count, or `None` when the mesh came out empty
2565/// — the caller emits the passthrough quad instead (never risk
2566/// under-coverage).
2567#[cfg(not(target_arch = "wasm32"))]
2568fn emit_arc_band_mesh(
2569    shape: &ShapeData,
2570    shape_idx: u32,
2571    band: &ArcMeshBand,
2572    vertices: &mut Vec<MeshVertex>,
2573    indices: &mut Vec<u32>,
2574) -> Option<usize> {
2575    let [cx, cy] = band.center;
2576    let ra = (band.outer + band.inner) * 0.5;
2577    let rb = ((band.outer - band.inner) * 0.5).max(0.0);
2578    let rb_m = rb + ARC_MESH_MARGIN;
2579    let ro = ra + rb_m;
2580    let ri = (ra - rb_m).max(0.0);
2581    let tau = cranpose_ui_graphics::TAU;
2582
2583    let (range_start, range) = if band.sweep >= tau {
2584        (0.0, tau)
2585    } else {
2586        let pad = if rb_m < ra {
2587            (rb_m / ra).asin() + 0.05
2588        } else {
2589            // The cap disc wraps the center; such shapes are tiny, take the
2590            // whole circle.
2591            std::f32::consts::PI
2592        };
2593        let padded = band.sweep + pad + pad;
2594        if padded >= tau {
2595            (0.0, tau)
2596        } else {
2597            (band.start - pad, padded)
2598        }
2599    };
2600    let closed = range >= tau;
2601
2602    let dtheta = (2.0 * (ro / (ro + ARC_MESH_OVERSHOOT)).acos()).clamp(tau / 64.0, tau / 6.0);
2603    let segments =
2604        ((range / dtheta).ceil() as usize).clamp(ARC_MESH_MIN_SEGMENTS, ARC_MESH_MAX_SEGMENTS);
2605    let step = range / segments as f32;
2606    let rc = ro / (step * 0.5).cos();
2607
2608    // Boundary vertices are computed once and shared by both adjacent
2609    // trapezoids: bitwise-equal edge endpoints are what let the rasterizer's
2610    // fill rule shade each seam exactly once.
2611    let boundary_count = if closed { segments } else { segments + 1 };
2612    let mut boundaries = Vec::with_capacity(boundary_count);
2613    for j in 0..boundary_count {
2614        let (sin, cos) = (range_start + step * j as f32).sin_cos();
2615        boundaries.push((
2616            [cx + cos * ri, cy + sin * ri],
2617            [cx + cos * rc, cy + sin * rc],
2618        ));
2619    }
2620
2621    let quad_min = [shape.quad01[0], shape.quad01[1]];
2622    let quad_max = [shape.quad23[2], shape.quad23[3]];
2623
2624    /// One trapezoid's clip outcome (see the function docs): `Shared` means
2625    /// the clip output is bitwise the input quad, so its corners index the
2626    /// shared boundary block; `Fan` carries the clipped polygon for private
2627    /// fan triangulation; `Empty` was clipped away entirely.
2628    enum SegmentGeometry {
2629        Shared,
2630        Fan(Vec<[f32; 2]>),
2631        Empty,
2632    }
2633
2634    // Phase 1: clip every trapezoid and classify it.
2635    let mut polygon: Vec<[f32; 2]> = Vec::with_capacity(8);
2636    let mut scratch: Vec<[f32; 2]> = Vec::with_capacity(8);
2637    let mut segment_geometry = Vec::with_capacity(segments);
2638    let mut boundary_used = vec![false; boundary_count];
2639    for j in 0..segments {
2640        let jb = (j + 1) % boundary_count;
2641        let (inner_a, outer_a) = boundaries[j];
2642        let (inner_b, outer_b) = boundaries[jb];
2643        polygon.clear();
2644        polygon.extend_from_slice(&[inner_a, outer_a, outer_b, inner_b]);
2645        clip_polygon_axis(&polygon, 0, quad_min[0], false, &mut scratch);
2646        clip_polygon_axis(&scratch, 0, quad_max[0], true, &mut polygon);
2647        clip_polygon_axis(&polygon, 1, quad_min[1], false, &mut scratch);
2648        clip_polygon_axis(&scratch, 1, quad_max[1], true, &mut polygon);
2649        // Collapse exact duplicates (an `Ri == 0` pie wedge duplicates the
2650        // center) before fanning.
2651        scratch.clear();
2652        for &point in polygon.iter() {
2653            if scratch.last() != Some(&point) {
2654                scratch.push(point);
2655            }
2656        }
2657        while scratch.len() > 1 && scratch.first() == scratch.last() {
2658            scratch.pop();
2659        }
2660        if scratch.len() < 3 {
2661            segment_geometry.push(SegmentGeometry::Empty);
2662        } else if scratch[..] == [inner_a, outer_a, outer_b, inner_b] {
2663            boundary_used[j] = true;
2664            boundary_used[jb] = true;
2665            segment_geometry.push(SegmentGeometry::Shared);
2666        } else {
2667            segment_geometry.push(SegmentGeometry::Fan(scratch.clone()));
2668        }
2669    }
2670
2671    let push_vertex = |vertices: &mut Vec<MeshVertex>, position: [f32; 2]| -> u32 {
2672        let index = vertices.len() as u32;
2673        vertices.push(MeshVertex {
2674            position,
2675            uv: [
2676                (position[0] - shape.rect[0]) / shape.rect[2],
2677                (position[1] - shape.rect[1]) / shape.rect[3],
2678            ],
2679            shape_idx,
2680        });
2681        index
2682    };
2683
2684    // Shared block: every boundary referenced by a surviving whole trapezoid
2685    // gets its (inner, outer) vertex pair exactly once, in boundary order.
2686    let mut boundary_vertex = vec![[0u32; 2]; boundary_count];
2687    for (j, used) in boundary_used.iter().enumerate() {
2688        if *used {
2689            let (inner, outer) = boundaries[j];
2690            boundary_vertex[j] = [push_vertex(vertices, inner), push_vertex(vertices, outer)];
2691        }
2692    }
2693
2694    // Phase 2: indices in exact segment order — the primitive stream matches
2695    // the non-indexed emitter triangle for triangle.
2696    let start_len = indices.len();
2697    for (j, geometry) in segment_geometry.iter().enumerate() {
2698        match geometry {
2699            SegmentGeometry::Empty => {}
2700            SegmentGeometry::Shared => {
2701                let jb = (j + 1) % boundary_count;
2702                let [in_a, out_a] = boundary_vertex[j];
2703                let [in_b, out_b] = boundary_vertex[jb];
2704                // The fan the non-indexed emitter produced for an untouched
2705                // trapezoid: (in_a, out_a, out_b)(in_a, out_b, in_b) — the
2706                // same quad diagonal.
2707                indices.extend_from_slice(&[in_a, out_a, out_b, in_a, out_b, in_b]);
2708            }
2709            SegmentGeometry::Fan(points) => {
2710                let base = vertices.len() as u32;
2711                for &point in points {
2712                    push_vertex(vertices, point);
2713                }
2714                for i in 1..points.len() as u32 - 1 {
2715                    indices.extend_from_slice(&[base, base + i, base + i + 1]);
2716                }
2717            }
2718        }
2719    }
2720    if indices.len() == start_len {
2721        return None;
2722    }
2723    Some(segments)
2724}
2725
2726/// Unsigned shoelace area of an emitted indexed triangle list, for
2727/// telemetry.
2728#[cfg(not(target_arch = "wasm32"))]
2729fn triangles_shoelace_area(vertices: &[MeshVertex], indices: &[u32]) -> f64 {
2730    indices
2731        .chunks_exact(3)
2732        .map(|tri| {
2733            let [a, b, c] = [
2734                vertices[tri[0] as usize].position,
2735                vertices[tri[1] as usize].position,
2736                vertices[tri[2] as usize].position,
2737            ];
2738            let cross = (b[0] as f64 - a[0] as f64) * (c[1] as f64 - a[1] as f64)
2739                - (b[1] as f64 - a[1] as f64) * (c[0] as f64 - a[0] as f64);
2740            cross.abs() * 0.5
2741        })
2742        .sum()
2743}
2744
2745/// Unsigned area of the two triangles the quad-expansion path would rasterize for
2746/// this shape, for telemetry.
2747#[cfg(not(target_arch = "wasm32"))]
2748fn quad_shoelace_area(shape: &ShapeData) -> f64 {
2749    let corners = [
2750        [shape.quad01[0] as f64, shape.quad01[1] as f64],
2751        [shape.quad01[2] as f64, shape.quad01[3] as f64],
2752        [shape.quad23[0] as f64, shape.quad23[1] as f64],
2753        [shape.quad23[2] as f64, shape.quad23[3] as f64],
2754    ];
2755    let tri = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
2756        ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() * 0.5
2757    };
2758    tri(corners[0], corners[1], corners[2]) + tri(corners[2], corners[1], corners[3])
2759}
2760
2761#[cfg(not(target_arch = "wasm32"))]
2762struct ArcMeshBuild {
2763    vertices: Vec<MeshVertex>,
2764    /// Triangle-list indices into `vertices`; see [`ReplaySlotMesh`].
2765    indices: Vec<u32>,
2766    /// `shape_count + 1` entries; shape `i` owns triangles
2767    /// `indices[index_prefix[i]..index_prefix[i + 1]]`.
2768    index_prefix: Vec<u32>,
2769    meshed_arcs: usize,
2770    meshed_segments: usize,
2771    passthrough: usize,
2772    quad_area: f64,
2773    mesh_area: f64,
2774}
2775
2776/// Builds a slot's conservative indexed mesh: arc bands become
2777/// vertex-sharing trapezoid strips, every other shape a passthrough quad
2778/// (four vertices, six indices), in the exact capture shape order. Returns
2779/// `None` when the byte budget overflows — the caller warns and the whole
2780/// slot replays through the quad-expansion path (silent truncation would
2781/// break the containment invariant).
2782#[cfg(not(target_arch = "wasm32"))]
2783fn build_arc_mesh_vertices(shape_data: &[ShapeData]) -> Option<ArcMeshBuild> {
2784    let budget_bytes =
2785        (shape_data.len() * ARC_MESH_BUDGET_BYTES_PER_SHAPE).max(ARC_MESH_BUDGET_FLOOR_BYTES);
2786    let mut build = ArcMeshBuild {
2787        vertices: Vec::new(),
2788        indices: Vec::new(),
2789        index_prefix: Vec::with_capacity(shape_data.len() + 1),
2790        meshed_arcs: 0,
2791        meshed_segments: 0,
2792        passthrough: 0,
2793        quad_area: 0.0,
2794        mesh_area: 0.0,
2795    };
2796    build.index_prefix.push(0);
2797    for (index, shape) in shape_data.iter().enumerate() {
2798        let start = build.indices.len();
2799        let meshed = arc_mesh_band(shape).and_then(|band| {
2800            emit_arc_band_mesh(
2801                shape,
2802                index as u32,
2803                &band,
2804                &mut build.vertices,
2805                &mut build.indices,
2806            )
2807        });
2808        match meshed {
2809            Some(segments) => {
2810                build.meshed_arcs += 1;
2811                build.meshed_segments += segments;
2812            }
2813            None => {
2814                emit_passthrough_quad(shape, index as u32, &mut build.vertices, &mut build.indices);
2815                build.passthrough += 1;
2816            }
2817        }
2818        if arc_mesh_bytes(build.vertices.len(), build.indices.len()) > budget_bytes {
2819            return None;
2820        }
2821        build.index_prefix.push(build.indices.len() as u32);
2822        build.quad_area += quad_shoelace_area(shape);
2823        build.mesh_area += triangles_shoelace_area(&build.vertices, &build.indices[start..]);
2824    }
2825    Some(build)
2826}
2827
2828/// The renderer's registry of live replay slots. The replay cache (scene
2829/// side) owns slot LIFECYCLE decisions; this store owns the GPU resources.
2830#[cfg(not(target_arch = "wasm32"))]
2831struct ReplaySlotStore {
2832    slots: std::collections::HashMap<u32, ReplaySlot, cranpose_ui_graphics::FxBuildHasher>,
2833    transform_buffer: wgpu::Buffer,
2834    free_ids: Vec<u32>,
2835    /// Global capture counter feeding [`ReplaySlot::capture_epoch`]: bumped
2836    /// on every capture, never reused, so an epoch identifies one capture's
2837    /// buffers for the renderer's whole lifetime.
2838    next_capture_epoch: u64,
2839}
2840
2841#[cfg(not(target_arch = "wasm32"))]
2842impl ReplaySlotStore {
2843    fn new(device: &wgpu::Device) -> Self {
2844        let transform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2845            label: Some("Replay Transform Buffer"),
2846            size: MAX_REPLAY_SLOTS as u64 * REPLAY_TRANSFORM_STRIDE,
2847            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2848            mapped_at_creation: false,
2849        });
2850        Self {
2851            slots: std::collections::HashMap::default(),
2852            transform_buffer,
2853            free_ids: (0..MAX_REPLAY_SLOTS).rev().collect(),
2854            next_capture_epoch: 1,
2855        }
2856    }
2857}
2858
2859/// Kill switch for cached retained render bundles, mirroring
2860/// `command_feed_enabled`: default ON, `CRANPOSE_RETAINED_BUNDLES=0` (or the
2861/// `debug.cranpose.retained_bundles` property on Android) drops the fused
2862/// retained arms back to direct per-op encoding, so a device A/B needs no
2863/// rebuild. Read per partition — the parity harness flips it between passes.
2864#[cfg(not(target_arch = "wasm32"))]
2865fn retained_bundles_enabled() -> bool {
2866    std::env::var("CRANPOSE_RETAINED_BUNDLES").as_deref() != Ok("0")
2867}
2868
2869/// Kill switch for instanced ordinary-shape quads: default ON,
2870/// `CRANPOSE_INSTANCED_QUADS=0` (or the `debug.cranpose.instanced_quads`
2871/// property on Android) reverts every ordinary shape draw to the six-vertex
2872/// `vs_main` expansion. Unlike the per-partition bundle flag this is read
2873/// ONCE per [`GpuRenderer`] construction into a field: cached retained
2874/// bundles encode the selected pipeline, so a flag that moved per draw would
2875/// let a cached bundle replay a selection the direct path no longer makes.
2876#[cfg(not(target_arch = "wasm32"))]
2877fn instanced_quads_enabled() -> bool {
2878    std::env::var("CRANPOSE_INSTANCED_QUADS").as_deref() != Ok("0")
2879}
2880
2881/// The index pattern of one instanced quad: the exact triangle pair
2882/// `vs_main`'s six-slot corner mapping produces — (0, 1, 2)(2, 1, 3), same
2883/// diagonal, same winding — shared by every instance.
2884#[cfg(not(target_arch = "wasm32"))]
2885const INSTANCED_QUAD_INDICES: [u16; 6] = [0, 1, 2, 2, 1, 3];
2886
2887/// The latched instanced-quad selection: `Some` exactly when the renderer
2888/// was constructed in storage mode with [`instanced_quads_enabled`]. Both
2889/// blend variants exist because ordinary batches draw SrcOver and DstOut;
2890/// the `vs_main` pipelines coexist untouched so the `=0` revert (and the
2891/// uniform-mode path) still has its six-vertex draws.
2892#[cfg(not(target_arch = "wasm32"))]
2893struct InstancedQuadPipelines {
2894    pipeline: wgpu::RenderPipeline,
2895    pipeline_dst_out: wgpu::RenderPipeline,
2896    /// Static `[0, 1, 2, 2, 1, 3]` u16 index buffer, created once and shared
2897    /// by every instanced draw.
2898    index_buffer: wgpu::Buffer,
2899}
2900
2901/// Everything that decides the commands one retained op contributes to a
2902/// cached bundle. Equal op keys imply identical encoded commands:
2903/// `capture_epoch` pins the slot's bind group and buffers to one capture,
2904/// `has_mesh` pins the pipeline and vertex-buffer choice, `first..last` is
2905/// the clamped draw range, and `retained_index` is the dynamic transform
2906/// offset. Transforms and paints are NOT here — they are data-buffer
2907/// contents the bundle reads at execution.
2908#[cfg(not(target_arch = "wasm32"))]
2909#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2910struct RetainedBundleOpKey {
2911    slot: u32,
2912    /// The slot's capture epoch at key time, `None` while the slot is absent
2913    /// from the store (the op encodes nothing). Epochs are globally unique
2914    /// per capture, so a recaptured slot reusing its id can never satisfy a
2915    /// key recorded against the previous capture's buffers.
2916    capture_epoch: Option<u64>,
2917    first: u32,
2918    last: u32,
2919    retained_index: u32,
2920    has_mesh: bool,
2921}
2922
2923/// Key of one maximal consecutive retained stretch: the op keys in draw
2924/// order. Any reorder, count change, range change, recapture, or slot
2925/// release changes the key and forces a rebuild.
2926#[cfg(not(target_arch = "wasm32"))]
2927#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
2928struct RetainedBundleKey {
2929    ops: Vec<RetainedBundleOpKey>,
2930}
2931
2932#[cfg(not(target_arch = "wasm32"))]
2933struct RetainedBundleCacheEntry<B> {
2934    bundle: B,
2935    last_used_frame: u64,
2936}
2937
2938/// Cache of encoded render bundles for retained stretches, generic over the
2939/// bundle payload so the reuse/invalidation/eviction logic is unit-testable
2940/// without a GPU. The full [`RetainedBundleKey`] is the map key — a fresh
2941/// key can only ever build a fresh bundle, never alias a stale one.
2942///
2943/// The surface format and the group-0 uniform bind group are deliberately
2944/// not part of the key: both are fixed for a `GpuRenderer`'s lifetime (a
2945/// surface reconfigure builds a new renderer, and with it an empty cache).
2946#[cfg(not(target_arch = "wasm32"))]
2947struct RetainedBundleCacheImpl<B> {
2948    entries: HashMap<RetainedBundleKey, RetainedBundleCacheEntry<B>>,
2949    frame: u64,
2950    rebuilds: u64,
2951    cached_executes: u64,
2952    window_rebuilds: u64,
2953    window_executes: u64,
2954}
2955
2956#[cfg(not(target_arch = "wasm32"))]
2957type RetainedBundleCache = RetainedBundleCacheImpl<wgpu::RenderBundle>;
2958
2959#[cfg(not(target_arch = "wasm32"))]
2960impl<B> RetainedBundleCacheImpl<B> {
2961    fn new() -> Self {
2962        Self {
2963            entries: HashMap::default(),
2964            frame: 0,
2965            rebuilds: 0,
2966            cached_executes: 0,
2967            window_rebuilds: 0,
2968            window_executes: 0,
2969        }
2970    }
2971
2972    /// True when a bundle for `key` is cached; marks it used this frame and
2973    /// counts a cached execute.
2974    fn hit(&mut self, key: &RetainedBundleKey) -> bool {
2975        let frame = self.frame;
2976        match self.entries.get_mut(key) {
2977            Some(entry) => {
2978                entry.last_used_frame = frame;
2979                self.cached_executes += 1;
2980                self.window_executes += 1;
2981                true
2982            }
2983            None => false,
2984        }
2985    }
2986
2987    /// Stores a freshly built bundle, counting a rebuild.
2988    fn insert(&mut self, key: RetainedBundleKey, bundle: B) {
2989        self.rebuilds += 1;
2990        self.window_rebuilds += 1;
2991        self.entries.insert(
2992            key,
2993            RetainedBundleCacheEntry {
2994                bundle,
2995                last_used_frame: self.frame,
2996            },
2997        );
2998    }
2999
3000    fn get(&self, key: &RetainedBundleKey) -> Option<&B> {
3001        self.entries.get(key).map(|entry| &entry.bundle)
3002    }
3003
3004    /// Drops every cached bundle. Called whenever a replay slot is released:
3005    /// the key compare already makes stale entries unreachable (their epochs
3006    /// can never recur), so this only releases the dropped capture's GPU
3007    /// resources promptly instead of one frame later via eviction.
3008    fn clear(&mut self) {
3009        self.entries.clear();
3010    }
3011
3012    /// Frame boundary: evicts entries the frame did not use — a bundle
3013    /// holds references on its slot's buffers, so unused entries must not
3014    /// accumulate — and emits the rate-limited rebuild/execute telemetry.
3015    fn end_frame(&mut self) {
3016        let frame = self.frame;
3017        self.entries
3018            .retain(|_, entry| entry.last_used_frame >= frame);
3019        self.frame = self.frame.wrapping_add(1);
3020        // Always-on at a cadence that cannot spam; every perf window (120
3021        // frames) under the replay diagnostics flag so short A/B runs see
3022        // the counts. log::warn because log::info is invisible on the
3023        // desktop console.
3024        let due = self.frame.is_multiple_of(1024)
3025            || (cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG")
3026                && self.frame.is_multiple_of(120));
3027        if due && self.window_rebuilds + self.window_executes > 0 {
3028            log::warn!(
3029                "[retained-bundles] {} stretches, {} rebuilds, {} cached executes ({} live bundles)",
3030                self.window_rebuilds + self.window_executes,
3031                self.window_rebuilds,
3032                self.window_executes,
3033                self.entries.len(),
3034            );
3035            self.window_rebuilds = 0;
3036            self.window_executes = 0;
3037        }
3038    }
3039
3040    /// Lifetime (rebuilds, cached executes) for tests and diagnostics.
3041    fn stats(&self) -> (u64, u64) {
3042        (self.rebuilds, self.cached_executes)
3043    }
3044}
3045
3046struct CachedImageTexture {
3047    _texture: wgpu::Texture,
3048    _view: wgpu::TextureView,
3049    nearest_bind_group: wgpu::BindGroup,
3050    linear_bind_group: wgpu::BindGroup,
3051    /// GPU bytes this entry pins (w×h×4): the cache is bounded by BYTES as
3052    /// well as count. A live camera publishes a new multi-MB bitmap id every
3053    /// frame; 256 count-slots of those is ~1.5GB of dead preview textures —
3054    /// which on iOS unified memory counts straight against the process's
3055    /// jetsam limit (measured: the app died mid-scan under an open camera
3056    /// with exactly that ballast).
3057    bytes: usize,
3058}
3059
3060impl CachedImageTexture {
3061    fn bind_group(&self, sampling: ImageSampling) -> &wgpu::BindGroup {
3062        match sampling {
3063            ImageSampling::Nearest => &self.nearest_bind_group,
3064            ImageSampling::Linear => &self.linear_bind_group,
3065        }
3066    }
3067}
3068
3069#[derive(Clone, Copy)]
3070struct GlyphAtlasEntry {
3071    x: u32,
3072    y: u32,
3073    width: u32,
3074    height: u32,
3075}
3076
3077/// Side length the glyph atlas should be rebuilt at after it overflowed at
3078/// `current`: one doubling, never past `max`.
3079///
3080/// Doubling (rather than jumping straight to `max`) is what makes the atlas
3081/// cost track the workload: an app that overflows once needs a little more
3082/// room, not sixteen times more.
3083fn next_glyph_atlas_size(current: u32, max: u32) -> u32 {
3084    current.saturating_mul(2).clamp(1, max.max(1))
3085}
3086
3087struct TextGlyphAtlas {
3088    texture: wgpu::Texture,
3089    _view: wgpu::TextureView,
3090    bind_group: wgpu::BindGroup,
3091    entries: BoundedLruCache<SoftwareGlyphAtlasKey, GlyphAtlasEntry>,
3092    generation: u64,
3093    /// Side length of `texture`, between `TEXT_GLYPH_ATLAS_MIN_SIZE` and the
3094    /// device's ceiling. Every UV is normalised against it, so it has to travel
3095    /// with the atlas rather than be read back off a constant.
3096    size: u32,
3097    /// Largest side length this atlas may grow to: the smaller of
3098    /// `TEXT_GLYPH_ATLAS_MAX_SIZE` and what the device grants. Mobile devices
3099    /// are requested `downlevel_defaults()` limits raised by `using_resolution`,
3100    /// so a device that only offers 2048 would otherwise fail to create the
3101    /// texture outright.
3102    max_size: u32,
3103    cursor_x: u32,
3104    cursor_y: u32,
3105    row_height: u32,
3106    upload_scratch: Vec<u8>,
3107}
3108
3109impl TextGlyphAtlas {
3110    fn new(
3111        device: &wgpu::Device,
3112        image_layout: &wgpu::BindGroupLayout,
3113        sampler: &wgpu::Sampler,
3114        size: u32,
3115    ) -> Self {
3116        let max_size = TEXT_GLYPH_ATLAS_MAX_SIZE.min(device.limits().max_texture_dimension_2d);
3117        let size = size.clamp(TEXT_GLYPH_ATLAS_MIN_SIZE.min(max_size), max_size);
3118        let texture = Self::create_texture(device, size);
3119        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3120        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3121            label: Some("Text Glyph Atlas Bind Group"),
3122            layout: image_layout,
3123            entries: &[
3124                wgpu::BindGroupEntry {
3125                    binding: 0,
3126                    resource: wgpu::BindingResource::TextureView(&view),
3127                },
3128                wgpu::BindGroupEntry {
3129                    binding: 1,
3130                    resource: wgpu::BindingResource::Sampler(sampler),
3131                },
3132            ],
3133        });
3134        Self {
3135            texture,
3136            _view: view,
3137            bind_group,
3138            entries: BoundedLruCache::with_capacity_at_least_one(MAX_TEXT_GLYPH_ATLAS_ITEMS),
3139            generation: 0,
3140            size,
3141            max_size,
3142            cursor_x: TEXT_GLYPH_ATLAS_PADDING,
3143            cursor_y: TEXT_GLYPH_ATLAS_PADDING,
3144            row_height: 0,
3145            upload_scratch: Vec::new(),
3146        }
3147    }
3148
3149    fn create_texture(device: &wgpu::Device, size: u32) -> wgpu::Texture {
3150        device.create_texture(&wgpu::TextureDescriptor {
3151            label: Some("Text Glyph Atlas Texture"),
3152            size: wgpu::Extent3d {
3153                width: size,
3154                height: size,
3155                depth_or_array_layers: 1,
3156            },
3157            mip_level_count: 1,
3158            sample_count: 1,
3159            dimension: wgpu::TextureDimension::D2,
3160            format: wgpu::TextureFormat::R8Unorm,
3161            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3162            view_formats: &[],
3163        })
3164    }
3165
3166    /// Throws every cached glyph away and starts over on a texture one doubling
3167    /// larger, up to [`TextGlyphAtlas::max_size`].
3168    ///
3169    /// `allocate` is a one-way shelf cursor with no compaction, so the only
3170    /// recovery from a full atlas is to start again — and starting again at the
3171    /// same size makes a workload whose live glyph set genuinely does not fit
3172    /// re-raster every glyph every frame. Treating each overflow as the signal
3173    /// to double means the atlas converges on the size the workload actually
3174    /// needs: a text-heavy screen reaches the old fixed 4096 after at most three
3175    /// resets and behaves identically from then on, while a watch face that
3176    /// never overflows never pays for space it will not use.
3177    ///
3178    /// Bumping the generation is what invalidates the cached glyph runs, whose
3179    /// UVs are normalised against the previous size and would otherwise sample
3180    /// the wrong part of the new texture.
3181    fn reset(
3182        &mut self,
3183        device: &wgpu::Device,
3184        image_layout: &wgpu::BindGroupLayout,
3185        sampler: &wgpu::Sampler,
3186    ) {
3187        let generation = self.generation.wrapping_add(1);
3188        let grown = next_glyph_atlas_size(self.size, self.max_size);
3189        let mut next = Self::new(device, image_layout, sampler, grown);
3190        next.generation = generation;
3191        *self = next;
3192    }
3193
3194    fn generation(&self) -> u64 {
3195        self.generation
3196    }
3197
3198    fn size(&self) -> u32 {
3199        self.size
3200    }
3201
3202    fn entry(&mut self, key: &SoftwareGlyphAtlasKey) -> Option<GlyphAtlasEntry> {
3203        self.entries.get(key).copied()
3204    }
3205
3206    fn allocate(&mut self, width: u32, height: u32) -> Option<GlyphAtlasEntry> {
3207        if width == 0
3208            || height == 0
3209            || width + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
3210            || height + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
3211        {
3212            return None;
3213        }
3214
3215        if self.cursor_x + width + TEXT_GLYPH_ATLAS_PADDING > self.size {
3216            self.cursor_x = TEXT_GLYPH_ATLAS_PADDING;
3217            self.cursor_y = self
3218                .cursor_y
3219                .saturating_add(self.row_height)
3220                .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
3221            self.row_height = 0;
3222        }
3223        if self.cursor_y + height + TEXT_GLYPH_ATLAS_PADDING > self.size {
3224            return None;
3225        }
3226
3227        let entry = GlyphAtlasEntry {
3228            x: self.cursor_x,
3229            y: self.cursor_y,
3230            width,
3231            height,
3232        };
3233        self.cursor_x = self
3234            .cursor_x
3235            .saturating_add(width)
3236            .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
3237        self.row_height = self.row_height.max(height);
3238        Some(entry)
3239    }
3240
3241    fn upload_glyph(
3242        &mut self,
3243        key: SoftwareGlyphAtlasKey,
3244        glyph: &SoftwareGlyphAtlasGlyph,
3245        queue: &wgpu::Queue,
3246        executor: &mut WgpuFrameGraphExecutor,
3247        frame_stats: &mut gpu_stats::FrameStats,
3248    ) -> Option<GlyphAtlasEntry> {
3249        if let Some(entry) = self.entry(&key) {
3250            frame_stats.record_text_glyph_atlas_hit();
3251            return Some(entry);
3252        }
3253
3254        let width = u32::try_from(glyph.mask.width).ok()?;
3255        let height = u32::try_from(glyph.mask.height).ok()?;
3256        let entry = self.allocate(width, height)?;
3257        self.upload_scratch.clear();
3258        self.upload_scratch.reserve(
3259            glyph
3260                .mask
3261                .alpha
3262                .len()
3263                .saturating_sub(self.upload_scratch.capacity()),
3264        );
3265        self.upload_scratch.extend(
3266            glyph
3267                .mask
3268                .alpha
3269                .iter()
3270                .map(|alpha| (alpha.clamp(0.0, 1.0) * 255.0).round() as u8),
3271        );
3272
3273        let upload_stats = executor.upload_texture(
3274            queue,
3275            wgpu::TexelCopyTextureInfo {
3276                texture: &self.texture,
3277                mip_level: 0,
3278                origin: wgpu::Origin3d {
3279                    x: entry.x,
3280                    y: entry.y,
3281                    z: 0,
3282                },
3283                aspect: wgpu::TextureAspect::All,
3284            },
3285            &self.upload_scratch,
3286            wgpu::TexelCopyBufferLayout {
3287                offset: 0,
3288                bytes_per_row: Some(entry.width),
3289                rows_per_image: Some(entry.height),
3290            },
3291            wgpu::Extent3d {
3292                width: entry.width,
3293                height: entry.height,
3294                depth_or_array_layers: 1,
3295            },
3296        );
3297        frame_stats.record_command_stats(upload_stats);
3298        frame_stats.record_text_glyph_atlas_miss(entry.width, entry.height);
3299        self.entries.put(key, entry);
3300        Some(entry)
3301    }
3302}
3303
3304struct ImageDrawCmd {
3305    index_start: u32,
3306    scissor: (u32, u32, u32, u32),
3307    image_id: u64,
3308    sampling: ImageSampling,
3309}
3310
3311#[derive(Clone, Copy)]
3312enum GlyphDrawSource {
3313    Shared {
3314        index_start: u32,
3315        index_count: u32,
3316    },
3317    #[cfg(not(target_arch = "wasm32"))]
3318    Retained {
3319        cache_key: TextGlyphRunCacheKey,
3320        uniform_slot: usize,
3321    },
3322}
3323
3324#[derive(Clone, Copy)]
3325struct GlyphDrawCmd {
3326    source: GlyphDrawSource,
3327    scissor: (u32, u32, u32, u32),
3328}
3329
3330impl GlyphDrawCmd {
3331    fn shared(index_start: u32, index_count: u32, scissor: (u32, u32, u32, u32)) -> Self {
3332        Self {
3333            source: GlyphDrawSource::Shared {
3334                index_start,
3335                index_count,
3336            },
3337            scissor,
3338        }
3339    }
3340
3341    #[cfg(not(target_arch = "wasm32"))]
3342    fn retained(
3343        cache_key: TextGlyphRunCacheKey,
3344        uniform_slot: usize,
3345        scissor: (u32, u32, u32, u32),
3346    ) -> Self {
3347        Self {
3348            source: GlyphDrawSource::Retained {
3349                cache_key,
3350                uniform_slot,
3351            },
3352            scissor,
3353        }
3354    }
3355}
3356
3357#[derive(Clone, Copy, Debug, PartialEq)]
3358struct ImageUvRect {
3359    min: [f32; 2],
3360    max: [f32; 2],
3361    sample_bounds: [f32; 4],
3362}
3363
3364// Text raster cache is owned by GpuRenderer and backed by software text images
3365// between measurement and rendering to eliminate duplicate text shaping
3366
3367/// Persistent GPU buffers for batched shape rendering. There is no vertex or
3368/// index buffer: the shape shader pulls quad corners straight out of
3369/// `ShapeData` by `vertex_index`, so the batch is drawn unindexed.
3370struct ShapeBatchBuffers {
3371    shape_buffer: wgpu::Buffer,
3372    gradient_buffer: wgpu::Buffer,
3373    bind_group: wgpu::BindGroup,
3374    shape_capacity: usize,
3375    gradient_capacity: usize,
3376    batch_limits: ShapeBatchLimits,
3377}
3378
3379#[cfg(target_arch = "wasm32")]
3380struct UniformBatchBuffer {
3381    buffer: wgpu::Buffer,
3382    bind_group: wgpu::BindGroup,
3383}
3384
3385#[cfg(target_arch = "wasm32")]
3386struct ImageBatchBuffers {
3387    vertex_buffer: wgpu::Buffer,
3388    index_buffer: wgpu::Buffer,
3389    vertex_capacity: usize,
3390    index_capacity: usize,
3391}
3392
3393#[derive(Clone, Copy, Debug, PartialEq)]
3394struct ViewportUniformParams {
3395    width: u32,
3396    height: u32,
3397    offset: [f32; 2],
3398}
3399
3400#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3401#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
3402enum UploadTarget {
3403    Uniform,
3404    ShapeData,
3405    ShapeGradient,
3406    ImageVertex,
3407    ImageIndex,
3408    #[cfg(not(target_arch = "wasm32"))]
3409    RetainedGlyphUniform,
3410    /// The shared replay-transform buffer; copies land at each slot's fixed
3411    /// 256-byte-aligned offset.
3412    #[cfg(not(target_arch = "wasm32"))]
3413    ReplayTransform,
3414    /// A replay slot's retained paint buffer (color patches land here).
3415    #[cfg(not(target_arch = "wasm32"))]
3416    ReplayPaintData(u32),
3417}
3418
3419#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3420#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
3421struct PendingBufferCopy {
3422    source_offset: u64,
3423    target_offset: u64,
3424    size: u64,
3425    target: UploadTarget,
3426}
3427
3428#[derive(Default)]
3429struct StagedBufferUploads {
3430    bytes: Vec<u8>,
3431    copies: Vec<PendingBufferCopy>,
3432}
3433
3434impl StagedBufferUploads {
3435    fn clear(&mut self) {
3436        self.bytes.clear();
3437        self.copies.clear();
3438    }
3439
3440    fn shrink_retained_capacity(&mut self, max_bytes: usize, max_copies: usize) -> bool {
3441        let mut shrunk = false;
3442        if self.bytes.len() <= max_bytes && self.bytes.capacity() > max_bytes {
3443            self.bytes.shrink_to(max_bytes);
3444            shrunk = true;
3445        }
3446        if self.copies.len() <= max_copies && self.copies.capacity() > max_copies {
3447            self.copies.shrink_to(max_copies);
3448            shrunk = true;
3449        }
3450        shrunk
3451    }
3452
3453    fn is_empty(&self) -> bool {
3454        self.copies.is_empty()
3455    }
3456
3457    #[cfg(test)]
3458    fn payload_for_copy(&self, copy: PendingBufferCopy) -> &[u8] {
3459        let start = copy.source_offset as usize;
3460        let end = start + copy.size as usize;
3461        &self.bytes[start..end]
3462    }
3463
3464    #[cfg(not(target_arch = "wasm32"))]
3465    fn stage(&mut self, target: UploadTarget, bytes: &[u8]) {
3466        self.stage_at(target, 0, bytes);
3467    }
3468
3469    /// Records a GPU copy whose source bytes were already written into the
3470    /// frame upload buffer (via `Queue::write_buffer_with`), so nothing is
3471    /// appended to `bytes`. `source_offset` is relative to the same base the
3472    /// caller later passes to `flush_staged_uploads_at`.
3473    #[cfg(not(target_arch = "wasm32"))]
3474    fn record_upload_copy(
3475        &mut self,
3476        target: UploadTarget,
3477        source_offset: u64,
3478        target_offset: u64,
3479        size: u64,
3480    ) {
3481        if size == 0 {
3482            return;
3483        }
3484        self.copies.push(PendingBufferCopy {
3485            source_offset,
3486            target_offset,
3487            size,
3488            target,
3489        });
3490    }
3491
3492    #[cfg(not(target_arch = "wasm32"))]
3493    fn stage_at(&mut self, target: UploadTarget, target_offset: u64, bytes: &[u8]) {
3494        if bytes.is_empty() {
3495            return;
3496        }
3497
3498        debug_assert_eq!(
3499            bytes.len() % wgpu::COPY_BUFFER_ALIGNMENT as usize,
3500            0,
3501            "buffer uploads must be aligned to copy requirements"
3502        );
3503
3504        let aligned_offset = align_usize_to(self.bytes.len(), wgpu::COPY_BUFFER_ALIGNMENT as usize);
3505        if aligned_offset > self.bytes.len() {
3506            self.bytes.resize(aligned_offset, 0);
3507        }
3508
3509        let source_offset = self.bytes.len() as u64;
3510        self.bytes.extend_from_slice(bytes);
3511        self.copies.push(PendingBufferCopy {
3512            source_offset,
3513            target_offset,
3514            size: bytes.len() as u64,
3515            target,
3516        });
3517    }
3518
3519    fn truncate(&mut self, bytes_len: usize, copies_len: usize) {
3520        self.bytes.truncate(bytes_len);
3521        self.copies.truncate(copies_len);
3522    }
3523}
3524
3525/// The fresh-batch entry list for the shape bind group layout: the batch's
3526/// own data buffers, the shared identity similarity buffer, and — storage
3527/// mode only, where the layout carries the paint entry — the renderer-wide
3528/// dummy paint buffer (fresh draws leave `paint_select` at 0.0).
3529fn shape_batch_bind_group_entries<'a>(
3530    shape_buffer: &'a wgpu::Buffer,
3531    gradient_buffer: &'a wgpu::Buffer,
3532    similarity_buffer: &'a wgpu::Buffer,
3533    paint_buffer: Option<&'a wgpu::Buffer>,
3534) -> Vec<wgpu::BindGroupEntry<'a>> {
3535    let mut entries = vec![
3536        wgpu::BindGroupEntry {
3537            binding: 0,
3538            resource: shape_buffer.as_entire_binding(),
3539        },
3540        wgpu::BindGroupEntry {
3541            binding: 1,
3542            resource: gradient_buffer.as_entire_binding(),
3543        },
3544        wgpu::BindGroupEntry {
3545            binding: 2,
3546            resource: similarity_buffer.as_entire_binding(),
3547        },
3548    ];
3549    if let Some(paint_buffer) = paint_buffer {
3550        entries.push(wgpu::BindGroupEntry {
3551            binding: 3,
3552            resource: paint_buffer.as_entire_binding(),
3553        });
3554    }
3555    entries
3556}
3557
3558impl ShapeBatchBuffers {
3559    fn new(
3560        device: &wgpu::Device,
3561        bind_group_layout: &wgpu::BindGroupLayout,
3562        similarity_buffer: &wgpu::Buffer,
3563        paint_buffer: Option<&wgpu::Buffer>,
3564        batch_limits: ShapeBatchLimits,
3565    ) -> Self {
3566        debug_assert_eq!(
3567            paint_buffer.is_some(),
3568            batch_limits.storage,
3569            "the paint binding exists exactly when the layout is in storage mode"
3570        );
3571        let initial_shape_cap = batch_limits.initial_shape_capacity();
3572        let initial_gradient_cap = batch_limits.initial_gradient_capacity();
3573
3574        let shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3575            label: Some("Shape Data Buffer"),
3576            size: (std::mem::size_of::<ShapeData>() * initial_shape_cap) as u64,
3577            usage: batch_limits.data_buffer_usage(),
3578            mapped_at_creation: false,
3579        });
3580
3581        let gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3582            label: Some("Gradient Buffer"),
3583            size: (std::mem::size_of::<GradientStop>() * initial_gradient_cap) as u64,
3584            usage: batch_limits.data_buffer_usage(),
3585            mapped_at_creation: false,
3586        });
3587
3588        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3589            label: Some("Shape Bind Group"),
3590            layout: bind_group_layout,
3591            entries: &shape_batch_bind_group_entries(
3592                &shape_buffer,
3593                &gradient_buffer,
3594                similarity_buffer,
3595                paint_buffer,
3596            ),
3597        });
3598
3599        Self {
3600            shape_buffer,
3601            gradient_buffer,
3602            bind_group,
3603            shape_capacity: initial_shape_cap,
3604            gradient_capacity: initial_gradient_cap,
3605            batch_limits,
3606        }
3607    }
3608
3609    /// Ensure buffers have enough capacity, resizing if needed.
3610    /// Clamps growth to prevent excessive allocations for huge scenes.
3611    fn ensure_capacity(
3612        &mut self,
3613        device: &wgpu::Device,
3614        bind_group_layout: &wgpu::BindGroupLayout,
3615        similarity_buffer: &wgpu::Buffer,
3616        paint_buffer: Option<&wgpu::Buffer>,
3617        shapes_needed: usize,
3618        gradients_needed: usize,
3619    ) {
3620        let mut need_bind_group_update = false;
3621
3622        // In uniform mode the shape and gradient buffers start at the cap
3623        // (the shader's fixed-size array length) so these never fire; in
3624        // storage mode they double toward the cap as scenes demand.
3625        if shapes_needed > self.shape_capacity
3626            && self.shape_capacity < self.batch_limits.max_shapes_per_batch
3627        {
3628            let new_cap = shapes_needed
3629                .next_power_of_two()
3630                .min(self.batch_limits.max_shapes_per_batch);
3631            self.shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3632                label: Some("Shape Data Buffer"),
3633                size: (std::mem::size_of::<ShapeData>() * new_cap) as u64,
3634                usage: self.batch_limits.data_buffer_usage(),
3635                mapped_at_creation: false,
3636            });
3637            self.shape_capacity = new_cap;
3638            need_bind_group_update = true;
3639        }
3640
3641        if gradients_needed > self.gradient_capacity
3642            && self.gradient_capacity < self.batch_limits.max_gradient_stops
3643        {
3644            let new_cap = gradients_needed
3645                .max(1)
3646                .next_power_of_two()
3647                .min(self.batch_limits.max_gradient_stops);
3648            self.gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3649                label: Some("Gradient Buffer"),
3650                size: (std::mem::size_of::<GradientStop>() * new_cap) as u64,
3651                usage: self.batch_limits.data_buffer_usage(),
3652                mapped_at_creation: false,
3653            });
3654            self.gradient_capacity = new_cap;
3655            need_bind_group_update = true;
3656        }
3657
3658        if need_bind_group_update {
3659            self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3660                label: Some("Shape Bind Group"),
3661                layout: bind_group_layout,
3662                entries: &shape_batch_bind_group_entries(
3663                    &self.shape_buffer,
3664                    &self.gradient_buffer,
3665                    similarity_buffer,
3666                    paint_buffer,
3667                ),
3668            });
3669        }
3670    }
3671}
3672
3673#[cfg(target_arch = "wasm32")]
3674impl UniformBatchBuffer {
3675    fn new(device: &wgpu::Device, bind_group_layout: &wgpu::BindGroupLayout) -> Self {
3676        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
3677            label: Some("Viewport Uniform Batch Buffer"),
3678            size: std::mem::size_of::<Uniforms>() as u64,
3679            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3680            mapped_at_creation: false,
3681        });
3682        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3683            label: Some("Viewport Uniform Batch Bind Group"),
3684            layout: bind_group_layout,
3685            entries: &[wgpu::BindGroupEntry {
3686                binding: 0,
3687                resource: buffer.as_entire_binding(),
3688            }],
3689        });
3690        Self { buffer, bind_group }
3691    }
3692}
3693
3694#[cfg(target_arch = "wasm32")]
3695impl ImageBatchBuffers {
3696    fn new(device: &wgpu::Device) -> Self {
3697        let vertex_capacity = 4;
3698        let index_capacity = 6;
3699        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3700            label: Some("Image Vertex Batch Buffer"),
3701            size: (std::mem::size_of::<Vertex>() * vertex_capacity) as u64,
3702            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
3703            mapped_at_creation: false,
3704        });
3705        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3706            label: Some("Image Index Batch Buffer"),
3707            size: (std::mem::size_of::<u32>() * index_capacity) as u64,
3708            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
3709            mapped_at_creation: false,
3710        });
3711        Self {
3712            vertex_buffer,
3713            index_buffer,
3714            vertex_capacity,
3715            index_capacity,
3716        }
3717    }
3718
3719    fn ensure_capacity(
3720        &mut self,
3721        device: &wgpu::Device,
3722        vertices_needed: usize,
3723        indices_needed: usize,
3724    ) {
3725        let hard_max_bytes = HARD_MAX_BUFFER_MB * 1024 * 1024;
3726        if vertices_needed > self.vertex_capacity {
3727            let desired = vertices_needed.next_power_of_two();
3728            let max_count = hard_max_bytes / std::mem::size_of::<Vertex>();
3729            let new_cap = desired.min(max_count);
3730            self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3731                label: Some("Image Vertex Batch Buffer"),
3732                size: (std::mem::size_of::<Vertex>() * new_cap) as u64,
3733                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
3734                mapped_at_creation: false,
3735            });
3736            self.vertex_capacity = new_cap;
3737        }
3738        if indices_needed > self.index_capacity {
3739            let desired = indices_needed.next_power_of_two();
3740            let max_count = hard_max_bytes / std::mem::size_of::<u32>();
3741            let new_cap = desired.min(max_count);
3742            self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3743                label: Some("Image Index Batch Buffer"),
3744                size: (std::mem::size_of::<u32>() * new_cap) as u64,
3745                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
3746                mapped_at_creation: false,
3747            });
3748            self.index_capacity = new_cap;
3749        }
3750    }
3751}
3752
3753// Text image cache keys are local to rasterized WGPU text batches
3754
3755pub struct GpuRenderer {
3756    pub(crate) device: Arc<wgpu::Device>,
3757    pub(crate) queue: Arc<wgpu::Queue>,
3758    /// This instance's renderer epoch, stamped by `init_gpu` at
3759    /// construction. A packet whose `renderer_epoch` differs was built
3760    /// against another instance and is cancelled at the head of
3761    /// [`Self::render`], never drawn.
3762    renderer_epoch: u64,
3763    /// The producer feed generation this store's slot universe belongs to:
3764    /// seeded at construction, advanced by `consume_replay_ops` when a
3765    /// higher-generation batch arrives (the batch itself carries the
3766    /// retirement releases). The store never reads the producer's
3767    /// thread-local — this field is its only generation authority.
3768    #[cfg(not(target_arch = "wasm32"))]
3769    store_feed_generation: u64,
3770    surface_format: wgpu::TextureFormat,
3771    shape_batch_limits: ShapeBatchLimits,
3772    pipeline: wgpu::RenderPipeline,
3773    pipeline_dst_out: wgpu::RenderPipeline,
3774    /// `Some` exactly in storage mode: the retained-mesh pipeline (`vs_mesh`
3775    /// over a vertex buffer) that replay slots with a captured arc mesh draw
3776    /// through. Uniform-mode devices never host retained slots.
3777    #[cfg(not(target_arch = "wasm32"))]
3778    mesh_pipeline: Option<wgpu::RenderPipeline>,
3779    /// `Some` exactly when this renderer latched the instanced-quad path at
3780    /// construction (storage mode && `CRANPOSE_INSTANCED_QUADS` != 0). Read
3781    /// ONCE per renderer lifetime — cached retained bundles encode the
3782    /// selection, so it must never move under them (see
3783    /// [`instanced_quads_enabled`]).
3784    #[cfg(not(target_arch = "wasm32"))]
3785    instanced_quads: Option<InstancedQuadPipelines>,
3786    #[cfg(target_arch = "wasm32")]
3787    uniform_bind_group_layout: wgpu::BindGroupLayout,
3788    shape_bind_group_layout: wgpu::BindGroupLayout,
3789    /// `Some` exactly in storage mode: the 16-byte stand-in every fresh
3790    /// batch binds at the paint entry (see `shape_batch_bind_group_entries`).
3791    dummy_paint_buffer: Option<wgpu::Buffer>,
3792    /// Shared identity binding for `@group(1) @binding(2)`: every freshly
3793    /// converted shape batch draws untransformed through this one buffer.
3794    identity_similarity_buffer: wgpu::Buffer,
3795    #[cfg(not(target_arch = "wasm32"))]
3796    replay_slots: ReplaySlotStore,
3797    image_pipeline: wgpu::RenderPipeline,
3798    image_pipeline_dst_out: wgpu::RenderPipeline,
3799    glyph_atlas_pipeline: wgpu::RenderPipeline,
3800    #[cfg(not(target_arch = "wasm32"))]
3801    retained_glyph_atlas_pipeline: wgpu::RenderPipeline,
3802    image_bind_group_layout: wgpu::BindGroupLayout,
3803    #[cfg(not(target_arch = "wasm32"))]
3804    retained_glyph_uniform_bind_group_layout: wgpu::BindGroupLayout,
3805    image_nearest_sampler: wgpu::Sampler,
3806    image_linear_sampler: wgpu::Sampler,
3807    text_fonts: SoftwareTextFontSet,
3808    // Persistent GPU buffers (reused across frames)
3809    #[cfg(not(target_arch = "wasm32"))]
3810    upload_buffer: wgpu::Buffer,
3811    #[cfg(not(target_arch = "wasm32"))]
3812    uniform_buffer: wgpu::Buffer,
3813    #[cfg(not(target_arch = "wasm32"))]
3814    uniform_bind_group: wgpu::BindGroup,
3815    #[cfg(not(target_arch = "wasm32"))]
3816    shape_buffers: ShapeBatchBuffers,
3817    #[cfg(not(target_arch = "wasm32"))]
3818    image_vertex_buffer: wgpu::Buffer,
3819    #[cfg(not(target_arch = "wasm32"))]
3820    image_index_buffer: wgpu::Buffer,
3821    #[cfg(not(target_arch = "wasm32"))]
3822    retained_glyph_uniform_buffer: wgpu::Buffer,
3823    #[cfg(not(target_arch = "wasm32"))]
3824    retained_glyph_uniform_bind_group: wgpu::BindGroup,
3825    #[cfg(not(target_arch = "wasm32"))]
3826    retained_glyph_uniform_stride: u64,
3827    #[cfg(not(target_arch = "wasm32"))]
3828    retained_glyph_uniform_capacity: usize,
3829    #[cfg(not(target_arch = "wasm32"))]
3830    retained_glyph_uniform_cursor: usize,
3831    #[cfg(target_arch = "wasm32")]
3832    wasm_uniform_batches: Vec<UniformBatchBuffer>,
3833    #[cfg(target_arch = "wasm32")]
3834    wasm_uniform_batch_cursor: usize,
3835    #[cfg(target_arch = "wasm32")]
3836    wasm_shape_batches: Vec<ShapeBatchBuffers>,
3837    #[cfg(target_arch = "wasm32")]
3838    wasm_shape_batch_cursor: usize,
3839    #[cfg(target_arch = "wasm32")]
3840    wasm_image_batches: Vec<ImageBatchBuffers>,
3841    #[cfg(target_arch = "wasm32")]
3842    wasm_image_batch_cursor: usize,
3843    image_texture_cache: BoundedLruCache<u64, CachedImageTexture>,
3844    /// Total `CachedImageTexture::bytes` currently in the cache.
3845    image_texture_cache_bytes: usize,
3846    text_image_cache: BoundedLruCache<TextImageCacheKey, CachedTextImage>,
3847    text_glyph_atlas: TextGlyphAtlas,
3848    text_glyph_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedTextGlyphRun>,
3849    #[cfg(not(target_arch = "wasm32"))]
3850    text_glyph_gpu_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedGpuTextGlyphRun>,
3851    text_glyph_mask_cache: SoftwareGlyphRasterCache,
3852    text_line_index_cache: TextLineIndexCache,
3853    scratch_shape_data: Vec<ShapeData>,
3854    scratch_gradients: Vec<GradientStop>,
3855    scratch_image_vertices: Vec<Vertex>,
3856    scratch_image_indices: Vec<u32>,
3857    scratch_image_cmds: Vec<ImageDrawCmd>,
3858    scratch_glyph_cmds: Vec<GlyphDrawCmd>,
3859    scratch_text_glyph_run: Vec<SoftwareGlyphAtlasRunGlyph>,
3860    scratch_text_glyph_placements: Vec<SoftwareGlyphAtlasPlacement>,
3861    scratch_text_glyph_quads: Vec<CachedTextGlyphQuad>,
3862    scratch_segment_items: Vec<(usize, SegmentDrawItem)>,
3863    scratch_effect_ranges: Vec<Range<usize>>,
3864    scratch_layer_events: Vec<LayerEvent>,
3865    staged_uploads: StagedBufferUploads,
3866    frame_graph_executor: WgpuFrameGraphExecutor,
3867    deferred_offscreen_releases: Vec<OffscreenTarget>,
3868    effect_renderer: EffectRenderer,
3869    layer_surface_cache: LayerSurfaceCache,
3870    observed_scene_range_cache_misses: BoundedLruCache<LayerRasterCacheKey, ()>,
3871    shadow_surface_cache: BoundedLruCache<ShadowSurfaceCacheKey, CachedShadowSurface>,
3872    shadow_surface_cache_bytes: u64,
3873    frame_stats: gpu_stats::FrameStats,
3874    last_frame_stats: Option<gpu_stats::FrameStatsSnapshot>,
3875    pending_frame_warmup_frames: u8,
3876    frame_count: u64,
3877    gpu_stats_enabled: bool,
3878    warning_state: RendererWarningState,
3879    #[cfg(not(target_arch = "wasm32"))]
3880    replay_upload_stats: ReplayUploadStats,
3881    /// The frame's replay recolor patches, parked here by
3882    /// `consume_replay_ops` until the retained prepare arms drain them
3883    /// (`stage_replay_patches`). The vec this frame's ops displace is last
3884    /// frame's, already drained empty, and returns to the producer with
3885    /// the ack — capacity ping-pongs planner queue → packet ops → here →
3886    /// ack return, so neither side allocates per frame (P4b).
3887    #[cfg(not(target_arch = "wasm32"))]
3888    replay_color_patches: Vec<crate::scene::ColorPatch>,
3889    /// Drain arena for `replay_color_patches`: `stage_replay_patches`
3890    /// swaps against this instead of `mem::take`, so both keep their
3891    /// high-water capacity across frames. Always empty between drains.
3892    #[cfg(not(target_arch = "wasm32"))]
3893    color_patch_scratch: Vec<crate::scene::ColorPatch>,
3894    /// Recycled confirmations buffer for the next [`crate::frame_packet::ReplayAck`]:
3895    /// `consume_replay_ops` fills it, the planner drains it in `apply_ack`,
3896    /// and the render loop hands the emptied vec (capacity intact) back
3897    /// here — the ack channel's half of the P4b no-allocation contract.
3898    #[cfg(not(target_arch = "wasm32"))]
3899    replay_ack_confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
3900    /// Lifetime count of replay-ops batches dropped whole by the
3901    /// generation check in `consume_replay_ops` — fail-closed against ops
3902    /// planned under a slot universe this store no longer holds.
3903    /// Synchronously impossible today; structural for the pipeline split.
3904    #[cfg(not(target_arch = "wasm32"))]
3905    replay_generation_drops: u64,
3906    /// Cached render bundles for maximal consecutive retained stretches in
3907    /// the fused segment pass (`CRANPOSE_RETAINED_BUNDLES` kill switch).
3908    #[cfg(not(target_arch = "wasm32"))]
3909    retained_bundle_cache: RetainedBundleCache,
3910}
3911
3912/// Running totals for retained-slot patch uploads, the paint-bandwidth
3913/// instrument: recolors upload 16-byte paint records (plus gradient stop
3914/// spans), coalesced per slot between the lowest and highest patched
3915/// index, so `bytes` versus `ideal_bytes` (patched colors alone) is just
3916/// the untouched records inside each coalesced span.
3917#[cfg(not(target_arch = "wasm32"))]
3918#[derive(Default)]
3919struct ReplayUploadStats {
3920    calls: u64,
3921    patched_calls: u64,
3922    patches: u64,
3923    slots: u64,
3924    records: u64,
3925    bytes: u64,
3926    ideal_bytes: u64,
3927    max_frame_bytes: u64,
3928}
3929
3930#[cfg(not(target_arch = "wasm32"))]
3931impl ReplayUploadStats {
3932    /// One aggregate line roughly every few seconds: cheap enough to stay
3933    /// on unconditionally, which matters because the watch cannot take
3934    /// setprop-backed diag flags — its logcat is the only channel, and a
3935    /// measurement window must catch several lines. Counts every drain
3936    /// call (the drain runs several times per frame; only the first sees
3937    /// patches) so a target with zero paint traffic still reports an
3938    /// affirmative zero instead of silence, while the averages divide by
3939    /// PATCHED calls so they read as per-frame numbers.
3940    /// warn level: the platform loggers filter info on desktop.
3941    const REPORT_CALLS: u64 = 1024;
3942
3943    fn note_frame(&mut self, patches: u64, slots: u64, records: u64, bytes: u64, ideal: u64) {
3944        self.calls += 1;
3945        if patches > 0 {
3946            self.patched_calls += 1;
3947            self.patches += patches;
3948            self.slots += slots;
3949            self.records += records;
3950            self.bytes += bytes;
3951            self.ideal_bytes += ideal;
3952            self.max_frame_bytes = self.max_frame_bytes.max(bytes);
3953        }
3954        if self.calls >= Self::REPORT_CALLS {
3955            let patched = self.patched_calls.max(1);
3956            log::warn!(
3957                "[replay-upload] {} patched of {} drains: avg {:.1} KB/frame (max {:.1} KB), \
3958                 color-only would be {:.1} KB/frame; avg {} patches over {} records in {} slots",
3959                self.patched_calls,
3960                self.calls,
3961                self.bytes as f64 / patched as f64 / 1024.0,
3962                self.max_frame_bytes as f64 / 1024.0,
3963                self.ideal_bytes as f64 / patched as f64 / 1024.0,
3964                self.patches / patched,
3965                self.records / patched,
3966                self.slots / patched,
3967            );
3968            *self = Self::default();
3969        }
3970    }
3971}
3972
3973fn image_sampler_descriptor(sampling: ImageSampling) -> wgpu::SamplerDescriptor<'static> {
3974    let filter = match sampling {
3975        ImageSampling::Nearest => wgpu::FilterMode::Nearest,
3976        ImageSampling::Linear => wgpu::FilterMode::Linear,
3977    };
3978    wgpu::SamplerDescriptor {
3979        label: Some(match sampling {
3980            ImageSampling::Nearest => "Nearest Image Sampler",
3981            ImageSampling::Linear => "Linear Image Sampler",
3982        }),
3983        address_mode_u: wgpu::AddressMode::ClampToEdge,
3984        address_mode_v: wgpu::AddressMode::ClampToEdge,
3985        address_mode_w: wgpu::AddressMode::ClampToEdge,
3986        mag_filter: filter,
3987        min_filter: filter,
3988        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3989        ..Default::default()
3990    }
3991}
3992
3993#[cfg(test)]
3994fn layer_raster_cache_candidate(
3995    layer: &LayerNode,
3996    root_scale: f32,
3997    has_backdrop_underlay: bool,
3998    allow_runtime_cache: bool,
3999) -> Option<(LayerRasterCacheKey, Rect)> {
4000    let mut layer_surface_requirements_cache = HashMap::new();
4001    let surface_requirements =
4002        layer_surface_requirements_cached(layer, &mut layer_surface_requirements_cache);
4003    let runtime_cache_is_safe = allow_runtime_cache
4004        && surface_requirements
4005            .surface_requirements
4006            .has_isolating_requirement()
4007        && !layer
4008            .effect()
4009            .is_some_and(RenderEffect::contains_runtime_shader);
4010    let cache_is_allowed = layer.cache_policy == CachePolicy::Auto
4011        || (allow_runtime_cache && surface_requirements.has_renderer_forced_surface())
4012        || runtime_cache_is_safe;
4013    if !cache_is_allowed {
4014        return None;
4015    }
4016    if layer_uses_external_backdrop_input(layer, has_backdrop_underlay) {
4017        return None;
4018    }
4019    if layer
4020        .effect()
4021        .is_some_and(RenderEffect::contains_runtime_shader)
4022    {
4023        return None;
4024    }
4025
4026    let logical_rect = estimate_layer_surface_rect(layer);
4027    let pixel_size = surface_target_size(logical_rect, root_scale, u32::MAX);
4028    Some((
4029        LayerRasterCacheKey::new(
4030            layer.node_id,
4031            layer.target_content_hash(),
4032            layer.effect_hash(),
4033            logical_rect,
4034            pixel_size,
4035            ScaleBucket::from_scale(root_scale),
4036        ),
4037        logical_rect,
4038    ))
4039}
4040
4041impl GpuRenderer {
4042    pub fn new(
4043        device: Arc<wgpu::Device>,
4044        queue: Arc<wgpu::Queue>,
4045        surface_format: wgpu::TextureFormat,
4046        adapter_backend: wgpu::Backend,
4047        text_fonts: SoftwareTextFontSet,
4048        renderer_epoch: u64,
4049        store_feed_generation: u64,
4050    ) -> Self {
4051        #[cfg(target_arch = "wasm32")]
4052        let _ = store_feed_generation;
4053        let shape_batch_limits = ShapeBatchLimits::for_device(&device);
4054        let uniform_bind_group_layout =
4055            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4056                label: Some("Uniform Bind Group Layout"),
4057                entries: &[wgpu::BindGroupLayoutEntry {
4058                    binding: 0,
4059                    visibility: wgpu::ShaderStages::VERTEX,
4060                    ty: wgpu::BindingType::Buffer {
4061                        ty: wgpu::BufferBindingType::Uniform,
4062                        has_dynamic_offset: false,
4063                        min_binding_size: None,
4064                    },
4065                    count: None,
4066                }],
4067            });
4068        #[cfg(not(target_arch = "wasm32"))]
4069        let retained_glyph_uniform_bind_group_layout =
4070            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4071                label: Some("Retained Glyph Dynamic Uniform Bind Group Layout"),
4072                entries: &[wgpu::BindGroupLayoutEntry {
4073                    binding: 0,
4074                    visibility: wgpu::ShaderStages::VERTEX,
4075                    ty: wgpu::BindingType::Buffer {
4076                        ty: wgpu::BufferBindingType::Uniform,
4077                        has_dynamic_offset: true,
4078                        min_binding_size: wgpu::BufferSize::new(
4079                            std::mem::size_of::<Uniforms>() as u64
4080                        ),
4081                    },
4082                    count: None,
4083                }],
4084            });
4085
4086        // Read-only storage bindings where the device has them (so a whole
4087        // scene fits one batch); uniform arrays on WebGL-class devices, which
4088        // have no storage buffers in fragment shaders. The shape array is
4089        // visible to the vertex stage as well: the pipeline has no vertex
4090        // buffer and `vs_main` pulls quad corners from ShapeData. (Storage
4091        // mode is gated on `max_storage_buffers_per_shader_stage`, which GL
4092        // backends report as the minimum across stages, so a device that
4093        // cannot read storage from the vertex stage falls back to uniforms.)
4094        let mut shape_bind_group_layout_entries = vec![
4095            wgpu::BindGroupLayoutEntry {
4096                binding: 0,
4097                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
4098                ty: wgpu::BindingType::Buffer {
4099                    ty: shape_batch_limits.data_binding_type(),
4100                    has_dynamic_offset: false,
4101                    min_binding_size: None,
4102                },
4103                count: None,
4104            },
4105            wgpu::BindGroupLayoutEntry {
4106                binding: 1,
4107                visibility: wgpu::ShaderStages::FRAGMENT,
4108                ty: wgpu::BindingType::Buffer {
4109                    ty: shape_batch_limits.data_binding_type(),
4110                    has_dynamic_offset: false,
4111                    min_binding_size: None,
4112                },
4113                count: None,
4114            },
4115            // The similarity transform rides a dynamic offset so
4116            // retained draws sharing one captured batch can each
4117            // apply their own transform; ordinary batches pass
4118            // offset 0 into the identity buffer.
4119            wgpu::BindGroupLayoutEntry {
4120                binding: 2,
4121                visibility: wgpu::ShaderStages::VERTEX,
4122                ty: wgpu::BindingType::Buffer {
4123                    ty: wgpu::BufferBindingType::Uniform,
4124                    has_dynamic_offset: true,
4125                    min_binding_size: wgpu::BufferSize::new(
4126                        std::mem::size_of::<SimilarityTransform>() as u64,
4127                    ),
4128                },
4129                count: None,
4130            },
4131        ];
4132        // Retained-slot paint colors, read by the vertex stage under
4133        // `paint_select` (see `shape_shader_source`). Storage mode only:
4134        // the uniform-variant shader never declares the array, and
4135        // uniform-mode devices never host retained slots, so their layout
4136        // stays exactly the three-entry one the uniform pipeline expects.
4137        if shape_batch_limits.storage {
4138            shape_bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry {
4139                binding: 3,
4140                visibility: wgpu::ShaderStages::VERTEX,
4141                ty: wgpu::BindingType::Buffer {
4142                    ty: wgpu::BufferBindingType::Storage { read_only: true },
4143                    has_dynamic_offset: false,
4144                    min_binding_size: None,
4145                },
4146                count: None,
4147            });
4148        }
4149        let shape_bind_group_layout =
4150            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4151                label: Some("Shape Bind Group Layout"),
4152                entries: &shape_bind_group_layout_entries,
4153            });
4154
4155        let identity_similarity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4156            label: Some("Identity Similarity Buffer"),
4157            size: std::mem::size_of::<SimilarityTransform>() as u64,
4158            usage: wgpu::BufferUsages::UNIFORM,
4159            mapped_at_creation: true,
4160        });
4161        identity_similarity_buffer
4162            .slice(..)
4163            .get_mapped_range_mut()
4164            .copy_from_slice(bytemuck::bytes_of(&SimilarityTransform::IDENTITY));
4165        identity_similarity_buffer.unmap();
4166
4167        // Fresh-batch bind groups need a resource at the paint binding even
4168        // though their draws leave `paint_select` at 0.0 and never use the
4169        // value; one minimal buffer (a single never-read vec4) serves every
4170        // batch. Uniform-mode layouts have no paint entry, so none exists.
4171        let dummy_paint_buffer = shape_batch_limits.storage.then(|| {
4172            device.create_buffer(&wgpu::BufferDescriptor {
4173                label: Some("Dummy Paint Buffer"),
4174                size: std::mem::size_of::<[f32; 4]>() as u64,
4175                usage: wgpu::BufferUsages::STORAGE,
4176                mapped_at_creation: false,
4177            })
4178        });
4179        #[cfg(not(target_arch = "wasm32"))]
4180        let replay_slot_store = ReplaySlotStore::new(&device);
4181
4182        let pipeline = create_shape_pipeline(
4183            &device,
4184            surface_format,
4185            &uniform_bind_group_layout,
4186            &shape_bind_group_layout,
4187            BlendMode::SrcOver,
4188            shape_batch_limits,
4189        );
4190        let pipeline_dst_out = create_shape_pipeline(
4191            &device,
4192            surface_format,
4193            &uniform_bind_group_layout,
4194            &shape_bind_group_layout,
4195            BlendMode::DstOut,
4196            shape_batch_limits,
4197        );
4198        #[cfg(not(target_arch = "wasm32"))]
4199        let mesh_pipeline = shape_batch_limits.storage.then(|| {
4200            create_mesh_shape_pipeline(
4201                &device,
4202                surface_format,
4203                &uniform_bind_group_layout,
4204                &shape_bind_group_layout,
4205                shape_batch_limits,
4206            )
4207        });
4208        // The instanced-quad selection is LATCHED here, once per renderer:
4209        // cached retained bundles encode whichever pipelines this resolves
4210        // to, so a per-draw env read could let a bundle replay a selection
4211        // the direct path no longer makes. Storage mode only — the
4212        // uniform/WebGL path keeps `vs_main` and its plain draws untouched.
4213        #[cfg(not(target_arch = "wasm32"))]
4214        let instanced_quads =
4215            (shape_batch_limits.storage && instanced_quads_enabled()).then(|| {
4216                let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4217                    label: Some("Instanced Quad Index Buffer"),
4218                    size: std::mem::size_of_val(&INSTANCED_QUAD_INDICES) as u64,
4219                    usage: wgpu::BufferUsages::INDEX,
4220                    mapped_at_creation: true,
4221                });
4222                index_buffer
4223                    .slice(..)
4224                    .get_mapped_range_mut()
4225                    .copy_from_slice(bytemuck::cast_slice(&INSTANCED_QUAD_INDICES));
4226                index_buffer.unmap();
4227                InstancedQuadPipelines {
4228                    pipeline: create_instanced_shape_pipeline(
4229                        &device,
4230                        surface_format,
4231                        &uniform_bind_group_layout,
4232                        &shape_bind_group_layout,
4233                        BlendMode::SrcOver,
4234                        shape_batch_limits,
4235                    ),
4236                    pipeline_dst_out: create_instanced_shape_pipeline(
4237                        &device,
4238                        surface_format,
4239                        &uniform_bind_group_layout,
4240                        &shape_bind_group_layout,
4241                        BlendMode::DstOut,
4242                        shape_batch_limits,
4243                    ),
4244                    index_buffer,
4245                }
4246            });
4247
4248        let image_bind_group_layout =
4249            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4250                label: Some("Image Texture Bind Group Layout"),
4251                entries: &[
4252                    wgpu::BindGroupLayoutEntry {
4253                        binding: 0,
4254                        visibility: wgpu::ShaderStages::FRAGMENT,
4255                        ty: wgpu::BindingType::Texture {
4256                            multisampled: false,
4257                            view_dimension: wgpu::TextureViewDimension::D2,
4258                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
4259                        },
4260                        count: None,
4261                    },
4262                    wgpu::BindGroupLayoutEntry {
4263                        binding: 1,
4264                        visibility: wgpu::ShaderStages::FRAGMENT,
4265                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
4266                        count: None,
4267                    },
4268                ],
4269            });
4270
4271        let image_pipeline = create_image_pipeline(
4272            &device,
4273            surface_format,
4274            &uniform_bind_group_layout,
4275            &image_bind_group_layout,
4276            BlendMode::SrcOver,
4277        );
4278        let image_pipeline_dst_out = create_image_pipeline(
4279            &device,
4280            surface_format,
4281            &uniform_bind_group_layout,
4282            &image_bind_group_layout,
4283            BlendMode::DstOut,
4284        );
4285        let glyph_atlas_pipeline = create_glyph_atlas_pipeline(
4286            &device,
4287            surface_format,
4288            &uniform_bind_group_layout,
4289            &image_bind_group_layout,
4290        );
4291        #[cfg(not(target_arch = "wasm32"))]
4292        let retained_glyph_atlas_pipeline = create_glyph_atlas_pipeline(
4293            &device,
4294            surface_format,
4295            &retained_glyph_uniform_bind_group_layout,
4296            &image_bind_group_layout,
4297        );
4298
4299        #[cfg(not(target_arch = "wasm32"))]
4300        let upload_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4301            label: Some("Frame Upload Buffer"),
4302            size: INITIAL_UPLOAD_BUFFER_BYTES,
4303            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
4304            mapped_at_creation: false,
4305        });
4306
4307        #[cfg(not(target_arch = "wasm32"))]
4308        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4309            label: Some("Uniform Buffer"),
4310            size: std::mem::size_of::<Uniforms>() as u64,
4311            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4312            mapped_at_creation: false,
4313        });
4314
4315        #[cfg(not(target_arch = "wasm32"))]
4316        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
4317            label: Some("Uniform Bind Group"),
4318            layout: &uniform_bind_group_layout,
4319            entries: &[wgpu::BindGroupEntry {
4320                binding: 0,
4321                resource: uniform_buffer.as_entire_binding(),
4322            }],
4323        });
4324
4325        #[cfg(not(target_arch = "wasm32"))]
4326        let shape_buffers = ShapeBatchBuffers::new(
4327            &device,
4328            &shape_bind_group_layout,
4329            &identity_similarity_buffer,
4330            dummy_paint_buffer.as_ref(),
4331            shape_batch_limits,
4332        );
4333
4334        let image_nearest_sampler =
4335            device.create_sampler(&image_sampler_descriptor(ImageSampling::Nearest));
4336        let image_linear_sampler =
4337            device.create_sampler(&image_sampler_descriptor(ImageSampling::Linear));
4338        let text_glyph_atlas = TextGlyphAtlas::new(
4339            &device,
4340            &image_bind_group_layout,
4341            &image_nearest_sampler,
4342            TEXT_GLYPH_ATLAS_MIN_SIZE,
4343        );
4344
4345        #[cfg(not(target_arch = "wasm32"))]
4346        let image_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4347            label: Some("Image Vertex Buffer"),
4348            size: (std::mem::size_of::<Vertex>() * 4) as u64,
4349            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
4350            mapped_at_creation: false,
4351        });
4352
4353        #[cfg(not(target_arch = "wasm32"))]
4354        let image_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4355            label: Some("Image Index Buffer"),
4356            size: (std::mem::size_of::<u32>() * 6) as u64,
4357            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
4358            mapped_at_creation: false,
4359        });
4360        #[cfg(not(target_arch = "wasm32"))]
4361        let retained_glyph_uniform_stride = align_usize_to(
4362            std::mem::size_of::<Uniforms>(),
4363            (device.limits().min_uniform_buffer_offset_alignment as usize)
4364                .max(wgpu::COPY_BUFFER_ALIGNMENT as usize),
4365        ) as u64;
4366        #[cfg(not(target_arch = "wasm32"))]
4367        let retained_glyph_uniform_capacity = INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS;
4368        #[cfg(not(target_arch = "wasm32"))]
4369        let retained_glyph_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4370            label: Some("Retained Glyph Uniform Buffer"),
4371            size: retained_glyph_uniform_stride * retained_glyph_uniform_capacity as u64,
4372            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4373            mapped_at_creation: false,
4374        });
4375        #[cfg(not(target_arch = "wasm32"))]
4376        let retained_glyph_uniform_bind_group =
4377            device.create_bind_group(&wgpu::BindGroupDescriptor {
4378                label: Some("Retained Glyph Uniform Bind Group"),
4379                layout: &retained_glyph_uniform_bind_group_layout,
4380                entries: &[wgpu::BindGroupEntry {
4381                    binding: 0,
4382                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
4383                        buffer: &retained_glyph_uniform_buffer,
4384                        offset: 0,
4385                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
4386                    }),
4387                }],
4388            });
4389
4390        let effect_renderer = EffectRenderer::new(&device, surface_format, adapter_backend);
4391
4392        Self {
4393            device,
4394            queue,
4395            renderer_epoch,
4396            #[cfg(not(target_arch = "wasm32"))]
4397            store_feed_generation,
4398            surface_format,
4399            shape_batch_limits,
4400            pipeline,
4401            pipeline_dst_out,
4402            #[cfg(not(target_arch = "wasm32"))]
4403            mesh_pipeline,
4404            #[cfg(not(target_arch = "wasm32"))]
4405            instanced_quads,
4406            #[cfg(target_arch = "wasm32")]
4407            uniform_bind_group_layout,
4408            shape_bind_group_layout,
4409            dummy_paint_buffer,
4410            identity_similarity_buffer,
4411            #[cfg(not(target_arch = "wasm32"))]
4412            replay_slots: replay_slot_store,
4413            image_pipeline,
4414            image_pipeline_dst_out,
4415            glyph_atlas_pipeline,
4416            #[cfg(not(target_arch = "wasm32"))]
4417            retained_glyph_atlas_pipeline,
4418            image_bind_group_layout,
4419            #[cfg(not(target_arch = "wasm32"))]
4420            retained_glyph_uniform_bind_group_layout,
4421            image_nearest_sampler,
4422            image_linear_sampler,
4423            text_fonts,
4424            #[cfg(not(target_arch = "wasm32"))]
4425            upload_buffer,
4426            #[cfg(not(target_arch = "wasm32"))]
4427            uniform_buffer,
4428            #[cfg(not(target_arch = "wasm32"))]
4429            uniform_bind_group,
4430            #[cfg(not(target_arch = "wasm32"))]
4431            shape_buffers,
4432            #[cfg(not(target_arch = "wasm32"))]
4433            image_vertex_buffer,
4434            #[cfg(not(target_arch = "wasm32"))]
4435            image_index_buffer,
4436            #[cfg(not(target_arch = "wasm32"))]
4437            retained_glyph_uniform_buffer,
4438            #[cfg(not(target_arch = "wasm32"))]
4439            retained_glyph_uniform_bind_group,
4440            #[cfg(not(target_arch = "wasm32"))]
4441            retained_glyph_uniform_stride,
4442            #[cfg(not(target_arch = "wasm32"))]
4443            retained_glyph_uniform_capacity,
4444            #[cfg(not(target_arch = "wasm32"))]
4445            retained_glyph_uniform_cursor: 0,
4446            #[cfg(target_arch = "wasm32")]
4447            wasm_uniform_batches: Vec::new(),
4448            #[cfg(target_arch = "wasm32")]
4449            wasm_uniform_batch_cursor: 0,
4450            #[cfg(target_arch = "wasm32")]
4451            wasm_shape_batches: Vec::new(),
4452            #[cfg(target_arch = "wasm32")]
4453            wasm_shape_batch_cursor: 0,
4454            #[cfg(target_arch = "wasm32")]
4455            wasm_image_batches: Vec::new(),
4456            #[cfg(target_arch = "wasm32")]
4457            wasm_image_batch_cursor: 0,
4458            image_texture_cache: BoundedLruCache::with_capacity_at_least_one(
4459                MAX_TEXTURE_CACHE_ITEMS,
4460            ),
4461            image_texture_cache_bytes: 0,
4462            text_image_cache: BoundedLruCache::with_capacity_at_least_one(
4463                MAX_TEXT_IMAGE_CACHE_ITEMS,
4464            ),
4465            text_glyph_atlas,
4466            text_glyph_run_cache: BoundedLruCache::with_capacity_at_least_one(
4467                MAX_TEXT_GLYPH_RUN_CACHE_ITEMS,
4468            ),
4469            #[cfg(not(target_arch = "wasm32"))]
4470            text_glyph_gpu_run_cache: BoundedLruCache::with_capacity_at_least_one(
4471                MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS,
4472            ),
4473            text_glyph_mask_cache: SoftwareGlyphRasterCache::with_capacity_at_least_one(
4474                MAX_TEXT_GLYPH_MASK_CACHE_ITEMS,
4475            ),
4476            text_line_index_cache: TextLineIndexCache::new(MAX_TEXT_LINE_INDEX_CACHE_ITEMS),
4477            scratch_shape_data: Vec::new(),
4478            scratch_gradients: Vec::new(),
4479            scratch_image_vertices: Vec::new(),
4480            scratch_image_indices: Vec::new(),
4481            scratch_image_cmds: Vec::new(),
4482            scratch_glyph_cmds: Vec::new(),
4483            scratch_text_glyph_run: Vec::new(),
4484            scratch_text_glyph_placements: Vec::new(),
4485            scratch_text_glyph_quads: Vec::new(),
4486            scratch_segment_items: Vec::new(),
4487            scratch_effect_ranges: Vec::new(),
4488            scratch_layer_events: Vec::new(),
4489            staged_uploads: StagedBufferUploads::default(),
4490            frame_graph_executor: WgpuFrameGraphExecutor::new(),
4491            deferred_offscreen_releases: Vec::new(),
4492            effect_renderer,
4493            layer_surface_cache: LayerSurfaceCache::new(),
4494            observed_scene_range_cache_misses: BoundedLruCache::with_capacity_at_least_one(
4495                MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES,
4496            ),
4497            shadow_surface_cache: BoundedLruCache::with_capacity_at_least_one(
4498                MAX_SHADOW_SURFACE_CACHE_ITEMS,
4499            ),
4500            shadow_surface_cache_bytes: 0,
4501            frame_stats: gpu_stats::FrameStats::default(),
4502            last_frame_stats: None,
4503            pending_frame_warmup_frames: 0,
4504            frame_count: 0,
4505            gpu_stats_enabled: gpu_stats_enabled(),
4506            warning_state: RendererWarningState::default(),
4507            #[cfg(not(target_arch = "wasm32"))]
4508            replay_upload_stats: ReplayUploadStats::default(),
4509            #[cfg(not(target_arch = "wasm32"))]
4510            replay_color_patches: Vec::new(),
4511            #[cfg(not(target_arch = "wasm32"))]
4512            color_patch_scratch: Vec::new(),
4513            #[cfg(not(target_arch = "wasm32"))]
4514            replay_ack_confirmations: Vec::new(),
4515            #[cfg(not(target_arch = "wasm32"))]
4516            replay_generation_drops: 0,
4517            #[cfg(not(target_arch = "wasm32"))]
4518            retained_bundle_cache: RetainedBundleCache::new(),
4519        }
4520    }
4521
4522    fn ensure_image_cached(&mut self, image: &ImageBitmap) -> Result<(), String> {
4523        if self.image_texture_cache.get(&image.id()).is_some() {
4524            return Ok(());
4525        }
4526
4527        let size = wgpu::Extent3d {
4528            width: image.width(),
4529            height: image.height(),
4530            depth_or_array_layers: 1,
4531        };
4532
4533        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
4534            label: Some("Image Texture"),
4535            size,
4536            mip_level_count: 1,
4537            sample_count: 1,
4538            dimension: wgpu::TextureDimension::D2,
4539            format: wgpu::TextureFormat::Rgba8Unorm,
4540            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4541            view_formats: &[],
4542        });
4543
4544        let upload_stats = self.frame_graph_executor.upload_texture(
4545            &self.queue,
4546            wgpu::TexelCopyTextureInfo {
4547                texture: &texture,
4548                mip_level: 0,
4549                origin: wgpu::Origin3d::ZERO,
4550                aspect: wgpu::TextureAspect::All,
4551            },
4552            image.pixels(),
4553            wgpu::TexelCopyBufferLayout {
4554                offset: 0,
4555                bytes_per_row: Some(4 * image.width()),
4556                rows_per_image: Some(image.height()),
4557            },
4558            size,
4559        );
4560        self.frame_stats.record_command_stats(upload_stats);
4561
4562        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
4563        let nearest_bind_group = self.image_bind_group(&view, &self.image_nearest_sampler);
4564        let linear_bind_group = self.image_bind_group(&view, &self.image_linear_sampler);
4565
4566        let bytes = image.width() as usize * image.height() as usize * 4;
4567        if let Some(replaced) = self.image_texture_cache.put(
4568            image.id(),
4569            CachedImageTexture {
4570                _texture: texture,
4571                _view: view,
4572                nearest_bind_group,
4573                linear_bind_group,
4574                bytes,
4575            },
4576        ) {
4577            self.image_texture_cache_bytes = self
4578                .image_texture_cache_bytes
4579                .saturating_sub(replaced.bytes);
4580        }
4581        self.image_texture_cache_bytes += bytes;
4582        // Byte-bounded eviction on top of the count bound: never evict the
4583        // entry just inserted (this frame draws it).
4584        while self.image_texture_cache_bytes > MAX_IMAGE_TEXTURE_CACHE_BYTES
4585            && self.image_texture_cache.len() > 1
4586        {
4587            let Some((_, evicted)) = self.image_texture_cache.pop_lru() else {
4588                break;
4589            };
4590            self.image_texture_cache_bytes =
4591                self.image_texture_cache_bytes.saturating_sub(evicted.bytes);
4592        }
4593        Ok(())
4594    }
4595
4596    fn image_bind_group(
4597        &self,
4598        view: &wgpu::TextureView,
4599        sampler: &wgpu::Sampler,
4600    ) -> wgpu::BindGroup {
4601        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4602            label: Some("Image Texture Bind Group"),
4603            layout: &self.image_bind_group_layout,
4604            entries: &[
4605                wgpu::BindGroupEntry {
4606                    binding: 0,
4607                    resource: wgpu::BindingResource::TextureView(view),
4608                },
4609                wgpu::BindGroupEntry {
4610                    binding: 1,
4611                    resource: wgpu::BindingResource::Sampler(sampler),
4612                },
4613            ],
4614        })
4615    }
4616
4617    /// Acquire an offscreen target from the pool with stats tracking.
4618    /// Uses split borrows to avoid conflicting borrows on self.
4619    fn max_texture_dim(&self) -> u32 {
4620        self.effect_renderer.max_texture_dim()
4621    }
4622
4623    fn acquire_offscreen(&mut self, width: u32, height: u32) -> OffscreenTarget {
4624        self.effect_renderer
4625            .acquire_offscreen(&self.device, width, height, Some(&self.frame_stats))
4626    }
4627
4628    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
4629        self.acquire_offscreen(width, height)
4630    }
4631
4632    fn transient_offscreen_descriptor(
4633        &self,
4634        label: &'static str,
4635        width: u32,
4636        height: u32,
4637    ) -> FrameTextureDescriptor {
4638        let max_texture_dim = self.max_texture_dim();
4639        FrameTextureDescriptor::render_attachment(
4640            label,
4641            width.min(max_texture_dim),
4642            height.min(max_texture_dim),
4643            self.surface_format,
4644        )
4645    }
4646
4647    fn defer_offscreen_release(&mut self, target: OffscreenTarget) {
4648        self.deferred_offscreen_releases.push(target);
4649    }
4650
4651    fn flush_deferred_offscreen_releases(&mut self) {
4652        for target in self.deferred_offscreen_releases.drain(..) {
4653            self.effect_renderer.release_offscreen(target);
4654        }
4655    }
4656
4657    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
4658        if let LayerSurfaceTexture::Owned(target) = target {
4659            self.defer_offscreen_release(target);
4660        }
4661    }
4662
4663    fn cached_layer_surface(
4664        &mut self,
4665        key: &LayerRasterCacheKey,
4666    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
4667        self.layer_surface_cache.get(key, &self.frame_stats)
4668    }
4669
4670    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
4671        admit_layer_surface_cache_miss_impl(key, &mut self.observed_scene_range_cache_misses)
4672    }
4673
4674    fn insert_cached_layer_surface(
4675        &mut self,
4676        key: LayerRasterCacheKey,
4677        target: OffscreenTarget,
4678        logical_rect: Rect,
4679    ) -> Rc<OffscreenTarget> {
4680        self.layer_surface_cache
4681            .insert(key, target, logical_rect, &self.frame_stats)
4682    }
4683
4684    fn cached_shadow_surface(
4685        &mut self,
4686        key: &ShadowSurfaceCacheKey,
4687    ) -> Option<Rc<OffscreenTarget>> {
4688        self.shadow_surface_cache
4689            .get(key)
4690            .map(|cached| cached.target.clone())
4691    }
4692
4693    fn cached_shape_shadow_composite(
4694        &mut self,
4695        shadow: &ShadowDraw,
4696        width: u32,
4697        height: u32,
4698        root_scale: f32,
4699    ) -> Option<CachedShadowComposite> {
4700        if shadow.blur_radius <= 0.0 || shadow.shapes.is_empty() || !shadow.texts.is_empty() {
4701            return None;
4702        }
4703
4704        let plan = shape_shadow_surface_plan(
4705            &shadow.shapes,
4706            shadow.clip,
4707            shadow.blur_radius,
4708            width,
4709            height,
4710            root_scale,
4711            self.max_texture_dim(),
4712        )?;
4713        let key = shape_shadow_surface_cache_key(
4714            &shadow.shapes,
4715            plan.source_device_bounds,
4716            plan.pixel_radius,
4717            root_scale,
4718        )?;
4719        let cached = self.cached_shadow_surface(&key)?;
4720        let viewport_offset = [plan.source_device_bounds.x, plan.source_device_bounds.y];
4721        self.frame_stats.record_shadow_shape_cache_hit(
4722            plan.source_device_bounds.width,
4723            plan.source_device_bounds.height,
4724        );
4725
4726        let clip_scissor = shadow
4727            .clip
4728            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
4729        let scissor = clip_scissor.or(plan.processing_scissor);
4730        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
4731            mask.rect[0] -= viewport_offset[0];
4732            mask.rect[1] -= viewport_offset[1];
4733            mask
4734        });
4735        let dest_viewport = Some((
4736            viewport_offset[0],
4737            viewport_offset[1],
4738            plan.source_device_bounds.width as f32,
4739            plan.source_device_bounds.height as f32,
4740        ));
4741
4742        Some(CachedShadowComposite {
4743            source: cached,
4744            scissor,
4745            rounded_mask,
4746            dest_viewport,
4747        })
4748    }
4749
4750    fn insert_cached_shadow_surface(
4751        &mut self,
4752        key: ShadowSurfaceCacheKey,
4753        target: OffscreenTarget,
4754    ) {
4755        let byte_size = offscreen_byte_size(target.width, target.height);
4756        while self.shadow_surface_cache_bytes + byte_size > MAX_SHADOW_SURFACE_CACHE_BYTES {
4757            let Some((_evicted_key, evicted_entry)) = self.shadow_surface_cache.pop_lru() else {
4758                break;
4759            };
4760            self.shadow_surface_cache_bytes = self
4761                .shadow_surface_cache_bytes
4762                .saturating_sub(evicted_entry.byte_size);
4763        }
4764
4765        let cached = CachedShadowSurface {
4766            target: Rc::new(target),
4767            byte_size,
4768        };
4769        if let Some((_replaced_key, replaced_entry)) = self.shadow_surface_cache.push(key, cached) {
4770            self.shadow_surface_cache_bytes = self
4771                .shadow_surface_cache_bytes
4772                .saturating_sub(replaced_entry.byte_size);
4773        }
4774        self.shadow_surface_cache_bytes = self.shadow_surface_cache_bytes.saturating_add(byte_size);
4775    }
4776
4777    fn supports_render_effect(&self, effect: &RenderEffect) -> bool {
4778        is_render_effect_supported(effect)
4779    }
4780}
4781
4782struct RecordingSurfaceBackend<'renderer, 'recorder, C: FrameCommandRecorder> {
4783    renderer: &'renderer mut GpuRenderer,
4784    recorder: &'recorder mut C,
4785}
4786
4787impl<C: FrameCommandRecorder> RecordingSurfaceBackend<'_, '_, C> {
4788    #[allow(clippy::too_many_arguments)]
4789    fn render_range_with_layer_events_to_target_recorded(
4790        &mut self,
4791        target: &OffscreenTarget,
4792        shapes: &[DrawShape],
4793        images: &[ImageDraw],
4794        texts: &[TextDraw],
4795        shadow_draws: &[ShadowDraw],
4796        draw_ops: &[DrawOp],
4797        effect_layers: &[EffectLayer],
4798        backdrop_layers: &[BackdropLayer],
4799        z_start: usize,
4800        z_end: usize,
4801        excluded_effect_layer: Option<usize>,
4802        width: u32,
4803        height: u32,
4804        root_scale: f32,
4805        backdrop_underlay: Option<&OffscreenTarget>,
4806        initial_load_op: wgpu::LoadOp<wgpu::Color>,
4807    ) -> Result<(), String> {
4808        if z_start >= z_end {
4809            if matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
4810                self.clear_target_view_with_load_op(&target.view, initial_load_op);
4811            }
4812            return Ok(());
4813        }
4814
4815        let mut effect_z_ranges = std::mem::take(&mut self.renderer.scratch_effect_ranges);
4816        collect_effect_ranges(
4817            effect_layers,
4818            z_start,
4819            z_end,
4820            excluded_effect_layer,
4821            &mut effect_z_ranges,
4822        );
4823        let mut events = std::mem::take(&mut self.renderer.scratch_layer_events);
4824        collect_layer_events(
4825            effect_layers,
4826            backdrop_layers,
4827            z_start,
4828            z_end,
4829            excluded_effect_layer,
4830            &mut events,
4831        );
4832
4833        let result = (|| -> Result<(), String> {
4834            let mut next_load_op = initial_load_op;
4835            let mut cursor_z = z_start;
4836            for event in &events {
4837                if event.z_index > cursor_z {
4838                    self.render_non_effect_segment(
4839                        &target.view,
4840                        shapes,
4841                        images,
4842                        texts,
4843                        shadow_draws,
4844                        // Windowed scenes never carry retained draws — see
4845                        // `build_scene_window`.
4846                        &[],
4847                        draw_ops,
4848                        cursor_z,
4849                        event.z_index,
4850                        &effect_z_ranges,
4851                        width,
4852                        height,
4853                        root_scale,
4854                        next_load_op,
4855                    )?;
4856                    next_load_op = wgpu::LoadOp::Load;
4857                    cursor_z = event.z_index;
4858                } else if event.z_index < cursor_z {
4859                    continue;
4860                }
4861
4862                if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
4863                    self.clear_target_view_with_load_op(&target.view, next_load_op);
4864                    next_load_op = wgpu::LoadOp::Load;
4865                }
4866
4867                match event.kind {
4868                    LayerEventKind::Backdrop(index) => {
4869                        let layer = &backdrop_layers[index];
4870                        let effective_backdrop_underlay = if backdrop_underlay.is_some()
4871                            && backdrop_underlay_is_covered_by_local_content(
4872                                shapes,
4873                                images,
4874                                shadow_draws,
4875                                draw_ops,
4876                                effect_layers,
4877                                backdrop_layers,
4878                                layer,
4879                            ) {
4880                            None
4881                        } else {
4882                            backdrop_underlay
4883                        };
4884                        execute_apply_backdrop_layer_to_target(
4885                            self,
4886                            target,
4887                            layer,
4888                            effective_backdrop_underlay,
4889                            width,
4890                            height,
4891                            root_scale,
4892                            None,
4893                        )?;
4894                    }
4895                    LayerEventKind::Effect(index) => {
4896                        let layer = &effect_layers[index];
4897                        if layer.z_start < cursor_z {
4898                            continue;
4899                        }
4900                        execute_render_effect_layer_to_target(
4901                            self,
4902                            target,
4903                            shapes,
4904                            images,
4905                            texts,
4906                            shadow_draws,
4907                            draw_ops,
4908                            effect_layers,
4909                            backdrop_layers,
4910                            index,
4911                            backdrop_underlay,
4912                            width,
4913                            height,
4914                            root_scale,
4915                        )?;
4916                        cursor_z = cursor_z.max(layer.z_end);
4917                    }
4918                }
4919            }
4920
4921            if cursor_z < z_end {
4922                self.render_non_effect_segment(
4923                    &target.view,
4924                    shapes,
4925                    images,
4926                    texts,
4927                    shadow_draws,
4928                    &[],
4929                    draw_ops,
4930                    cursor_z,
4931                    z_end,
4932                    &effect_z_ranges,
4933                    width,
4934                    height,
4935                    root_scale,
4936                    next_load_op,
4937                )?;
4938            } else if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
4939                self.clear_target_view_with_load_op(&target.view, next_load_op);
4940            }
4941
4942            Ok(())
4943        })();
4944
4945        self.renderer.scratch_effect_ranges = effect_z_ranges;
4946        self.renderer.scratch_layer_events = events;
4947        result
4948    }
4949
4950    #[allow(clippy::too_many_arguments)]
4951    fn record_shader_composite(
4952        &mut self,
4953        source: &OffscreenTarget,
4954        shader: &RuntimeShader,
4955        effect_rect: [f32; 4],
4956        dest_view: &wgpu::TextureView,
4957        alpha: f32,
4958        load_op: wgpu::LoadOp<wgpu::Color>,
4959        scissor: Option<(u32, u32, u32, u32)>,
4960        blend_mode: BlendMode,
4961        dest_viewport: Option<(f32, f32, f32, f32)>,
4962        sample_mode: CompositeSampleMode,
4963    ) {
4964        let device = self.renderer.device.clone();
4965        if let Some(viewport) = direct_shader_composite_viewport(
4966            alpha,
4967            blend_mode,
4968            dest_viewport,
4969            sample_mode,
4970            (source.width, source.height),
4971        ) {
4972            let shader_applied = self
4973                .renderer
4974                .effect_renderer
4975                .encode_shader_src_over_to_view(
4976                    self.recorder,
4977                    &device,
4978                    source,
4979                    dest_view,
4980                    shader,
4981                    effect_rect,
4982                    load_op,
4983                    scissor,
4984                    viewport,
4985                );
4986            if shader_applied {
4987                self.renderer
4988                    .effect_renderer
4989                    .debug_effects
4990                    .set(self.renderer.effect_renderer.debug_effects.get() + 1);
4991                self.recorder.record_pass();
4992                self.renderer.effect_renderer.record_composite_pass();
4993                return;
4994            }
4995        }
4996        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
4997            "Shader Effect Composite Scratch",
4998            source.width,
4999            source.height,
5000        );
5001        let scratch = self
5002            .recorder
5003            .acquire_transient_offscreen(&device, scratch_descriptor);
5004        let shader_applied = {
5005            self.renderer.effect_renderer.encode_shader(
5006                self.recorder,
5007                &device,
5008                source,
5009                &scratch.view,
5010                shader,
5011                effect_rect,
5012            )
5013        };
5014        let composite_source = if shader_applied {
5015            self.renderer
5016                .effect_renderer
5017                .debug_effects
5018                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5019            self.recorder.record_pass();
5020            &scratch
5021        } else {
5022            source
5023        };
5024        {
5025            self.renderer
5026                .effect_renderer
5027                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
5028                    self.recorder,
5029                    &device,
5030                    composite_source,
5031                    dest_view,
5032                    alpha,
5033                    load_op,
5034                    scissor,
5035                    None,
5036                    supported_blend_mode(blend_mode),
5037                    dest_viewport,
5038                    sample_mode,
5039                );
5040        }
5041        self.recorder.record_pass();
5042        self.renderer.effect_renderer.record_composite_pass();
5043        self.recorder
5044            .release_transient_offscreen(scratch_descriptor, scratch);
5045    }
5046
5047    #[allow(clippy::too_many_arguments)]
5048    fn record_shader_projective_composite(
5049        &mut self,
5050        source: &OffscreenTarget,
5051        shader: &RuntimeShader,
5052        effect_rect: [f32; 4],
5053        dest_view: &wgpu::TextureView,
5054        viewport: (u32, u32),
5055        source_size: (f32, f32),
5056        inverse_matrix: [[f32; 3]; 3],
5057        dest_bounds: [[f32; 2]; 4],
5058        alpha: f32,
5059        load_op: wgpu::LoadOp<wgpu::Color>,
5060        scissor: Option<(u32, u32, u32, u32)>,
5061        blend_mode: BlendMode,
5062        sample_mode: CompositeSampleMode,
5063    ) {
5064        if projective_dest_bounds_rect(dest_bounds).is_none() {
5065            return;
5066        }
5067        let device = self.renderer.device.clone();
5068        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5069            "Shader Projective Composite Scratch",
5070            source.width,
5071            source.height,
5072        );
5073        let scratch = self
5074            .recorder
5075            .acquire_transient_offscreen(&device, scratch_descriptor);
5076        let shader_applied = {
5077            self.renderer.effect_renderer.encode_shader(
5078                self.recorder,
5079                &device,
5080                source,
5081                &scratch.view,
5082                shader,
5083                effect_rect,
5084            )
5085        };
5086        let composite_source = if shader_applied {
5087            self.renderer
5088                .effect_renderer
5089                .debug_effects
5090                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5091            self.recorder.record_pass();
5092            &scratch
5093        } else {
5094            source
5095        };
5096        let composited = {
5097            self.renderer
5098                .effect_renderer
5099                .encode_composite_to_view_projective(
5100                    self.recorder,
5101                    &device,
5102                    composite_source,
5103                    dest_view,
5104                    viewport,
5105                    source_size,
5106                    inverse_matrix,
5107                    dest_bounds,
5108                    alpha,
5109                    load_op,
5110                    scissor,
5111                    supported_blend_mode(blend_mode),
5112                    sample_mode,
5113                )
5114        };
5115        if composited {
5116            self.recorder.record_pass();
5117            self.renderer.effect_renderer.record_composite_pass();
5118        }
5119        self.recorder
5120            .release_transient_offscreen(scratch_descriptor, scratch);
5121    }
5122
5123    #[allow(clippy::too_many_arguments)]
5124    fn record_effect_with_direct_shader_tail_composite(
5125        &mut self,
5126        source: &OffscreenTarget,
5127        first_effect: &RenderEffect,
5128        shader: &RuntimeShader,
5129        effect_rect: [f32; 4],
5130        dest_view: &wgpu::TextureView,
5131        load_op: wgpu::LoadOp<wgpu::Color>,
5132        scissor: Option<(u32, u32, u32, u32)>,
5133        dest_viewport: (f32, f32, f32, f32),
5134    ) -> Result<bool, String> {
5135        let device = self.renderer.device.clone();
5136        let intermediate_descriptor = self.renderer.transient_offscreen_descriptor(
5137            "Render Effect Direct Shader Tail Intermediate",
5138            source.width,
5139            source.height,
5140        );
5141        let intermediate = self
5142            .recorder
5143            .acquire_transient_offscreen(&device, intermediate_descriptor);
5144        let effect_scratch_targets = self
5145            .renderer
5146            .effect_renderer
5147            .acquire_recorded_effect_scratch_targets(
5148                self.recorder,
5149                &device,
5150                first_effect,
5151                source.width,
5152                source.height,
5153                self.renderer.surface_format,
5154            );
5155        let first_passes = {
5156            let mut effect_scratch_refs = effect_scratch_targets.refs();
5157            let pass_count = self.renderer.effect_renderer.encode_effect(
5158                self.recorder,
5159                &device,
5160                source,
5161                &intermediate.view,
5162                first_effect,
5163                effect_rect,
5164                &mut effect_scratch_refs,
5165            );
5166            match pass_count {
5167                Ok(pass_count) => effect_scratch_refs.assert_consumed().map(|()| pass_count),
5168                Err(error) => Err(error),
5169            }
5170        };
5171        let first_passes = match first_passes {
5172            Ok(pass_count) => pass_count,
5173            Err(error) => {
5174                effect_scratch_targets.release_into(self.recorder);
5175                self.recorder
5176                    .release_transient_offscreen(intermediate_descriptor, intermediate);
5177                return Err(error);
5178            }
5179        };
5180        let shader_applied = self
5181            .renderer
5182            .effect_renderer
5183            .encode_shader_src_over_to_view(
5184                self.recorder,
5185                &device,
5186                &intermediate,
5187                dest_view,
5188                shader,
5189                effect_rect,
5190                load_op,
5191                scissor,
5192                dest_viewport,
5193            );
5194        self.recorder
5195            .record_passes(first_passes.saturating_add(u32::from(shader_applied)));
5196        effect_scratch_targets.release_into(self.recorder);
5197        self.recorder
5198            .release_transient_offscreen(intermediate_descriptor, intermediate);
5199        if !shader_applied {
5200            return Ok(false);
5201        }
5202        self.renderer
5203            .effect_renderer
5204            .debug_effects
5205            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5206        self.renderer.effect_renderer.record_composite_pass();
5207        Ok(true)
5208    }
5209
5210    #[allow(clippy::too_many_arguments)]
5211    fn record_effect_composite(
5212        &mut self,
5213        source: &OffscreenTarget,
5214        effect: &RenderEffect,
5215        effect_rect: [f32; 4],
5216        dest_view: &wgpu::TextureView,
5217        alpha: f32,
5218        load_op: wgpu::LoadOp<wgpu::Color>,
5219        scissor: Option<(u32, u32, u32, u32)>,
5220        blend_mode: BlendMode,
5221        dest_viewport: Option<(f32, f32, f32, f32)>,
5222        sample_mode: CompositeSampleMode,
5223    ) -> Result<(), String> {
5224        if let (
5225            RenderEffect::Chain { first, second },
5226            Some(viewport),
5227            BlendMode::SrcOver,
5228            CompositeSampleMode::Linear,
5229        ) = (
5230            effect,
5231            dest_viewport,
5232            supported_blend_mode(blend_mode),
5233            sample_mode,
5234        ) {
5235            if let (
5236                RenderEffect::Blur {
5237                    radius_x,
5238                    radius_y,
5239                    edge_treatment,
5240                },
5241                RenderEffect::Shader { shader },
5242            ) = (first.as_ref(), second.as_ref())
5243            {
5244                if *radius_x > 0.0 || *radius_y > 0.0 {
5245                    let device = self.renderer.device.clone();
5246                    let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5247                        "Blur Rounded Mask Scratch",
5248                        source.width,
5249                        source.height,
5250                    );
5251                    let scratch = self
5252                        .recorder
5253                        .acquire_transient_offscreen(&device, scratch_descriptor);
5254                    let fused = self
5255                        .renderer
5256                        .effect_renderer
5257                        .encode_blur_then_rounded_mask_src_over_to_view(
5258                            self.recorder,
5259                            &device,
5260                            source,
5261                            &scratch,
5262                            dest_view,
5263                            *radius_x,
5264                            *radius_y,
5265                            *edge_treatment,
5266                            shader,
5267                            effect_rect,
5268                            load_op,
5269                            scissor,
5270                            viewport,
5271                        );
5272                    if fused {
5273                        self.recorder.record_passes(2);
5274                        self.renderer.effect_renderer.record_blur_pass();
5275                        self.renderer
5276                            .effect_renderer
5277                            .debug_effects
5278                            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5279                        self.renderer.effect_renderer.record_composite_pass();
5280                        self.recorder
5281                            .release_transient_offscreen(scratch_descriptor, scratch);
5282                        return Ok(());
5283                    }
5284                    self.recorder
5285                        .release_transient_offscreen(scratch_descriptor, scratch);
5286                }
5287            }
5288        }
5289        if let Some((first_effect, shader, viewport)) = direct_shader_tail_composite(
5290            effect,
5291            alpha,
5292            blend_mode,
5293            dest_viewport,
5294            sample_mode,
5295            (source.width, source.height),
5296        ) {
5297            if self.record_effect_with_direct_shader_tail_composite(
5298                source,
5299                first_effect,
5300                shader,
5301                effect_rect,
5302                dest_view,
5303                load_op,
5304                scissor,
5305                viewport,
5306            )? {
5307                return Ok(());
5308            }
5309        }
5310        let device = self.renderer.device.clone();
5311        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5312            "Render Effect Composite Scratch",
5313            source.width,
5314            source.height,
5315        );
5316        let scratch = self
5317            .recorder
5318            .acquire_transient_offscreen(&device, scratch_descriptor);
5319        let effect_scratch_targets = self
5320            .renderer
5321            .effect_renderer
5322            .acquire_recorded_effect_scratch_targets(
5323                self.recorder,
5324                &device,
5325                effect,
5326                source.width,
5327                source.height,
5328                self.renderer.surface_format,
5329            );
5330        let effect_passes = {
5331            let mut effect_scratch_refs = effect_scratch_targets.refs();
5332            let pass_count = self.renderer.effect_renderer.encode_effect(
5333                self.recorder,
5334                &device,
5335                source,
5336                &scratch.view,
5337                effect,
5338                effect_rect,
5339                &mut effect_scratch_refs,
5340            )?;
5341            effect_scratch_refs.assert_consumed()?;
5342            Ok(pass_count)
5343        };
5344        let effect_passes = match effect_passes {
5345            Ok(pass_count) => pass_count,
5346            Err(error) => {
5347                effect_scratch_targets.release_into(self.recorder);
5348                self.recorder
5349                    .release_transient_offscreen(scratch_descriptor, scratch);
5350                return Err(error);
5351            }
5352        };
5353        {
5354            self.renderer
5355                .effect_renderer
5356                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
5357                    self.recorder,
5358                    &device,
5359                    &scratch,
5360                    dest_view,
5361                    alpha,
5362                    load_op,
5363                    scissor,
5364                    None,
5365                    supported_blend_mode(blend_mode),
5366                    dest_viewport,
5367                    sample_mode,
5368                );
5369        }
5370        self.recorder.record_passes(effect_passes.saturating_add(1));
5371        self.renderer.effect_renderer.record_composite_pass();
5372        effect_scratch_targets.release_into(self.recorder);
5373        self.recorder
5374            .release_transient_offscreen(scratch_descriptor, scratch);
5375        Ok(())
5376    }
5377
5378    #[allow(clippy::too_many_arguments)]
5379    fn record_effect_projective_composite(
5380        &mut self,
5381        source: &OffscreenTarget,
5382        effect: &RenderEffect,
5383        effect_rect: [f32; 4],
5384        dest_view: &wgpu::TextureView,
5385        viewport: (u32, u32),
5386        source_size: (f32, f32),
5387        inverse_matrix: [[f32; 3]; 3],
5388        dest_bounds: [[f32; 2]; 4],
5389        alpha: f32,
5390        load_op: wgpu::LoadOp<wgpu::Color>,
5391        scissor: Option<(u32, u32, u32, u32)>,
5392        blend_mode: BlendMode,
5393        sample_mode: CompositeSampleMode,
5394    ) -> Result<(), String> {
5395        if projective_dest_bounds_rect(dest_bounds).is_none() {
5396            return Ok(());
5397        }
5398        let device = self.renderer.device.clone();
5399        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5400            "Render Effect Projective Composite Scratch",
5401            source.width,
5402            source.height,
5403        );
5404        let scratch = self
5405            .recorder
5406            .acquire_transient_offscreen(&device, scratch_descriptor);
5407        let effect_scratch_targets = self
5408            .renderer
5409            .effect_renderer
5410            .acquire_recorded_effect_scratch_targets(
5411                self.recorder,
5412                &device,
5413                effect,
5414                source.width,
5415                source.height,
5416                self.renderer.surface_format,
5417            );
5418        let effect_passes = {
5419            let mut effect_scratch_refs = effect_scratch_targets.refs();
5420            let pass_count = self.renderer.effect_renderer.encode_effect(
5421                self.recorder,
5422                &device,
5423                source,
5424                &scratch.view,
5425                effect,
5426                effect_rect,
5427                &mut effect_scratch_refs,
5428            )?;
5429            effect_scratch_refs.assert_consumed()?;
5430            Ok(pass_count)
5431        };
5432        let effect_passes = match effect_passes {
5433            Ok(pass_count) => pass_count,
5434            Err(error) => {
5435                effect_scratch_targets.release_into(self.recorder);
5436                self.recorder
5437                    .release_transient_offscreen(scratch_descriptor, scratch);
5438                return Err(error);
5439            }
5440        };
5441        let composited = {
5442            self.renderer
5443                .effect_renderer
5444                .encode_composite_to_view_projective(
5445                    self.recorder,
5446                    &device,
5447                    &scratch,
5448                    dest_view,
5449                    viewport,
5450                    source_size,
5451                    inverse_matrix,
5452                    dest_bounds,
5453                    alpha,
5454                    load_op,
5455                    scissor,
5456                    supported_blend_mode(blend_mode),
5457                    sample_mode,
5458                )
5459        };
5460        if composited {
5461            self.recorder.record_passes(effect_passes.saturating_add(1));
5462            self.renderer.effect_renderer.record_composite_pass();
5463        } else {
5464            self.recorder.record_passes(effect_passes);
5465        }
5466        effect_scratch_targets.release_into(self.recorder);
5467        self.recorder
5468            .release_transient_offscreen(scratch_descriptor, scratch);
5469        Ok(())
5470    }
5471}
5472
5473impl<C: FrameCommandRecorder> SurfaceExecutionBackend for RecordingSurfaceBackend<'_, '_, C> {
5474    fn max_texture_dim(&self) -> u32 {
5475        self.renderer.max_texture_dim()
5476    }
5477
5478    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
5479        self.renderer.acquire_retained_surface(width, height)
5480    }
5481
5482    fn acquire_frame_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
5483        let descriptor =
5484            self.renderer
5485                .transient_offscreen_descriptor("Frame Surface", width, height);
5486        self.recorder
5487            .acquire_transient_offscreen(&self.renderer.device, descriptor)
5488    }
5489
5490    fn release_frame_surface(&mut self, target: OffscreenTarget) {
5491        let descriptor = self.renderer.transient_offscreen_descriptor(
5492            "Frame Surface",
5493            target.width,
5494            target.height,
5495        );
5496        self.recorder
5497            .release_transient_offscreen(descriptor, target);
5498    }
5499
5500    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
5501        self.renderer.release_layer_surface_target(target);
5502    }
5503
5504    fn cached_layer_surface(
5505        &mut self,
5506        key: &LayerRasterCacheKey,
5507    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
5508        self.renderer.cached_layer_surface(key)
5509    }
5510
5511    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
5512        self.renderer.admit_layer_surface_cache_miss(key)
5513    }
5514
5515    fn insert_cached_layer_surface(
5516        &mut self,
5517        key: LayerRasterCacheKey,
5518        target: OffscreenTarget,
5519        logical_rect: Rect,
5520    ) -> Rc<OffscreenTarget> {
5521        self.renderer
5522            .insert_cached_layer_surface(key, target, logical_rect)
5523    }
5524
5525    fn clear_target_view_with_load_op(
5526        &mut self,
5527        target_view: &wgpu::TextureView,
5528        load_op: wgpu::LoadOp<wgpu::Color>,
5529    ) {
5530        {
5531            let _clear = self
5532                .recorder
5533                .encoder()
5534                .begin_render_pass(&wgpu::RenderPassDescriptor {
5535                    label: Some("Layer Event Clear Pass"),
5536                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
5537                        view: target_view,
5538                        resolve_target: None,
5539                        depth_slice: None,
5540                        ops: wgpu::Operations {
5541                            load: load_op,
5542                            store: wgpu::StoreOp::Store,
5543                        },
5544                    })],
5545                    depth_stencil_attachment: None,
5546                    timestamp_writes: None,
5547                    occlusion_query_set: None,
5548                    multiview_mask: None,
5549                });
5550        }
5551        self.recorder.record_pass();
5552    }
5553
5554    #[allow(clippy::too_many_arguments)]
5555    fn render_non_effect_segment(
5556        &mut self,
5557        target_view: &wgpu::TextureView,
5558        shapes: &[DrawShape],
5559        images: &[ImageDraw],
5560        texts: &[TextDraw],
5561        shadow_draws: &[ShadowDraw],
5562        retained_draws: &[RetainedDraw],
5563        draw_ops: &[DrawOp],
5564        z_start: usize,
5565        z_end: usize,
5566        effect_z_ranges: &[Range<usize>],
5567        width: u32,
5568        height: u32,
5569        root_scale: f32,
5570        initial_load_op: wgpu::LoadOp<wgpu::Color>,
5571    ) -> Result<(), String> {
5572        self.render_non_effect_segment_with_composites(
5573            target_view,
5574            shapes,
5575            images,
5576            texts,
5577            shadow_draws,
5578            retained_draws,
5579            draw_ops,
5580            z_start,
5581            z_end,
5582            effect_z_ranges,
5583            &[],
5584            &[],
5585            width,
5586            height,
5587            root_scale,
5588            initial_load_op,
5589        )
5590    }
5591
5592    #[allow(clippy::too_many_arguments)]
5593    fn render_non_effect_segment_with_composites(
5594        &mut self,
5595        target_view: &wgpu::TextureView,
5596        shapes: &[DrawShape],
5597        images: &[ImageDraw],
5598        texts: &[TextDraw],
5599        shadow_draws: &[ShadowDraw],
5600        retained_draws: &[RetainedDraw],
5601        draw_ops: &[DrawOp],
5602        z_start: usize,
5603        z_end: usize,
5604        effect_z_ranges: &[Range<usize>],
5605        composites: &[(usize, CompositeBatchItem<'_>)],
5606        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
5607        width: u32,
5608        height: u32,
5609        root_scale: f32,
5610        initial_load_op: wgpu::LoadOp<wgpu::Color>,
5611    ) -> Result<(), String> {
5612        let mut ordered_items = std::mem::take(&mut self.renderer.scratch_segment_items);
5613        collect_non_effect_segment_items(
5614            shapes,
5615            images,
5616            texts,
5617            shadow_draws,
5618            draw_ops,
5619            z_start,
5620            z_end,
5621            effect_z_ranges,
5622            width,
5623            height,
5624            root_scale,
5625            &mut ordered_items,
5626        );
5627        #[cfg(not(target_arch = "wasm32"))]
5628        let raw_shadow_items = ordered_items
5629            .iter()
5630            .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
5631            .count();
5632        let culled_shadow_items = retain_renderable_shadow_items(
5633            &mut ordered_items,
5634            shadow_draws,
5635            width,
5636            height,
5637            root_scale,
5638            self.renderer.max_texture_dim(),
5639        );
5640        #[cfg(target_arch = "wasm32")]
5641        let _ = culled_shadow_items;
5642        let mut cached_shadow_composites: Vec<(usize, CachedShadowComposite)> = Vec::new();
5643        ordered_items.extend(
5644            composites
5645                .iter()
5646                .enumerate()
5647                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::Composite(index))),
5648        );
5649        ordered_items.extend(
5650            shader_composites
5651                .iter()
5652                .enumerate()
5653                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::ShaderComposite(index))),
5654        );
5655        for (z_index, item) in &mut ordered_items {
5656            let SegmentDrawItem::Shadow(shadow_index) = *item else {
5657                continue;
5658            };
5659            let Some(composite) = self.renderer.cached_shape_shadow_composite(
5660                &shadow_draws[shadow_index],
5661                width,
5662                height,
5663                root_scale,
5664            ) else {
5665                continue;
5666            };
5667            let composite_index = composites.len() + cached_shadow_composites.len();
5668            cached_shadow_composites.push((*z_index, composite));
5669            *item = SegmentDrawItem::Composite(composite_index);
5670        }
5671        let mut merged_composites = Vec::with_capacity(
5672            composites
5673                .len()
5674                .saturating_add(cached_shadow_composites.len()),
5675        );
5676        merged_composites.extend(composites.iter().copied());
5677        merged_composites.extend(
5678            cached_shadow_composites
5679                .iter()
5680                .map(|(z_index, composite)| (*z_index, composite.batch_item())),
5681        );
5682        // Z indices are unique — the scene hands every op its own `next_z` — so an
5683        // unstable sort cannot reorder anything a stable one wouldn't, and it skips
5684        // the stable sort's scratch allocation, paid here once per segment per frame.
5685        ordered_items.sort_unstable_by_key(|(z_index, _)| *z_index);
5686        #[cfg(not(target_arch = "wasm32"))]
5687        maybe_print_segment_diag(
5688            z_start..z_end,
5689            &ordered_items,
5690            shapes,
5691            images,
5692            SegmentDiagCounts {
5693                raw_shadow_items,
5694                culled_shadow_items,
5695                cached_shadow_composites: cached_shadow_composites.len(),
5696                composite_items: merged_composites.len(),
5697                shader_composite_items: shader_composites.len(),
5698            },
5699            self.renderer.shape_batch_limits,
5700        );
5701        let result = if ordered_items.is_empty() {
5702            Ok(SegmentCommandEncodeOutcome { first_batch: true })
5703        } else {
5704            self.renderer.encode_non_effect_segment_commands(
5705                self.recorder,
5706                target_view,
5707                &ordered_items,
5708                &merged_composites,
5709                shader_composites,
5710                shapes,
5711                images,
5712                texts,
5713                shadow_draws,
5714                retained_draws,
5715                initial_load_op,
5716                width,
5717                height,
5718                root_scale,
5719            )
5720        };
5721        self.renderer.scratch_segment_items = ordered_items;
5722        let outcome = result?;
5723        if outcome.first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
5724            self.clear_target_view_with_load_op(target_view, initial_load_op);
5725        }
5726        Ok(())
5727    }
5728
5729    fn render_range_with_layer_events_to_target(
5730        &mut self,
5731        target: &OffscreenTarget,
5732        shapes: &[DrawShape],
5733        images: &[ImageDraw],
5734        texts: &[TextDraw],
5735        shadow_draws: &[ShadowDraw],
5736        draw_ops: &[DrawOp],
5737        effect_layers: &[EffectLayer],
5738        backdrop_layers: &[BackdropLayer],
5739        z_start: usize,
5740        z_end: usize,
5741        excluded_effect_layer: Option<usize>,
5742        width: u32,
5743        height: u32,
5744        root_scale: f32,
5745        backdrop_underlay: Option<&OffscreenTarget>,
5746        initial_load_op: wgpu::LoadOp<wgpu::Color>,
5747    ) -> Result<(), String> {
5748        self.render_range_with_layer_events_to_target_recorded(
5749            target,
5750            shapes,
5751            images,
5752            texts,
5753            shadow_draws,
5754            draw_ops,
5755            effect_layers,
5756            backdrop_layers,
5757            z_start,
5758            z_end,
5759            excluded_effect_layer,
5760            width,
5761            height,
5762            root_scale,
5763            backdrop_underlay,
5764            initial_load_op,
5765        )
5766    }
5767
5768    fn render_shadow_draw(
5769        &mut self,
5770        target_view: &wgpu::TextureView,
5771        shadow: &ShadowDraw,
5772        width: u32,
5773        height: u32,
5774        root_scale: f32,
5775    ) {
5776        self.renderer.encode_shadow_draw(
5777            self.recorder,
5778            target_view,
5779            shadow,
5780            width,
5781            height,
5782            root_scale,
5783        );
5784    }
5785
5786    fn composite_to_view_projective(
5787        &mut self,
5788        source: &OffscreenTarget,
5789        dest_view: &wgpu::TextureView,
5790        viewport: (u32, u32),
5791        source_size: (f32, f32),
5792        inverse_matrix: [[f32; 3]; 3],
5793        dest_bounds: [[f32; 2]; 4],
5794        alpha: f32,
5795        load_op: wgpu::LoadOp<wgpu::Color>,
5796        scissor: Option<(u32, u32, u32, u32)>,
5797        blend_mode: BlendMode,
5798        sample_mode: CompositeSampleMode,
5799    ) {
5800        let device = self.renderer.device.clone();
5801        let composited = {
5802            self.renderer
5803                .effect_renderer
5804                .encode_composite_to_view_projective(
5805                    self.recorder,
5806                    &device,
5807                    source,
5808                    dest_view,
5809                    viewport,
5810                    source_size,
5811                    inverse_matrix,
5812                    dest_bounds,
5813                    alpha,
5814                    load_op,
5815                    scissor,
5816                    supported_blend_mode(blend_mode),
5817                    sample_mode,
5818                )
5819        };
5820        if composited {
5821            self.recorder.record_pass();
5822            self.renderer.effect_renderer.record_composite_pass();
5823        }
5824    }
5825
5826    fn composite_projective_surfaces_to_view(
5827        &mut self,
5828        dest_view: &wgpu::TextureView,
5829        viewport: (u32, u32),
5830        composites: &[ProjectiveSurfaceComposite<'_>],
5831    ) {
5832        let device = self.renderer.device.clone();
5833        let mut composite_count = 0_u32;
5834        for composite in composites
5835            .iter()
5836            .copied()
5837            .filter(|composite| projective_dest_bounds_rect(composite.dest_bounds).is_some())
5838        {
5839            let composited = {
5840                self.renderer
5841                    .effect_renderer
5842                    .encode_composite_to_view_projective(
5843                        self.recorder,
5844                        &device,
5845                        composite.source,
5846                        dest_view,
5847                        viewport,
5848                        composite.source_size,
5849                        composite.inverse_matrix,
5850                        composite.dest_bounds,
5851                        composite.alpha,
5852                        composite.load_op,
5853                        composite.scissor,
5854                        supported_blend_mode(composite.blend_mode),
5855                        composite.sample_mode,
5856                    )
5857            };
5858            if composited {
5859                composite_count = composite_count.saturating_add(1);
5860            }
5861        }
5862        if composite_count > 0 {
5863            self.recorder.record_passes(composite_count);
5864            self.renderer
5865                .effect_renderer
5866                .debug_composites
5867                .set(self.renderer.effect_renderer.debug_composites.get() + composite_count);
5868        }
5869    }
5870
5871    fn composite_surface_batch_to_view(
5872        &mut self,
5873        dest_view: &wgpu::TextureView,
5874        viewport: (u32, u32),
5875        load_op: wgpu::LoadOp<wgpu::Color>,
5876        composites: &[CompositeBatchItem<'_>],
5877    ) {
5878        if composites.is_empty() {
5879            return;
5880        }
5881        let device = self.renderer.device.clone();
5882        self.renderer
5883            .effect_renderer
5884            .encode_composite_batch_to_view_pass(
5885                self.recorder,
5886                &device,
5887                dest_view,
5888                viewport,
5889                load_op,
5890                composites,
5891            );
5892        self.recorder.record_pass();
5893        self.renderer.effect_renderer.record_composite_pass();
5894    }
5895
5896    fn copy_texture_region_to_target(
5897        &mut self,
5898        source: &OffscreenTarget,
5899        source_origin: (u32, u32),
5900        target: &OffscreenTarget,
5901        size: (u32, u32),
5902    ) -> bool {
5903        let (width, height) = size;
5904        if width == 0 || height == 0 || width > target.width || height > target.height {
5905            return false;
5906        }
5907        let Some(source_right) = source_origin.0.checked_add(width) else {
5908            return false;
5909        };
5910        let Some(source_bottom) = source_origin.1.checked_add(height) else {
5911            return false;
5912        };
5913        if source_right > source.width || source_bottom > source.height {
5914            return false;
5915        }
5916
5917        self.recorder.encoder().copy_texture_to_texture(
5918            wgpu::TexelCopyTextureInfo {
5919                texture: source.texture(),
5920                mip_level: 0,
5921                origin: wgpu::Origin3d {
5922                    x: source_origin.0,
5923                    y: source_origin.1,
5924                    z: 0,
5925                },
5926                aspect: wgpu::TextureAspect::All,
5927            },
5928            wgpu::TexelCopyTextureInfo {
5929                texture: target.texture(),
5930                mip_level: 0,
5931                origin: wgpu::Origin3d::ZERO,
5932                aspect: wgpu::TextureAspect::All,
5933            },
5934            wgpu::Extent3d {
5935                width,
5936                height,
5937                depth_or_array_layers: 1,
5938            },
5939        );
5940        true
5941    }
5942
5943    fn shader_composite_batch_to_view(
5944        &mut self,
5945        dest_view: &wgpu::TextureView,
5946        viewport: (u32, u32),
5947        load_op: wgpu::LoadOp<wgpu::Color>,
5948        composites: &[ShaderCompositeBatchItem<'_>],
5949    ) -> bool {
5950        if composites.is_empty() {
5951            return true;
5952        }
5953        let device = self.renderer.device.clone();
5954        let encoded = self
5955            .renderer
5956            .effect_renderer
5957            .encode_shader_batch_src_over_to_view(
5958                self.recorder,
5959                &device,
5960                dest_view,
5961                viewport,
5962                load_op,
5963                composites,
5964            );
5965        if encoded {
5966            self.recorder.record_pass();
5967            self.renderer.effect_renderer.record_composite_pass();
5968            self.renderer
5969                .effect_renderer
5970                .debug_effects
5971                .set(self.renderer.effect_renderer.debug_effects.get() + composites.len() as u32);
5972        }
5973        encoded
5974    }
5975
5976    fn composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
5977        &mut self,
5978        source: &OffscreenTarget,
5979        dest_view: &wgpu::TextureView,
5980        alpha: f32,
5981        load_op: wgpu::LoadOp<wgpu::Color>,
5982        scissor: Option<(u32, u32, u32, u32)>,
5983        rounded_mask: Option<RoundedCompositeMask>,
5984        blend_mode: BlendMode,
5985        dest_viewport: Option<(f32, f32, f32, f32)>,
5986        sample_mode: CompositeSampleMode,
5987    ) {
5988        let device = self.renderer.device.clone();
5989        {
5990            self.renderer
5991                .effect_renderer
5992                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
5993                    self.recorder,
5994                    &device,
5995                    source,
5996                    dest_view,
5997                    alpha,
5998                    load_op,
5999                    scissor,
6000                    rounded_mask,
6001                    supported_blend_mode(blend_mode),
6002                    dest_viewport,
6003                    sample_mode,
6004                );
6005        }
6006        self.recorder.record_pass();
6007        self.renderer.effect_renderer.record_composite_pass();
6008    }
6009
6010    fn apply_effect_and_composite_to_view(
6011        &mut self,
6012        source: &OffscreenTarget,
6013        effect: &RenderEffect,
6014        effect_rect: [f32; 4],
6015        dest_view: &wgpu::TextureView,
6016        alpha: f32,
6017        load_op: wgpu::LoadOp<wgpu::Color>,
6018        scissor: Option<(u32, u32, u32, u32)>,
6019        blend_mode: BlendMode,
6020        dest_viewport: Option<(f32, f32, f32, f32)>,
6021        sample_mode: CompositeSampleMode,
6022    ) -> Result<(), String> {
6023        self.record_effect_composite(
6024            source,
6025            effect,
6026            effect_rect,
6027            dest_view,
6028            alpha,
6029            load_op,
6030            scissor,
6031            blend_mode,
6032            dest_viewport,
6033            sample_mode,
6034        )
6035    }
6036
6037    fn apply_shader_and_composite_to_view(
6038        &mut self,
6039        source: &OffscreenTarget,
6040        shader: &RuntimeShader,
6041        effect_rect: [f32; 4],
6042        dest_view: &wgpu::TextureView,
6043        alpha: f32,
6044        load_op: wgpu::LoadOp<wgpu::Color>,
6045        scissor: Option<(u32, u32, u32, u32)>,
6046        blend_mode: BlendMode,
6047        dest_viewport: Option<(f32, f32, f32, f32)>,
6048        sample_mode: CompositeSampleMode,
6049    ) {
6050        self.record_shader_composite(
6051            source,
6052            shader,
6053            effect_rect,
6054            dest_view,
6055            alpha,
6056            load_op,
6057            scissor,
6058            blend_mode,
6059            dest_viewport,
6060            sample_mode,
6061        );
6062    }
6063
6064    fn apply_shader_and_composite_to_view_projective(
6065        &mut self,
6066        source: &OffscreenTarget,
6067        shader: &RuntimeShader,
6068        effect_rect: [f32; 4],
6069        dest_view: &wgpu::TextureView,
6070        viewport: (u32, u32),
6071        source_size: (f32, f32),
6072        inverse_matrix: [[f32; 3]; 3],
6073        dest_bounds: [[f32; 2]; 4],
6074        alpha: f32,
6075        load_op: wgpu::LoadOp<wgpu::Color>,
6076        scissor: Option<(u32, u32, u32, u32)>,
6077        blend_mode: BlendMode,
6078        sample_mode: CompositeSampleMode,
6079    ) {
6080        self.record_shader_projective_composite(
6081            source,
6082            shader,
6083            effect_rect,
6084            dest_view,
6085            viewport,
6086            source_size,
6087            inverse_matrix,
6088            dest_bounds,
6089            alpha,
6090            load_op,
6091            scissor,
6092            blend_mode,
6093            sample_mode,
6094        );
6095    }
6096
6097    fn apply_effect_and_composite_to_view_projective(
6098        &mut self,
6099        source: &OffscreenTarget,
6100        effect: &RenderEffect,
6101        effect_rect: [f32; 4],
6102        dest_view: &wgpu::TextureView,
6103        viewport: (u32, u32),
6104        source_size: (f32, f32),
6105        inverse_matrix: [[f32; 3]; 3],
6106        dest_bounds: [[f32; 2]; 4],
6107        alpha: f32,
6108        load_op: wgpu::LoadOp<wgpu::Color>,
6109        scissor: Option<(u32, u32, u32, u32)>,
6110        blend_mode: BlendMode,
6111        sample_mode: CompositeSampleMode,
6112    ) -> Result<(), String> {
6113        self.record_effect_projective_composite(
6114            source,
6115            effect,
6116            effect_rect,
6117            dest_view,
6118            viewport,
6119            source_size,
6120            inverse_matrix,
6121            dest_bounds,
6122            alpha,
6123            load_op,
6124            scissor,
6125            blend_mode,
6126            sample_mode,
6127        )
6128    }
6129
6130    fn is_render_effect_supported(&self, effect: &RenderEffect) -> bool {
6131        self.renderer.supports_render_effect(effect)
6132    }
6133
6134    fn warn_unsupported_effect_once(&self) {
6135        self.renderer.warning_state.warn_unsupported_effect_once();
6136    }
6137
6138    fn record_layer_cache_miss(&self, width: u32, height: u32) {
6139        self.renderer
6140            .frame_stats
6141            .record_layer_cache_miss(width, height);
6142    }
6143
6144    fn record_isolated_layer_render(
6145        &self,
6146        width: u32,
6147        height: u32,
6148        node_id: Option<NodeId>,
6149        logical_rect: Rect,
6150        requirements: SurfaceRequirementSet,
6151    ) {
6152        self.renderer.frame_stats.record_isolated_layer_render(
6153            width,
6154            height,
6155            node_id,
6156            logical_rect,
6157            requirements.into(),
6158        );
6159    }
6160}
6161
6162impl GpuRenderer {
6163    pub fn render(
6164        &mut self,
6165        view: &wgpu::TextureView,
6166        width: u32,
6167        height: u32,
6168        packet: FramePacket,
6169        surface_epoch: u64,
6170        returns: &mut RenderReturns,
6171    ) -> Result<(), String> {
6172        // Packet validity gate — BEFORE consume_replay_ops and any
6173        // encoding. A packet built against another renderer instance,
6174        // another surface configuration, or another viewport is cancelled
6175        // whole: its buffers travel back through `returns` for re-queue
6176        // and recycling, and nothing of it reaches the GPU.
6177        let cancel_reason = if packet.renderer_epoch != self.renderer_epoch {
6178            Some(CancelReason::RendererEpoch)
6179        } else if packet.surface_epoch != surface_epoch {
6180            Some(CancelReason::SurfaceEpoch)
6181        } else if packet.viewport != (width, height) {
6182            Some(CancelReason::Viewport)
6183        } else {
6184            None
6185        };
6186        if let Some(reason) = cancel_reason {
6187            return Self::cancel_packet(packet, reason, returns);
6188        }
6189        returns.frame_id = packet.frame_id;
6190        log::trace!("🎨 Rendering graph to {}x{}", width, height);
6191        let render_start = Instant::now();
6192
6193        #[cfg(target_arch = "wasm32")]
6194        {
6195            self.wasm_uniform_batch_cursor = 0;
6196            self.wasm_shape_batch_cursor = 0;
6197            self.wasm_image_batch_cursor = 0;
6198        }
6199        #[cfg(not(target_arch = "wasm32"))]
6200        {
6201            self.retained_glyph_uniform_cursor = 0;
6202        }
6203
6204        // Producer-side text layout cache size, carried by the packet — the
6205        // present call tree holds no text layout state, and no layout runs
6206        // between packet build and the stats block below.
6207        let text_cache_len = packet.text_cache_len;
6208        let result = self.render_graph(view, packet, returns);
6209        let after_graph = Instant::now();
6210        self.flush_deferred_offscreen_releases();
6211
6212        #[cfg(target_arch = "wasm32")]
6213        {
6214            const WASM_BATCH_POOL_MARGIN: usize = 4;
6215            self.wasm_uniform_batches.truncate(
6216                self.wasm_uniform_batch_cursor
6217                    .saturating_add(WASM_BATCH_POOL_MARGIN),
6218            );
6219            self.wasm_shape_batches.truncate(
6220                self.wasm_shape_batch_cursor
6221                    .saturating_add(WASM_BATCH_POOL_MARGIN),
6222            );
6223            self.wasm_image_batches.truncate(
6224                self.wasm_image_batch_cursor
6225                    .saturating_add(WASM_BATCH_POOL_MARGIN),
6226            );
6227        }
6228        self.staged_uploads
6229            .shrink_retained_capacity(RETAINED_STAGED_UPLOAD_BYTES, RETAINED_STAGED_UPLOAD_COPIES);
6230
6231        self.layer_surface_cache.finish_frame(&self.frame_stats);
6232        #[cfg(not(target_arch = "wasm32"))]
6233        self.retained_bundle_cache.end_frame();
6234
6235        self.frame_stats.offscreen_pool_size.set(
6236            self.effect_renderer
6237                .retained_offscreen_count()
6238                .saturating_add(self.frame_graph_executor.retained_texture_count())
6239                as u32,
6240        );
6241        self.frame_stats.offscreen_pool_bytes.set(
6242            (self.effect_renderer.retained_offscreen_bytes() as u64)
6243                .saturating_add(self.frame_graph_executor.retained_texture_bytes()),
6244        );
6245        self.frame_stats
6246            .text_pool_size
6247            .set(self.text_image_cache.len() as u32);
6248        self.frame_stats
6249            .image_cache_size
6250            .set(self.image_texture_cache.len() as u32);
6251        self.frame_stats.text_cache_size.set(text_cache_len as u32);
6252        self.effect_renderer
6253            .merge_and_reset_debug_counters(&self.frame_stats);
6254        self.frame_graph_executor.reset_upload_allocators();
6255        let snapshot = self.frame_stats.snapshot();
6256        self.last_frame_stats = Some(snapshot);
6257        PRESENTED_FRAMES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6258        update_frame_warmup_budget(&mut self.pending_frame_warmup_frames, &snapshot);
6259        self.frame_stats.maybe_print_snapshot(
6260            snapshot,
6261            &mut self.frame_count,
6262            self.gpu_stats_enabled,
6263        );
6264        if self.gpu_stats_enabled && self.frame_count.is_multiple_of(60) {
6265            gpu_stats::print_gpu_memory_report(&self.device, self.frame_count);
6266        }
6267        self.frame_stats.reset();
6268        let after_stats = Instant::now();
6269        if let Some(total_ms) = should_log_wgpu_render_stage(render_start, after_stats) {
6270            log::warn!(
6271                "[wgpu-render-stage:render] total_ms={total_ms:.2} graph_ms={:.2} cleanup_stats_ms={:.2}",
6272                instant_ms(render_start, after_graph),
6273                instant_ms(after_graph, after_stats),
6274            );
6275        }
6276        if result.is_ok() {
6277            // Only a draw that actually ran may report `Presented`; an
6278            // errored draw leaves the default `NotRun`.
6279            returns.outcome = PresentOutcome::Presented;
6280        }
6281        result
6282    }
6283
6284    /// Refuses a packet whole, before any encoding: every buffer it
6285    /// carries travels back through `returns` — the direct scene for the
6286    /// producer pool, the unconsumed replay plan for the planner to
6287    /// re-queue (its releases name still-live store slots; dropping them
6288    /// would leak pool ids forever). A cancel is a protocol outcome, not a
6289    /// draw error, so the render call returns `Ok(())`.
6290    fn cancel_packet(
6291        packet: FramePacket,
6292        reason: CancelReason,
6293        returns: &mut RenderReturns,
6294    ) -> Result<(), String> {
6295        let FramePacket {
6296            frame_id,
6297            viewport: _,
6298            renderer_epoch: _,
6299            surface_epoch: _,
6300            root_scale: _,
6301            root,
6302            overlay: _,
6303            replay,
6304            text_cache_len: _,
6305        } = packet;
6306        match root {
6307            PacketRoot::Direct(root) => {
6308                // Destructure: the scene buffers return to the producer
6309                // pool; the rest of the collected layer drops. A Direct
6310                // packet's replay plan came from the planner and must go
6311                // back to it unconsumed — a Surface packet only ever
6312                // carries the empty default plan, which has nothing to
6313                // reclaim.
6314                returns.scene = Some(root.scene);
6315                #[cfg(not(target_arch = "wasm32"))]
6316                {
6317                    returns.cancelled_replay = Some(replay);
6318                }
6319            }
6320            PacketRoot::Surface(_) => {}
6321        }
6322        #[cfg(target_arch = "wasm32")]
6323        let _ = replay;
6324        returns.ack = None;
6325        returns.frame_id = frame_id;
6326        returns.outcome = PresentOutcome::Cancelled(reason);
6327        Ok(())
6328    }
6329
6330    pub fn last_frame_stats(&self) -> Option<gpu_stats::FrameStatsSnapshot> {
6331        self.last_frame_stats
6332    }
6333
6334    pub fn needs_frame_warmup(&self) -> bool {
6335        self.pending_frame_warmup_frames > 0
6336    }
6337
6338    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
6339        let layer_surface_cache_stats = self.layer_surface_cache.debug_stats();
6340        DebugCpuAllocationStats {
6341            scene_graph_node_count: 0,
6342            scene_graph_heap_bytes: 0,
6343            scene_hits_len: 0,
6344            scene_hits_cap: 0,
6345            scene_node_index_len: 0,
6346            scene_node_index_cap: 0,
6347            text_renderer_pool_len: self.text_image_cache.len(),
6348            text_renderer_pool_cap: self.text_image_cache.cap().get(),
6349            swash_image_cache_len: 0,
6350            swash_image_cache_cap: 0,
6351            swash_outline_cache_len: 0,
6352            swash_outline_cache_cap: 0,
6353            image_texture_cache_len: self.image_texture_cache.len(),
6354            image_texture_cache_cap: self.image_texture_cache.cap().get(),
6355            scratch_shape_data_cap: self.scratch_shape_data.capacity(),
6356            scratch_gradients_cap: self.scratch_gradients.capacity(),
6357            scratch_image_vertices_cap: self.scratch_image_vertices.capacity(),
6358            scratch_image_indices_cap: self.scratch_image_indices.capacity(),
6359            scratch_image_cmds_cap: self.scratch_image_cmds.capacity(),
6360            scratch_segment_items_cap: self.scratch_segment_items.capacity(),
6361            scratch_effect_ranges_cap: self.scratch_effect_ranges.capacity(),
6362            scratch_layer_events_cap: self.scratch_layer_events.capacity(),
6363            staged_upload_bytes_cap: self.staged_uploads.bytes.capacity(),
6364            staged_upload_copies_cap: self.staged_uploads.copies.capacity(),
6365            layer_surface_cache_len: layer_surface_cache_stats.entries_len,
6366            layer_surface_cache_cap: layer_surface_cache_stats.entries_cap,
6367            layer_surface_cache_identity_len: layer_surface_cache_stats.identity_len,
6368            layer_surface_cache_identity_cap: layer_surface_cache_stats.identity_cap,
6369            // The producer frontend owns the only lowering-memo pair since
6370            // step 6b; the present backend contributes nothing.
6371            layer_surface_rect_cache_len: 0,
6372            layer_surface_rect_cache_cap: 0,
6373            layer_surface_requirements_cache_len: 0,
6374            layer_surface_requirements_cache_cap: 0,
6375            layer_cache_seen_this_frame_len: layer_surface_cache_stats.seen_this_frame_len,
6376            layer_cache_seen_this_frame_cap: layer_surface_cache_stats.seen_this_frame_cap,
6377        }
6378    }
6379
6380    pub fn render_to_rgba_pixels(
6381        &mut self,
6382        width: u32,
6383        height: u32,
6384        packet: FramePacket,
6385        surface_epoch: u64,
6386        returns: &mut RenderReturns,
6387    ) -> Result<Vec<u8>, String> {
6388        if width == 0 || height == 0 {
6389            return Err("Screenshot size must be non-zero".to_string());
6390        }
6391
6392        let output_texture = self.device.create_texture(&wgpu::TextureDescriptor {
6393            label: Some("Screenshot Output Texture"),
6394            size: wgpu::Extent3d {
6395                width,
6396                height,
6397                depth_or_array_layers: 1,
6398            },
6399            mip_level_count: 1,
6400            sample_count: 1,
6401            dimension: wgpu::TextureDimension::D2,
6402            format: self.surface_format,
6403            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
6404            view_formats: &[],
6405        });
6406        let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
6407
6408        self.render(&output_view, width, height, packet, surface_epoch, returns)?;
6409
6410        let bytes_per_pixel = 4u32;
6411        let unpadded_bytes_per_row = width
6412            .checked_mul(bytes_per_pixel)
6413            .ok_or_else(|| "Screenshot row byte size overflow".to_string())?;
6414        let padded_bytes_per_row =
6415            align_to(unpadded_bytes_per_row, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
6416        let output_buffer_size = padded_bytes_per_row as u64 * height as u64;
6417
6418        let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
6419            label: Some("Screenshot Readback Buffer"),
6420            size: output_buffer_size,
6421            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
6422            mapped_at_creation: false,
6423        });
6424
6425        let device = self.device.clone();
6426        let queue = self.queue.clone();
6427        let mut graph = WgpuFrameGraph::new(Some("Screenshot Copy Encoder"));
6428        let source = graph.import_surface("screenshot-copy-source");
6429        graph.add_fallible_command_pass(Some("Screenshot Copy Pass"), &[source], &[], |context| {
6430            context.encoder.copy_texture_to_buffer(
6431                wgpu::TexelCopyTextureInfo {
6432                    texture: &output_texture,
6433                    mip_level: 0,
6434                    origin: wgpu::Origin3d::ZERO,
6435                    aspect: wgpu::TextureAspect::All,
6436                },
6437                wgpu::TexelCopyBufferInfo {
6438                    buffer: &output_buffer,
6439                    layout: wgpu::TexelCopyBufferLayout {
6440                        offset: 0,
6441                        bytes_per_row: Some(padded_bytes_per_row),
6442                        rows_per_image: Some(height),
6443                    },
6444                },
6445                wgpu::Extent3d {
6446                    width,
6447                    height,
6448                    depth_or_array_layers: 1,
6449                },
6450            );
6451            Ok(())
6452        });
6453        let mut executor = std::mem::take(&mut self.frame_graph_executor);
6454        let execution = executor.execute_recorded_graph(&device, &queue, graph);
6455        self.frame_graph_executor = executor;
6456        let execution = execution.map_err(|error| error.to_string())?;
6457        let submission_index = execution.submission;
6458        let copy_stats = execution.stats;
6459        self.last_frame_stats = self
6460            .last_frame_stats
6461            .map(|snapshot| snapshot.with_command_stats_added(copy_stats));
6462
6463        let buffer_slice = output_buffer.slice(..);
6464        let (tx, rx) = mpsc::channel();
6465        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
6466            let _ = tx.send(result);
6467        });
6468        let _ = self.device.poll(wgpu::PollType::Wait {
6469            submission_index: Some(submission_index),
6470            timeout: None,
6471        });
6472
6473        match rx.recv_timeout(Duration::from_secs(3)) {
6474            Ok(Ok(())) => {}
6475            Ok(Err(err)) => return Err(format!("Screenshot map_async failed: {err:?}")),
6476            Err(err) => return Err(format!("Screenshot readback timed out: {err}")),
6477        }
6478
6479        let mapped = buffer_slice.get_mapped_range();
6480        let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4];
6481
6482        let src_row_len = padded_bytes_per_row as usize;
6483        let dst_row_len = unpadded_bytes_per_row as usize;
6484        for row in 0..height as usize {
6485            let src_offset = row * src_row_len;
6486            let dst_offset = row * dst_row_len;
6487            pixels[dst_offset..dst_offset + dst_row_len]
6488                .copy_from_slice(&mapped[src_offset..src_offset + dst_row_len]);
6489        }
6490        drop(mapped);
6491        output_buffer.unmap();
6492
6493        self.convert_surface_pixels_to_rgba(&mut pixels)?;
6494        Ok(pixels)
6495    }
6496
6497    fn render_graph(
6498        &mut self,
6499        surface_view: &wgpu::TextureView,
6500        packet: FramePacket,
6501        returns: &mut RenderReturns,
6502    ) -> Result<(), String> {
6503        let device = self.device.clone();
6504        let queue = self.queue.clone();
6505        let graph_start = Instant::now();
6506
6507        #[cfg(not(target_arch = "wasm32"))]
6508        {
6509            let mut executor = std::mem::take(&mut self.frame_graph_executor);
6510            let mut frame_graph = WgpuFrameGraph::new(Some("Renderer Frame Graph"));
6511            let surface = frame_graph.import_surface("renderer-surface");
6512            frame_graph.add_fallible_recorded_command_pass(
6513                Some("Renderer Frame Pass"),
6514                &[],
6515                &[surface],
6516                |frame_encoder| {
6517                    self.render_graph_recorded(surface_view, packet, returns, frame_encoder)
6518                },
6519            );
6520            let after_build = Instant::now();
6521            let execution = executor.execute_recorded_graph(&device, &queue, frame_graph);
6522            let after_execute = Instant::now();
6523            self.frame_graph_executor = executor;
6524            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
6525                log::warn!(
6526                    "[wgpu-render-stage:graph] total_ms={total_ms:.2} build_ms={:.2} execute_ms={:.2}",
6527                    instant_ms(graph_start, after_build),
6528                    instant_ms(after_build, after_execute),
6529                );
6530            }
6531
6532            match execution {
6533                Ok(execution) => {
6534                    if execution.stats.pass_count > 0 {
6535                        self.frame_stats.record_command_stats(execution.stats);
6536                    }
6537                    Ok(())
6538                }
6539                Err(crate::frame_graph::FrameGraphError::NoDeclaredPasses) => Ok(()),
6540                Err(error) => Err(error.to_string()),
6541            }
6542        }
6543
6544        #[cfg(target_arch = "wasm32")]
6545        {
6546            let mut executor = std::mem::take(&mut self.frame_graph_executor);
6547            let (result, execution) = {
6548                let mut frame_encoder =
6549                    executor.begin(&device, &queue, Some("Renderer Frame Encoder"));
6550                let initial_pass_count = frame_encoder.recorded_pass_count();
6551                let result =
6552                    self.render_graph_recorded(surface_view, packet, returns, &mut frame_encoder);
6553                let execution =
6554                    if result.is_ok() && frame_encoder.recorded_pass_count() > initial_pass_count {
6555                        Some(frame_encoder.finish())
6556                    } else {
6557                        None
6558                    };
6559                (result, execution)
6560            };
6561            let after_execute = Instant::now();
6562            self.frame_graph_executor = executor;
6563            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
6564                log::warn!("[wgpu-render-stage:graph] total_ms={total_ms:.2}",);
6565            }
6566            if let Some(execution) = execution {
6567                self.frame_stats.record_command_stats(execution.stats);
6568            }
6569            result
6570        }
6571    }
6572
6573    fn render_graph_recorded<C: FrameCommandRecorder>(
6574        &mut self,
6575        surface_view: &wgpu::TextureView,
6576        packet: FramePacket,
6577        returns: &mut RenderReturns,
6578        frame_encoder: &mut C,
6579    ) -> Result<(), String> {
6580        let recorded_start = Instant::now();
6581
6582        // Present-side consumption of the packet's replay plan, adjacent to
6583        // packet consumption: the store honors the ops just before the
6584        // packet renders. Gated on a Direct root — a Surface packet never
6585        // touched the planner and carries the empty default plan
6586        // (generation 0), which the store must not consume: it would count
6587        // a false generation drop. The ack travels back through `returns`
6588        // and the producer applies it right after this render call —
6589        // equivalent to the in-store drain this replaces, because both
6590        // application points sit after this frame's graph build and before
6591        // the next collect, which is where the bypass gate and `feed_slots`
6592        // are read.
6593        #[cfg(not(target_arch = "wasm32"))]
6594        let mut packet = packet;
6595        #[cfg(not(target_arch = "wasm32"))]
6596        if let PacketRoot::Direct(root) = &packet.root {
6597            let ops = std::mem::take(&mut packet.replay);
6598            let (ack, recycled) =
6599                self.consume_replay_ops(ops, &root.scene.shapes, packet.root_scale);
6600            returns.ack = Some((ack, recycled));
6601        }
6602
6603        let FramePacket {
6604            frame_id,
6605            viewport: (width, height),
6606            renderer_epoch: _,
6607            surface_epoch: _,
6608            root_scale,
6609            root,
6610            overlay,
6611            replay: _,
6612            text_cache_len: _,
6613        } = packet;
6614
6615        let mut backend = RecordingSurfaceBackend {
6616            renderer: self,
6617            recorder: frame_encoder,
6618        };
6619
6620        let surface_packet = match root {
6621            PacketRoot::Direct(root) => {
6622                let direct_render_start = Instant::now();
6623                let result = match execute_render_root_direct(
6624                    &mut backend,
6625                    surface_view,
6626                    *root,
6627                    width,
6628                    height,
6629                    root_scale,
6630                    wgpu::LoadOp::Clear(CLEAR_COLOR),
6631                ) {
6632                    // Return the packet's scene buffers to the producer pool
6633                    // in BOTH arms — for a heavy animated frame they are
6634                    // megabytes of Vec, and an errored draw must not leak
6635                    // them.
6636                    Ok(scene) => {
6637                        returns.scene = Some(scene);
6638                        Ok(())
6639                    }
6640                    Err((error, scene)) => {
6641                        returns.scene = Some(scene);
6642                        Err(error)
6643                    }
6644                };
6645                if result.is_ok() {
6646                    if let Some(overlay) = overlay {
6647                        Self::render_overlay_packet(
6648                            &mut backend,
6649                            surface_view,
6650                            overlay,
6651                            width,
6652                            height,
6653                            root_scale,
6654                        )?;
6655                    }
6656                }
6657                let after_direct_render = Instant::now();
6658                if let Some(total_ms) =
6659                    should_log_wgpu_render_stage(recorded_start, after_direct_render)
6660                {
6661                    log::warn!(
6662                        "[wgpu-render-stage:recorded-direct-root] frame={frame_id} total_ms={total_ms:.2} render_ms={:.2}",
6663                        instant_ms(direct_render_start, after_direct_render),
6664                    );
6665                }
6666                return result;
6667            }
6668            PacketRoot::Surface(surface_packet) => surface_packet,
6669        };
6670        let after_root_collect = Instant::now();
6671
6672        let RootSurfacePacket {
6673            lowered,
6674            source,
6675            transform_to_parent,
6676            node_id,
6677            backdrop,
6678            graphics_layer,
6679            local_bounds,
6680            clip_rect,
6681            shadow_clip,
6682        } = *surface_packet;
6683        let mut lowered = lowered;
6684        lowered.source = source;
6685
6686        // The root layer's visible area is always the viewport — content
6687        // outside the screen is invisible regardless of scroll offsets or
6688        // inflated scene bounds.  Pass the viewport rect as an explicit
6689        // surface rect to prevent offscreen inflation on constrained GPUs.
6690        let viewport_rect = Rect {
6691            x: 0.0,
6692            y: 0.0,
6693            width: width as f32 / root_scale,
6694            height: height as f32 / root_scale,
6695        };
6696        let root_surface = execute_render_layer_surface(
6697            &mut backend,
6698            &mut lowered,
6699            LayerSurfaceRequest {
6700                root_scale,
6701                backdrop_underlay: None,
6702                allow_runtime_cache: false,
6703                logical_rect_override: Some(viewport_rect),
6704                capture_clip_override: None,
6705                activates_nested_capture: false,
6706                translation_context: TranslationRenderContext::default(),
6707            },
6708        )?;
6709        let root_quad = transform_to_parent.map_rect(root_surface.logical_rect);
6710        let root_dest_quad = scaled_quad(root_quad, root_scale);
6711
6712        let needs_root_composite_target =
6713            backdrop.is_some() || graphics_layer.shadow_elevation > 0.0;
6714
6715        if needs_root_composite_target {
6716            let composite_target = backend.acquire_frame_surface(width, height);
6717            backend.clear_target_view_with_load_op(
6718                &composite_target.view,
6719                wgpu::LoadOp::Clear(CLEAR_COLOR),
6720            );
6721
6722            if let Some(backdrop) = &backdrop {
6723                execute_apply_backdrop_layer_to_target(
6724                    &mut backend,
6725                    &composite_target,
6726                    &BackdropLayer {
6727                        node_id,
6728                        rect: quad_bounds(transform_to_parent.map_rect(local_bounds)),
6729                        clip: clip_rect.map(|clip| quad_bounds(transform_to_parent.map_rect(clip))),
6730                        snap_anchor: None,
6731                        effect: backdrop.clone(),
6732                        z_index: 0,
6733                    },
6734                    None,
6735                    width,
6736                    height,
6737                    root_scale,
6738                    None,
6739                )?;
6740            }
6741
6742            let mut root_shadow_scene = CompositorScene::new();
6743            let root_shadow_clip =
6744                shadow_clip.map(|clip| quad_bounds(transform_to_parent.map_rect(clip)));
6745            push_layer_shadow(
6746                &mut root_shadow_scene,
6747                &graphics_layer,
6748                local_bounds,
6749                quad_bounds(transform_to_parent.map_rect(local_bounds)),
6750                root_shadow_clip,
6751            );
6752            for shadow in &root_shadow_scene.shadow_draws {
6753                backend.render_shadow_draw(
6754                    &composite_target.view,
6755                    shadow,
6756                    width,
6757                    height,
6758                    root_scale,
6759                );
6760            }
6761
6762            let composite_dest_quad =
6763                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
6764            execute_composite_surface_to_view(
6765                &mut backend,
6766                root_surface.target.target(),
6767                &composite_target.view,
6768                (width, height),
6769                composite_dest_quad,
6770                root_surface.composite_alpha,
6771                wgpu::LoadOp::Load,
6772                None,
6773                root_surface.blend_mode,
6774                root_surface.sample_mode,
6775            )?;
6776            backend.composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
6777                &composite_target,
6778                surface_view,
6779                1.0,
6780                wgpu::LoadOp::Clear(CLEAR_COLOR),
6781                None,
6782                None,
6783                BlendMode::SrcOver,
6784                None,
6785                CompositeSampleMode::Linear,
6786            );
6787            backend.release_frame_surface(composite_target);
6788        } else {
6789            let composite_dest_quad =
6790                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
6791            execute_composite_surface_to_view(
6792                &mut backend,
6793                root_surface.target.target(),
6794                surface_view,
6795                (width, height),
6796                composite_dest_quad,
6797                root_surface.composite_alpha,
6798                wgpu::LoadOp::Clear(CLEAR_COLOR),
6799                None,
6800                root_surface.blend_mode,
6801                root_surface.sample_mode,
6802            )?;
6803        }
6804        backend.release_layer_surface_target(root_surface.target);
6805        if let Some(overlay) = overlay {
6806            Self::render_overlay_packet(
6807                &mut backend,
6808                surface_view,
6809                overlay,
6810                width,
6811                height,
6812                root_scale,
6813            )?;
6814        }
6815        let after_layer_render = Instant::now();
6816        if let Some(total_ms) = should_log_wgpu_render_stage(recorded_start, after_layer_render) {
6817            log::warn!(
6818                "[wgpu-render-stage:recorded-layer-root] total_ms={total_ms:.2} collect_ms={:.2} render_ms={:.2}",
6819                instant_ms(recorded_start, after_root_collect),
6820                instant_ms(after_root_collect, after_layer_render),
6821            );
6822        }
6823        Ok(())
6824    }
6825
6826    /// Renders the producer-lowered dev overlay on top of the frame. The
6827    /// packet carries the collected overlay; the backend only validates
6828    /// that it stayed directly renderable and draws it.
6829    fn render_overlay_packet<C: FrameCommandRecorder>(
6830        backend: &mut RecordingSurfaceBackend<'_, '_, C>,
6831        surface_view: &wgpu::TextureView,
6832        overlay: CollectedLayer,
6833        width: u32,
6834        height: u32,
6835        root_scale: f32,
6836    ) -> Result<(), String> {
6837        if !overlay.child_layers.is_empty()
6838            || !root_direct_scene_events_are_supported(&overlay.scene)
6839            || !direct_root_child_underlays_are_supported(&overlay)
6840        {
6841            return Err("dev overlay graph must stay directly renderable".to_string());
6842        }
6843        execute_render_root_direct(
6844            backend,
6845            surface_view,
6846            overlay,
6847            width,
6848            height,
6849            root_scale,
6850            wgpu::LoadOp::Load,
6851        )
6852        .map(|_overlay_scene| ())
6853        .map_err(|(error, _overlay_scene)| error)
6854    }
6855
6856    #[allow(clippy::too_many_arguments)]
6857    fn encode_non_effect_segment_commands<C: FrameCommandRecorder>(
6858        &mut self,
6859        frame_encoder: &mut C,
6860        target_view: &wgpu::TextureView,
6861        ordered_items: &[(usize, SegmentDrawItem)],
6862        composites: &[(usize, CompositeBatchItem<'_>)],
6863        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
6864        shapes: &[DrawShape],
6865        images: &[ImageDraw],
6866        texts: &[TextDraw],
6867        shadow_draws: &[ShadowDraw],
6868        retained_draws: &[RetainedDraw],
6869        initial_load_op: wgpu::LoadOp<wgpu::Color>,
6870        width: u32,
6871        height: u32,
6872        root_scale: f32,
6873    ) -> Result<SegmentCommandEncodeOutcome, String> {
6874        let mut first_batch = true;
6875        for command in
6876            SegmentCommandIter::new(ordered_items, shapes, images, self.shape_batch_limits)
6877        {
6878            match command {
6879                SegmentRenderCommand::DrawChunk(chunk) => {
6880                    let load_op = if first_batch {
6881                        initial_load_op
6882                    } else {
6883                        wgpu::LoadOp::Load
6884                    };
6885                    let outcome = self.render_segment_draw_chunk(
6886                        frame_encoder,
6887                        target_view,
6888                        ordered_items,
6889                        composites,
6890                        shader_composites,
6891                        shapes,
6892                        images,
6893                        texts,
6894                        retained_draws,
6895                        chunk,
6896                        width,
6897                        height,
6898                        root_scale,
6899                        load_op,
6900                    )?;
6901                    if outcome.rendered_any {
6902                        frame_encoder.record_passes(outcome.pass_count);
6903                        first_batch = false;
6904                    }
6905                }
6906                SegmentRenderCommand::Shadow(index) => {
6907                    if first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
6908                        {
6909                            let _clear = frame_encoder.encoder().begin_render_pass(
6910                                &wgpu::RenderPassDescriptor {
6911                                    label: Some("Shadow Pre-Clear"),
6912                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
6913                                        view: target_view,
6914                                        resolve_target: None,
6915                                        depth_slice: None,
6916                                        ops: wgpu::Operations {
6917                                            load: initial_load_op,
6918                                            store: wgpu::StoreOp::Store,
6919                                        },
6920                                    })],
6921                                    depth_stencil_attachment: None,
6922                                    timestamp_writes: None,
6923                                    occlusion_query_set: None,
6924                                    multiview_mask: None,
6925                                },
6926                            );
6927                        }
6928                        frame_encoder.record_pass();
6929                        first_batch = false;
6930                    }
6931                    let pass_count_before = frame_encoder.recorded_pass_count();
6932                    self.encode_shadow_draw(
6933                        frame_encoder,
6934                        target_view,
6935                        &shadow_draws[index],
6936                        width,
6937                        height,
6938                        root_scale,
6939                    );
6940                    if frame_encoder.recorded_pass_count() > pass_count_before {
6941                        first_batch = false;
6942                    }
6943                }
6944            }
6945        }
6946        Ok(SegmentCommandEncodeOutcome { first_batch })
6947    }
6948
6949    #[cfg(not(target_arch = "wasm32"))]
6950    #[allow(clippy::too_many_arguments)]
6951    fn render_segment_draw_chunk_fused_native<C: FrameCommandRecorder>(
6952        &mut self,
6953        frame_encoder: &mut C,
6954        target_view: &wgpu::TextureView,
6955        ordered_items: &[(usize, SegmentDrawItem)],
6956        composites: &[(usize, CompositeBatchItem<'_>)],
6957        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
6958        shapes: &[DrawShape],
6959        images: &[ImageDraw],
6960        texts: &[TextDraw],
6961        retained_draws: &[RetainedDraw],
6962        chunk: &SegmentDrawChunkPlan,
6963        width: u32,
6964        height: u32,
6965        root_scale: f32,
6966        load_op: wgpu::LoadOp<wgpu::Color>,
6967    ) -> Result<Option<SegmentRenderOutcome>, String> {
6968        let Some(partitions) = native_segment_fusion_partitions(
6969            ordered_items,
6970            shapes,
6971            chunk,
6972            self.shape_batch_limits,
6973        )?
6974        else {
6975            return Ok(None);
6976        };
6977
6978        let mut rendered_any = false;
6979        let mut pass_count = 0_u32;
6980        let mut next_load_op = load_op;
6981        for partition in partitions {
6982            let outcome = self.render_segment_draw_chunk_fused_native_partition(
6983                frame_encoder,
6984                target_view,
6985                ordered_items,
6986                composites,
6987                shader_composites,
6988                shapes,
6989                images,
6990                texts,
6991                retained_draws,
6992                &partition.chunk,
6993                partition.budget,
6994                width,
6995                height,
6996                root_scale,
6997                next_load_op,
6998            )?;
6999            if outcome.rendered_any {
7000                rendered_any = true;
7001                pass_count = pass_count.saturating_add(outcome.pass_count);
7002                next_load_op = wgpu::LoadOp::Load;
7003            }
7004        }
7005
7006        Ok(Some(SegmentRenderOutcome {
7007            rendered_any,
7008            pass_count,
7009        }))
7010    }
7011
7012    #[cfg(not(target_arch = "wasm32"))]
7013    #[allow(clippy::too_many_arguments)]
7014    fn render_segment_draw_chunk_fused_native_partition<C: FrameCommandRecorder>(
7015        &mut self,
7016        frame_encoder: &mut C,
7017        target_view: &wgpu::TextureView,
7018        ordered_items: &[(usize, SegmentDrawItem)],
7019        composites: &[(usize, CompositeBatchItem<'_>)],
7020        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
7021        shapes: &[DrawShape],
7022        images: &[ImageDraw],
7023        texts: &[TextDraw],
7024        retained_draws: &[RetainedDraw],
7025        chunk: &SegmentDrawChunkPlan,
7026        budget: NativeSegmentFusionBudget,
7027        width: u32,
7028        height: u32,
7029        root_scale: f32,
7030        load_op: wgpu::LoadOp<wgpu::Color>,
7031    ) -> Result<SegmentRenderOutcome, String> {
7032        let partition_start = Instant::now();
7033        let mut staged_uploads = self.take_staged_uploads();
7034        staged_uploads.clear();
7035        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
7036        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
7037        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
7038        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
7039
7040        image_vertices.clear();
7041        image_indices.clear();
7042        image_cmds.clear();
7043        glyph_cmds.clear();
7044
7045        let result = (|| {
7046            let viewport = ViewportUniformParams {
7047                width,
7048                height,
7049                offset: [0.0, 0.0],
7050            };
7051            self.prewarm_offscreen_text_glyph_draws_in_chunk(
7052                ordered_items,
7053                texts,
7054                chunk,
7055                viewport,
7056                root_scale,
7057                &mut staged_uploads,
7058                &mut image_vertices,
7059                &mut image_indices,
7060                &mut glyph_cmds,
7061            )?;
7062            let mut shape_refs = Vec::with_capacity(budget.shape_count);
7063            for batch in chunk.iter() {
7064                let SegmentBatchPlan::Shape { start, end, .. } = batch else {
7065                    continue;
7066                };
7067                for (_, item) in &ordered_items[start..end] {
7068                    let SegmentDrawItem::Shape(shape_index) = item else {
7069                        return Err(format!(
7070                            "shape batch contains non-shape draw item: {item:?}"
7071                        ));
7072                    };
7073                    shape_refs.push(&shapes[*shape_index]);
7074                }
7075            }
7076            let after_shape_refs = Instant::now();
7077
7078            let mut direct_shape_uploads = StagedBufferUploads::default();
7079            let mut shape_upload_base = 0u64;
7080            if !shape_refs.is_empty() {
7081                let Some((_, upload_base)) = self.prepare_shapes_batch_direct(
7082                    frame_encoder,
7083                    shape_refs.iter().copied(),
7084                    root_scale,
7085                    viewport,
7086                    &mut direct_shape_uploads,
7087                ) else {
7088                    return Err(
7089                        "native fused segment shape preparation produced no draw batch".to_string(),
7090                    );
7091                };
7092                shape_upload_base = upload_base;
7093            }
7094            let after_shape_prepare = Instant::now();
7095
7096            let mut fused_batches = Vec::with_capacity(chunk.batches.len());
7097            let mut shape_cursor = 0_u32;
7098            let mut composite_cursor = 0usize;
7099            let mut shader_composite_cursor = 0usize;
7100            for batch in chunk.iter() {
7101                match batch {
7102                    SegmentBatchPlan::Shape {
7103                        start,
7104                        end,
7105                        blend_mode,
7106                    } => {
7107                        for (_, item) in &ordered_items[start..end] {
7108                            if !matches!(item, SegmentDrawItem::Shape(_)) {
7109                                return Err(format!(
7110                                    "shape batch contains non-shape draw item: {item:?}"
7111                                ));
7112                            }
7113                        }
7114                        let shape_count = end - start;
7115                        if shape_count > 0 {
7116                            fused_batches.push(FusedSegmentBatch::Shape {
7117                                batch: PreparedShapeBatch {
7118                                    vertex_start: shape_cursor * 6,
7119                                    vertex_count: shape_count as u32 * 6,
7120                                },
7121                                blend_mode,
7122                            });
7123                            shape_cursor += shape_count as u32;
7124                        }
7125                    }
7126                    SegmentBatchPlan::Image {
7127                        start,
7128                        end,
7129                        blend_mode,
7130                    } => {
7131                        let cmd_start = image_cmds.len();
7132                        for (_, item) in &ordered_items[start..end] {
7133                            let SegmentDrawItem::Image(image_index) = item else {
7134                                return Err(format!(
7135                                    "image batch contains non-image draw item: {item:?}"
7136                                ));
7137                            };
7138                            self.append_image_draw_cmd(
7139                                &images[*image_index],
7140                                viewport,
7141                                root_scale,
7142                                &mut image_vertices,
7143                                &mut image_indices,
7144                                &mut image_cmds,
7145                            )?;
7146                        }
7147                        let cmd_end = image_cmds.len();
7148                        if cmd_start < cmd_end {
7149                            fused_batches.push(FusedSegmentBatch::Image {
7150                                cmd_range: cmd_start..cmd_end,
7151                                blend_mode,
7152                            });
7153                        }
7154                    }
7155                    SegmentBatchPlan::Text { start, end } => {
7156                        let glyph_cmd_start = glyph_cmds.len();
7157                        let image_cmd_start = image_cmds.len();
7158                        let text_draws =
7159                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7160                        if !self.append_text_glyph_draws(
7161                            text_draws,
7162                            viewport,
7163                            root_scale,
7164                            false,
7165                            &mut staged_uploads,
7166                            &mut image_vertices,
7167                            &mut image_indices,
7168                            &mut glyph_cmds,
7169                        )? {
7170                            let text_draws =
7171                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7172                            self.append_text_image_draw_cmds(
7173                                text_draws,
7174                                viewport,
7175                                root_scale,
7176                                &mut image_vertices,
7177                                &mut image_indices,
7178                                &mut image_cmds,
7179                            )?;
7180                        }
7181                        let image_cmd_end = image_cmds.len();
7182                        let glyph_cmd_end = glyph_cmds.len();
7183                        if image_cmd_start < image_cmd_end || glyph_cmd_start < glyph_cmd_end {
7184                            fused_batches.push(FusedSegmentBatch::Text {
7185                                image_cmd_range: image_cmd_start..image_cmd_end,
7186                                glyph_cmd_range: glyph_cmd_start..glyph_cmd_end,
7187                            });
7188                        }
7189                    }
7190                    SegmentBatchPlan::Composite { start, end } => {
7191                        for (_, item) in &ordered_items[start..end] {
7192                            if !matches!(item, SegmentDrawItem::Composite(_)) {
7193                                return Err(format!(
7194                                    "composite batch contains non-composite draw item: {item:?}"
7195                                ));
7196                            }
7197                        }
7198                        let draw_count = end - start;
7199                        if draw_count > 0 {
7200                            let draw_start = composite_cursor;
7201                            composite_cursor += draw_count;
7202                            fused_batches.push(FusedSegmentBatch::Composite {
7203                                draw_range: draw_start..composite_cursor,
7204                            });
7205                        }
7206                    }
7207                    SegmentBatchPlan::ShaderComposite { start, end } => {
7208                        for (_, item) in &ordered_items[start..end] {
7209                            if !matches!(item, SegmentDrawItem::ShaderComposite(_)) {
7210                                return Err(format!(
7211                                    "shader composite batch contains non-shader-composite draw item: {item:?}"
7212                                ));
7213                            }
7214                        }
7215                        let draw_count = end - start;
7216                        if draw_count > 0 {
7217                            let draw_start = shader_composite_cursor;
7218                            shader_composite_cursor += draw_count;
7219                            fused_batches.push(FusedSegmentBatch::ShaderComposite {
7220                                draw_range: draw_start..shader_composite_cursor,
7221                            });
7222                        }
7223                    }
7224                    SegmentBatchPlan::Retained { start, end } => {
7225                        self.stage_replay_patches(&mut staged_uploads);
7226                        for (_, item) in &ordered_items[start..end] {
7227                            let SegmentDrawItem::Retained(index) = item else {
7228                                return Err(format!(
7229                                    "retained batch contains non-retained draw item: {item:?}"
7230                                ));
7231                            };
7232                            let retained = retained_draws.get(*index).ok_or_else(|| {
7233                                format!("retained draw index {index} out of bounds")
7234                            })?;
7235                            if (*index as u32) < MAX_REPLAY_SLOTS
7236                                && self.replay_slots.slots.contains_key(&retained.slot)
7237                            {
7238                                let transform = retained.transform.with_retained_paint();
7239                                staged_uploads.stage_at(
7240                                    UploadTarget::ReplayTransform,
7241                                    *index as u64 * REPLAY_TRANSFORM_STRIDE,
7242                                    bytemuck::bytes_of(&transform),
7243                                );
7244                            }
7245                        }
7246                        if end > start {
7247                            fused_batches.push(FusedSegmentBatch::Retained {
7248                                item_range: start..end,
7249                            });
7250                        }
7251                    }
7252                }
7253            }
7254            let after_batch_prepare = Instant::now();
7255
7256            if !image_indices.is_empty() {
7257                self.stage_native_image_buffers(
7258                    &mut staged_uploads,
7259                    viewport,
7260                    &image_vertices,
7261                    &image_indices,
7262                );
7263            }
7264
7265            let device = self.device.clone();
7266            let composite_items: Vec<_> = chunk
7267                .iter()
7268                .filter_map(|batch| match batch {
7269                    SegmentBatchPlan::Composite { start, end } => Some((start, end)),
7270                    _ => None,
7271                })
7272                .flat_map(|(start, end)| {
7273                    ordered_items[start..end].iter().filter_map(|(_, item)| {
7274                        let SegmentDrawItem::Composite(composite_index) = item else {
7275                            return None;
7276                        };
7277                        composites
7278                            .get(*composite_index)
7279                            .map(|(_, composite)| *composite)
7280                    })
7281                })
7282                .collect();
7283            let prepared_composites = self.effect_renderer.prepare_composite_batch_draws(
7284                frame_encoder,
7285                &device,
7286                load_op,
7287                &composite_items,
7288            );
7289            let shader_items: Vec<_> = chunk
7290                .iter()
7291                .filter_map(|batch| match batch {
7292                    SegmentBatchPlan::ShaderComposite { start, end } => Some((start, end)),
7293                    _ => None,
7294                })
7295                .flat_map(|(start, end)| {
7296                    ordered_items[start..end].iter().filter_map(|(_, item)| {
7297                        let SegmentDrawItem::ShaderComposite(composite_index) = item else {
7298                            return None;
7299                        };
7300                        shader_composites
7301                            .get(*composite_index)
7302                            .map(|(_, composite)| *composite)
7303                    })
7304                })
7305                .collect();
7306            let prepared_shaders = self
7307                .effect_renderer
7308                .prepare_shader_batch_draws(frame_encoder, &device, &shader_items)
7309                .ok_or_else(|| "shader composite batch preparation failed".to_string())?;
7310            if !shader_items.is_empty() {
7311                self.effect_renderer.record_composite_pass();
7312                self.effect_renderer
7313                    .debug_effects
7314                    .set(self.effect_renderer.debug_effects.get() + shader_items.len() as u32);
7315            }
7316            let after_composite_prepare = Instant::now();
7317
7318            if fused_batches.is_empty() {
7319                return Ok(SegmentRenderOutcome {
7320                    rendered_any: false,
7321                    pass_count: 0,
7322                });
7323            }
7324
7325            // The direct shape copies must be recorded before the staged
7326            // flush: its capacity check may replace `upload_buffer`, and the
7327            // shape payload was written into the buffer that existed at
7328            // prepare time. Recording first binds the copies to that buffer.
7329            self.flush_staged_uploads_at(
7330                frame_encoder.encoder(),
7331                &direct_shape_uploads,
7332                shape_upload_base,
7333            );
7334            let upload_offset =
7335                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7336            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
7337            let after_upload = Instant::now();
7338
7339            let use_retained_bundles = retained_bundles_enabled();
7340            let mut retained_encode_ms = 0.0_f64;
7341            {
7342                let mut render_pass =
7343                    frame_encoder
7344                        .encoder()
7345                        .begin_render_pass(&wgpu::RenderPassDescriptor {
7346                            label: Some("Fused Segment Draw Pass"),
7347                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7348                                view: target_view,
7349                                resolve_target: None,
7350                                depth_slice: None,
7351                                ops: wgpu::Operations {
7352                                    load: load_op,
7353                                    store: wgpu::StoreOp::Store,
7354                                },
7355                            })],
7356                            depth_stencil_attachment: None,
7357                            timestamp_writes: None,
7358                            occlusion_query_set: None,
7359                            multiview_mask: None,
7360                        });
7361
7362                for batch in &fused_batches {
7363                    match batch {
7364                        FusedSegmentBatch::Shape { batch, blend_mode } => {
7365                            self.draw_prepared_shapes(
7366                                &mut render_pass,
7367                                *blend_mode,
7368                                *batch,
7369                                width,
7370                                height,
7371                            );
7372                        }
7373                        FusedSegmentBatch::Image {
7374                            cmd_range,
7375                            blend_mode,
7376                        } => {
7377                            self.draw_native_prepared_image_cmd_range(
7378                                &mut render_pass,
7379                                &image_cmds,
7380                                cmd_range.clone(),
7381                                *blend_mode,
7382                            )?;
7383                        }
7384                        FusedSegmentBatch::Text {
7385                            image_cmd_range,
7386                            glyph_cmd_range,
7387                        } => {
7388                            if !image_cmd_range.is_empty() {
7389                                self.draw_native_prepared_image_cmd_range(
7390                                    &mut render_pass,
7391                                    &image_cmds,
7392                                    image_cmd_range.clone(),
7393                                    BlendMode::SrcOver,
7394                                )?;
7395                                self.frame_stats.bump_text();
7396                            }
7397                            if !glyph_cmd_range.is_empty() {
7398                                self.draw_native_prepared_glyph_cmd_range(
7399                                    &mut render_pass,
7400                                    &glyph_cmds,
7401                                    glyph_cmd_range.clone(),
7402                                )?;
7403                            }
7404                        }
7405                        FusedSegmentBatch::Composite { draw_range } => {
7406                            for draw in
7407                                prepared_composites.get(draw_range.clone()).ok_or_else(|| {
7408                                    "composite draw range is outside the prepared command buffer"
7409                                        .to_string()
7410                                })?
7411                            {
7412                                self.effect_renderer.draw_prepared_composite(
7413                                    &mut render_pass,
7414                                    (width, height),
7415                                    draw,
7416                                );
7417                            }
7418                        }
7419                        FusedSegmentBatch::ShaderComposite { draw_range } => {
7420                            for draw in prepared_shaders.get(draw_range.clone()).ok_or_else(|| {
7421                                "shader composite draw range is outside the prepared command buffer"
7422                                    .to_string()
7423                            })? {
7424                                self.effect_renderer.draw_prepared_shader_src_over(
7425                                    &device,
7426                                    &mut render_pass,
7427                                    (width, height),
7428                                    draw,
7429                                );
7430                            }
7431                        }
7432                        FusedSegmentBatch::Retained { item_range } => {
7433                            // Each Retained arm is one MAXIMAL consecutive
7434                            // retained stretch — the planner groups adjacent
7435                            // retained items into a single batch — so caching
7436                            // per arm never flattens across the dynamic
7437                            // batches interleaved at their z positions.
7438                            let retained_start = Instant::now();
7439                            if use_retained_bundles {
7440                                self.draw_retained_stretch_bundled(
7441                                    &mut render_pass,
7442                                    ordered_items,
7443                                    retained_draws,
7444                                    item_range.clone(),
7445                                    width,
7446                                    height,
7447                                );
7448                            } else {
7449                                for (_, item) in &ordered_items[item_range.clone()] {
7450                                    if let SegmentDrawItem::Retained(index) = item {
7451                                        if let Some(retained) = retained_draws.get(*index) {
7452                                            self.draw_retained_batch(
7453                                                &mut render_pass,
7454                                                retained,
7455                                                *index,
7456                                                width,
7457                                                height,
7458                                            );
7459                                        }
7460                                    }
7461                                }
7462                            }
7463                            retained_encode_ms += instant_ms(retained_start, Instant::now());
7464                        }
7465                    }
7466                }
7467            }
7468            let after_pass = Instant::now();
7469            if let Some(total_ms) = should_log_wgpu_render_stage(partition_start, after_pass) {
7470                log::warn!(
7471                    "[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={}",
7472                    instant_ms(partition_start, after_shape_refs),
7473                    instant_ms(after_shape_refs, after_shape_prepare),
7474                    instant_ms(after_shape_prepare, after_batch_prepare),
7475                    instant_ms(after_batch_prepare, after_composite_prepare),
7476                    instant_ms(after_composite_prepare, after_upload),
7477                    instant_ms(after_upload, after_pass),
7478                    fused_batches.len(),
7479                    budget.shape_count,
7480                    image_cmds.len(),
7481                    glyph_cmds.len(),
7482                    staged_uploads.bytes.len(),
7483                );
7484            }
7485
7486            Ok(SegmentRenderOutcome {
7487                rendered_any: true,
7488                pass_count: 1,
7489            })
7490        })();
7491
7492        self.scratch_image_vertices = image_vertices;
7493        self.scratch_image_indices = image_indices;
7494        self.scratch_image_cmds = image_cmds;
7495        self.scratch_glyph_cmds = glyph_cmds;
7496        self.restore_staged_uploads(staged_uploads);
7497        result
7498    }
7499
7500    #[allow(clippy::too_many_arguments)]
7501    fn render_segment_draw_chunk<C: FrameCommandRecorder>(
7502        &mut self,
7503        frame_encoder: &mut C,
7504        target_view: &wgpu::TextureView,
7505        ordered_items: &[(usize, SegmentDrawItem)],
7506        composites: &[(usize, CompositeBatchItem<'_>)],
7507        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
7508        shapes: &[DrawShape],
7509        images: &[ImageDraw],
7510        texts: &[TextDraw],
7511        retained_draws: &[RetainedDraw],
7512        chunk: SegmentDrawChunkPlan,
7513        width: u32,
7514        height: u32,
7515        root_scale: f32,
7516        load_op: wgpu::LoadOp<wgpu::Color>,
7517    ) -> Result<SegmentRenderOutcome, String> {
7518        #[cfg(target_arch = "wasm32")]
7519        let _ = retained_draws;
7520        #[cfg(not(target_arch = "wasm32"))]
7521        if let Some(outcome) = self.render_segment_draw_chunk_fused_native(
7522            frame_encoder,
7523            target_view,
7524            ordered_items,
7525            composites,
7526            shader_composites,
7527            shapes,
7528            images,
7529            texts,
7530            retained_draws,
7531            &chunk,
7532            width,
7533            height,
7534            root_scale,
7535            load_op,
7536        )? {
7537            return Ok(outcome);
7538        }
7539
7540        let mut staged_uploads = self.take_staged_uploads();
7541        let result = (|| {
7542            let mut rendered_any = false;
7543            let mut pass_count = 0_u32;
7544            let mut next_load_op = load_op;
7545            for batch in chunk.iter() {
7546                staged_uploads.clear();
7547                match batch {
7548                    SegmentBatchPlan::Shape {
7549                        start,
7550                        end,
7551                        blend_mode,
7552                    } => {
7553                        let slice = &ordered_items[start..end];
7554                        if slice.len() > self.shape_batch_limits.max_shapes_per_batch {
7555                            return Err(format!(
7556                                "shape batch contains {} shapes, exceeding the renderer limit of {}",
7557                                slice.len(),
7558                                self.shape_batch_limits.max_shapes_per_batch
7559                            ));
7560                        }
7561                        let viewport = ViewportUniformParams {
7562                            width,
7563                            height,
7564                            offset: [0.0, 0.0],
7565                        };
7566                        for (_, item) in slice {
7567                            if !matches!(item, SegmentDrawItem::Shape(_)) {
7568                                return Err(format!(
7569                                    "shape batch contains non-shape draw item: {item:?}"
7570                                ));
7571                            }
7572                        }
7573                        let Some(prepared) = self.prepare_shapes_batch(
7574                            slice.iter().filter_map(|(_, item)| match item {
7575                                SegmentDrawItem::Shape(shape_index) => Some(&shapes[*shape_index]),
7576                                _ => None,
7577                            }),
7578                            root_scale,
7579                            viewport,
7580                            &mut staged_uploads,
7581                        ) else {
7582                            continue;
7583                        };
7584                        let upload_offset = frame_encoder
7585                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7586                        self.flush_staged_uploads_at(
7587                            frame_encoder.encoder(),
7588                            &staged_uploads,
7589                            upload_offset,
7590                        );
7591                        {
7592                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
7593                                &wgpu::RenderPassDescriptor {
7594                                    label: Some("Segment Shape Pass"),
7595                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7596                                        view: target_view,
7597                                        resolve_target: None,
7598                                        depth_slice: None,
7599                                        ops: wgpu::Operations {
7600                                            load: next_load_op,
7601                                            store: wgpu::StoreOp::Store,
7602                                        },
7603                                    })],
7604                                    depth_stencil_attachment: None,
7605                                    timestamp_writes: None,
7606                                    occlusion_query_set: None,
7607                                    multiview_mask: None,
7608                                },
7609                            );
7610                            self.draw_prepared_shapes(
7611                                &mut render_pass,
7612                                blend_mode,
7613                                prepared,
7614                                width,
7615                                height,
7616                            );
7617                        }
7618                        pass_count = pass_count.saturating_add(1);
7619                        rendered_any = true;
7620                        next_load_op = wgpu::LoadOp::Load;
7621                    }
7622                    SegmentBatchPlan::Image {
7623                        start,
7624                        end,
7625                        blend_mode,
7626                    } => {
7627                        let viewport = ViewportUniformParams {
7628                            width,
7629                            height,
7630                            offset: [0.0, 0.0],
7631                        };
7632                        for (_, item) in &ordered_items[start..end] {
7633                            if !matches!(item, SegmentDrawItem::Image(_)) {
7634                                return Err(format!(
7635                                    "image batch contains non-image draw item: {item:?}"
7636                                ));
7637                            }
7638                        }
7639                        let prepared_images = self.prepare_image_draw_cmds(
7640                            ordered_items[start..end]
7641                                .iter()
7642                                .filter_map(|(_, item)| match item {
7643                                    SegmentDrawItem::Image(image_index) => {
7644                                        Some(&images[*image_index])
7645                                    }
7646                                    _ => None,
7647                                }),
7648                            viewport,
7649                            root_scale,
7650                            &mut staged_uploads,
7651                        )?;
7652                        if prepared_images.is_empty() {
7653                            self.scratch_image_cmds = prepared_images.into_cmds();
7654                            continue;
7655                        }
7656                        let upload_offset = frame_encoder
7657                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7658                        self.flush_staged_uploads_at(
7659                            frame_encoder.encoder(),
7660                            &staged_uploads,
7661                            upload_offset,
7662                        );
7663                        let draw_result = {
7664                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
7665                                &wgpu::RenderPassDescriptor {
7666                                    label: Some("Segment Image Pass"),
7667                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7668                                        view: target_view,
7669                                        resolve_target: None,
7670                                        depth_slice: None,
7671                                        ops: wgpu::Operations {
7672                                            load: next_load_op,
7673                                            store: wgpu::StoreOp::Store,
7674                                        },
7675                                    })],
7676                                    depth_stencil_attachment: None,
7677                                    timestamp_writes: None,
7678                                    occlusion_query_set: None,
7679                                    multiview_mask: None,
7680                                },
7681                            );
7682                            self.draw_prepared_images(
7683                                &mut render_pass,
7684                                &prepared_images,
7685                                blend_mode,
7686                            )
7687                        };
7688                        pass_count = pass_count.saturating_add(1);
7689                        self.scratch_image_cmds = prepared_images.into_cmds();
7690                        draw_result?;
7691                        rendered_any = true;
7692                        next_load_op = wgpu::LoadOp::Load;
7693                    }
7694                    SegmentBatchPlan::Text { start, end } => {
7695                        let viewport = ViewportUniformParams {
7696                            width,
7697                            height,
7698                            offset: [0.0, 0.0],
7699                        };
7700                        let text_draws =
7701                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7702                        if let Some(prepared_glyphs) = self.prepare_text_glyph_draw_cmds(
7703                            text_draws,
7704                            viewport,
7705                            root_scale,
7706                            &mut staged_uploads,
7707                        )? {
7708                            if prepared_glyphs.is_empty() {
7709                                self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
7710                                continue;
7711                            }
7712                            let upload_offset = frame_encoder
7713                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7714                            self.flush_staged_uploads_at(
7715                                frame_encoder.encoder(),
7716                                &staged_uploads,
7717                                upload_offset,
7718                            );
7719                            {
7720                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
7721                                    &wgpu::RenderPassDescriptor {
7722                                        label: Some("Segment Text Glyph Atlas Pass"),
7723                                        color_attachments: &[Some(
7724                                            wgpu::RenderPassColorAttachment {
7725                                                view: target_view,
7726                                                resolve_target: None,
7727                                                depth_slice: None,
7728                                                ops: wgpu::Operations {
7729                                                    load: next_load_op,
7730                                                    store: wgpu::StoreOp::Store,
7731                                                },
7732                                            },
7733                                        )],
7734                                        depth_stencil_attachment: None,
7735                                        timestamp_writes: None,
7736                                        occlusion_query_set: None,
7737                                        multiview_mask: None,
7738                                    },
7739                                );
7740                                self.draw_prepared_glyphs(&mut render_pass, &prepared_glyphs)?;
7741                            }
7742                            pass_count = pass_count.saturating_add(1);
7743                            self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
7744                            rendered_any = true;
7745                            next_load_op = wgpu::LoadOp::Load;
7746                        } else {
7747                            let text_draws =
7748                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7749                            let prepared_images = self.prepare_text_image_draw_cmds(
7750                                text_draws,
7751                                viewport,
7752                                root_scale,
7753                                &mut staged_uploads,
7754                            )?;
7755                            if prepared_images.is_empty() {
7756                                self.scratch_image_cmds = prepared_images.into_cmds();
7757                                continue;
7758                            }
7759                            let upload_offset = frame_encoder
7760                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7761                            self.flush_staged_uploads_at(
7762                                frame_encoder.encoder(),
7763                                &staged_uploads,
7764                                upload_offset,
7765                            );
7766                            {
7767                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
7768                                    &wgpu::RenderPassDescriptor {
7769                                        label: Some("Segment Text Pass"),
7770                                        color_attachments: &[Some(
7771                                            wgpu::RenderPassColorAttachment {
7772                                                view: target_view,
7773                                                resolve_target: None,
7774                                                depth_slice: None,
7775                                                ops: wgpu::Operations {
7776                                                    load: next_load_op,
7777                                                    store: wgpu::StoreOp::Store,
7778                                                },
7779                                            },
7780                                        )],
7781                                        depth_stencil_attachment: None,
7782                                        timestamp_writes: None,
7783                                        occlusion_query_set: None,
7784                                        multiview_mask: None,
7785                                    },
7786                                );
7787                                self.draw_prepared_images(
7788                                    &mut render_pass,
7789                                    &prepared_images,
7790                                    BlendMode::SrcOver,
7791                                )?;
7792                            }
7793                            self.frame_stats.bump_text();
7794                            pass_count = pass_count.saturating_add(1);
7795                            self.scratch_image_cmds = prepared_images.into_cmds();
7796                            rendered_any = true;
7797                            next_load_op = wgpu::LoadOp::Load;
7798                        }
7799                    }
7800                    SegmentBatchPlan::Composite { start, end } => {
7801                        let batch_items: Vec<_> = ordered_items[start..end]
7802                            .iter()
7803                            .map(|(_, item)| match item {
7804                                SegmentDrawItem::Composite(composite_index) => composites
7805                                    .get(*composite_index)
7806                                    .map(|(_, composite)| *composite)
7807                                    .ok_or_else(|| {
7808                                        "composite item index is outside the composite buffer"
7809                                            .to_string()
7810                                    }),
7811                                other => Err(format!(
7812                                    "composite batch contains non-composite draw item: {other:?}"
7813                                )),
7814                            })
7815                            .collect::<Result<_, _>>()?;
7816                        let device = self.device.clone();
7817                        self.effect_renderer.encode_composite_batch_to_view_pass(
7818                            frame_encoder,
7819                            &device,
7820                            target_view,
7821                            (width, height),
7822                            next_load_op,
7823                            &batch_items,
7824                        );
7825                        self.effect_renderer.record_composite_pass();
7826                        pass_count = pass_count.saturating_add(1);
7827                        rendered_any = true;
7828                        next_load_op = wgpu::LoadOp::Load;
7829                    }
7830                    SegmentBatchPlan::ShaderComposite { start, end } => {
7831                        let batch_items: Vec<_> = ordered_items[start..end]
7832                            .iter()
7833                            .map(|(_, item)| match item {
7834                                SegmentDrawItem::ShaderComposite(composite_index) => {
7835                                    shader_composites
7836                                        .get(*composite_index)
7837                                        .map(|(_, composite)| *composite)
7838                                        .ok_or_else(|| {
7839                                            "shader composite item index is outside the shader composite buffer"
7840                                                .to_string()
7841                                        })
7842                                }
7843                                other => Err(format!(
7844                                    "shader composite batch contains non-shader-composite draw item: {other:?}"
7845                                )),
7846                            })
7847                            .collect::<Result<Vec<_>, _>>()?;
7848                        let device = self.device.clone();
7849                        let encoded = self.effect_renderer.encode_shader_batch_src_over_to_view(
7850                            frame_encoder,
7851                            &device,
7852                            target_view,
7853                            (width, height),
7854                            next_load_op,
7855                            &batch_items,
7856                        );
7857                        if !encoded {
7858                            return Err("shader composite batch failed to encode".to_string());
7859                        }
7860                        self.effect_renderer.record_composite_pass();
7861                        self.effect_renderer.debug_effects.set(
7862                            self.effect_renderer.debug_effects.get() + batch_items.len() as u32,
7863                        );
7864                        pass_count = pass_count.saturating_add(1);
7865                        rendered_any = true;
7866                        next_load_op = wgpu::LoadOp::Load;
7867                    }
7868                    SegmentBatchPlan::Retained { start, end } => {
7869                        // Reached only when native fusion declined the chunk;
7870                        // retained batches exist on storage-mode native
7871                        // devices, where fusion always accepts, but the arm
7872                        // stays a real draw so that assumption is not load-
7873                        // bearing for correctness. Deliberately direct encode
7874                        // — retained bundle caching lives in the fused path
7875                        // only; this fallback stays the simple reference.
7876                        #[cfg(target_arch = "wasm32")]
7877                        {
7878                            let _ = (start, end);
7879                            return Err("retained shape batches are native-only".to_string());
7880                        }
7881                        #[cfg(not(target_arch = "wasm32"))]
7882                        {
7883                            self.stage_replay_patches(&mut staged_uploads);
7884                            for (_, item) in &ordered_items[start..end] {
7885                                let SegmentDrawItem::Retained(index) = item else {
7886                                    return Err(format!(
7887                                        "retained batch contains non-retained draw item: {item:?}"
7888                                    ));
7889                                };
7890                                let retained = retained_draws.get(*index).ok_or_else(|| {
7891                                    format!("retained draw index {index} out of bounds")
7892                                })?;
7893                                if (*index as u32) < MAX_REPLAY_SLOTS
7894                                    && self.replay_slots.slots.contains_key(&retained.slot)
7895                                {
7896                                    let transform = retained.transform.with_retained_paint();
7897                                    staged_uploads.stage_at(
7898                                        UploadTarget::ReplayTransform,
7899                                        *index as u64 * REPLAY_TRANSFORM_STRIDE,
7900                                        bytemuck::bytes_of(&transform),
7901                                    );
7902                                }
7903                            }
7904                            let upload_offset = frame_encoder
7905                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7906                            self.flush_staged_uploads_at(
7907                                frame_encoder.encoder(),
7908                                &staged_uploads,
7909                                upload_offset,
7910                            );
7911                            {
7912                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
7913                                    &wgpu::RenderPassDescriptor {
7914                                        label: Some("Segment Retained Pass"),
7915                                        color_attachments: &[Some(
7916                                            wgpu::RenderPassColorAttachment {
7917                                                view: target_view,
7918                                                resolve_target: None,
7919                                                depth_slice: None,
7920                                                ops: wgpu::Operations {
7921                                                    load: next_load_op,
7922                                                    store: wgpu::StoreOp::Store,
7923                                                },
7924                                            },
7925                                        )],
7926                                        depth_stencil_attachment: None,
7927                                        timestamp_writes: None,
7928                                        occlusion_query_set: None,
7929                                        multiview_mask: None,
7930                                    },
7931                                );
7932                                for (_, item) in &ordered_items[start..end] {
7933                                    if let SegmentDrawItem::Retained(index) = item {
7934                                        if let Some(retained) = retained_draws.get(*index) {
7935                                            self.draw_retained_batch(
7936                                                &mut render_pass,
7937                                                retained,
7938                                                *index,
7939                                                width,
7940                                                height,
7941                                            );
7942                                        }
7943                                    }
7944                                }
7945                            }
7946                            pass_count = pass_count.saturating_add(1);
7947                            rendered_any = true;
7948                            next_load_op = wgpu::LoadOp::Load;
7949                        }
7950                    }
7951                }
7952            }
7953            Ok(SegmentRenderOutcome {
7954                rendered_any,
7955                pass_count,
7956            })
7957        })();
7958        self.restore_staged_uploads(staged_uploads);
7959        result
7960    }
7961
7962    fn viewport_uniforms(params: ViewportUniformParams) -> Uniforms {
7963        Uniforms {
7964            viewport: [params.width as f32, params.height as f32],
7965            viewport_offset: params.offset,
7966        }
7967    }
7968
7969    #[cfg(not(target_arch = "wasm32"))]
7970    fn stage_viewport_uniforms(
7971        &self,
7972        staged_uploads: &mut StagedBufferUploads,
7973        params: ViewportUniformParams,
7974    ) {
7975        let uniforms = Self::viewport_uniforms(params);
7976        staged_uploads.stage(UploadTarget::Uniform, bytemuck::bytes_of(&uniforms));
7977    }
7978
7979    #[cfg(not(target_arch = "wasm32"))]
7980    fn stage_retained_glyph_viewport_uniforms(
7981        &mut self,
7982        staged_uploads: &mut StagedBufferUploads,
7983        params: ViewportUniformParams,
7984    ) -> usize {
7985        let slot = self.claim_retained_glyph_uniform_slot();
7986        let uniforms = Self::viewport_uniforms(params);
7987        staged_uploads.stage_at(
7988            UploadTarget::RetainedGlyphUniform,
7989            self.retained_glyph_uniform_offset(slot),
7990            bytemuck::bytes_of(&uniforms),
7991        );
7992        slot
7993    }
7994
7995    #[cfg(not(target_arch = "wasm32"))]
7996    fn claim_retained_glyph_uniform_slot(&mut self) -> usize {
7997        let slot = self.retained_glyph_uniform_cursor;
7998        self.retained_glyph_uniform_cursor = self.retained_glyph_uniform_cursor.saturating_add(1);
7999        self.ensure_retained_glyph_uniform_capacity(slot.saturating_add(1));
8000        slot
8001    }
8002
8003    #[cfg(not(target_arch = "wasm32"))]
8004    fn retained_glyph_uniform_offset(&self, slot: usize) -> u64 {
8005        self.retained_glyph_uniform_stride * slot as u64
8006    }
8007
8008    #[cfg(not(target_arch = "wasm32"))]
8009    fn retained_glyph_uniform_dynamic_offset(&self, slot: usize) -> Result<u32, String> {
8010        let offset = self.retained_glyph_uniform_offset(slot);
8011        u32::try_from(offset).map_err(|_| {
8012            "retained glyph uniform offset exceeded WGPU dynamic offset range".to_string()
8013        })
8014    }
8015
8016    #[cfg(not(target_arch = "wasm32"))]
8017    fn ensure_retained_glyph_uniform_capacity(&mut self, required_slots: usize) {
8018        if required_slots <= self.retained_glyph_uniform_capacity {
8019            return;
8020        }
8021        let new_capacity = required_slots
8022            .next_power_of_two()
8023            .max(INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS);
8024        self.retained_glyph_uniform_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
8025            label: Some("Retained Glyph Uniform Buffer"),
8026            size: self.retained_glyph_uniform_stride * new_capacity as u64,
8027            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
8028            mapped_at_creation: false,
8029        });
8030        self.retained_glyph_uniform_bind_group =
8031            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
8032                label: Some("Retained Glyph Uniform Bind Group"),
8033                layout: &self.retained_glyph_uniform_bind_group_layout,
8034                entries: &[wgpu::BindGroupEntry {
8035                    binding: 0,
8036                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
8037                        buffer: &self.retained_glyph_uniform_buffer,
8038                        offset: 0,
8039                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
8040                    }),
8041                }],
8042            });
8043        self.retained_glyph_uniform_capacity = new_capacity;
8044    }
8045
8046    #[cfg(target_arch = "wasm32")]
8047    fn prepare_wasm_viewport_uniforms(&mut self, params: ViewportUniformParams) -> usize {
8048        let slot = self.claim_wasm_uniform_batch();
8049        let uniforms = Self::viewport_uniforms(params);
8050        let bytes = bytemuck::bytes_of(&uniforms);
8051        let upload_stats = self.frame_graph_executor.upload_buffer(
8052            &self.queue,
8053            &self.wasm_uniform_batches[slot].buffer,
8054            0,
8055            bytes,
8056        );
8057        self.frame_stats.record_command_stats(upload_stats);
8058        slot
8059    }
8060
8061    #[cfg(target_arch = "wasm32")]
8062    fn claim_wasm_uniform_batch(&mut self) -> usize {
8063        let slot = self.wasm_uniform_batch_cursor;
8064        self.wasm_uniform_batch_cursor += 1;
8065        while self.wasm_uniform_batches.len() <= slot {
8066            self.wasm_uniform_batches.push(UniformBatchBuffer::new(
8067                &self.device,
8068                &self.uniform_bind_group_layout,
8069            ));
8070        }
8071        slot
8072    }
8073
8074    #[cfg(target_arch = "wasm32")]
8075    fn claim_wasm_shape_batch(&mut self) -> usize {
8076        let slot = self.wasm_shape_batch_cursor;
8077        self.wasm_shape_batch_cursor += 1;
8078        while self.wasm_shape_batches.len() <= slot {
8079            self.wasm_shape_batches.push(ShapeBatchBuffers::new(
8080                &self.device,
8081                &self.shape_bind_group_layout,
8082                &self.identity_similarity_buffer,
8083                self.dummy_paint_buffer.as_ref(),
8084                self.shape_batch_limits,
8085            ));
8086        }
8087        slot
8088    }
8089
8090    #[cfg(target_arch = "wasm32")]
8091    fn claim_wasm_image_batch(&mut self) -> usize {
8092        let slot = self.wasm_image_batch_cursor;
8093        self.wasm_image_batch_cursor += 1;
8094        while self.wasm_image_batches.len() <= slot {
8095            self.wasm_image_batches
8096                .push(ImageBatchBuffers::new(&self.device));
8097        }
8098        slot
8099    }
8100
8101    #[cfg(target_arch = "wasm32")]
8102    fn write_wasm_buffer(&self, buffer: &wgpu::Buffer, bytes: &[u8]) {
8103        let upload_stats = self
8104            .frame_graph_executor
8105            .upload_buffer(&self.queue, buffer, 0, bytes);
8106        self.frame_stats.record_command_stats(upload_stats);
8107    }
8108
8109    fn take_staged_uploads(&mut self) -> StagedBufferUploads {
8110        let mut staged_uploads = std::mem::take(&mut self.staged_uploads);
8111        debug_assert!(
8112            staged_uploads.is_empty(),
8113            "renderer-owned staged uploads should be restored as empty scratch storage"
8114        );
8115        staged_uploads.clear();
8116        staged_uploads
8117    }
8118
8119    fn restore_staged_uploads(&mut self, mut staged_uploads: StagedBufferUploads) {
8120        staged_uploads.clear();
8121        self.staged_uploads = staged_uploads;
8122    }
8123
8124    #[cfg(not(target_arch = "wasm32"))]
8125    fn ensure_upload_buffer_capacity(&mut self, required_bytes: u64) {
8126        if required_bytes <= self.upload_buffer.size() {
8127            return;
8128        }
8129
8130        let new_size = required_bytes
8131            .next_power_of_two()
8132            .max(INITIAL_UPLOAD_BUFFER_BYTES);
8133        self.upload_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
8134            label: Some("Frame Upload Buffer"),
8135            size: new_size,
8136            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
8137            mapped_at_creation: false,
8138        });
8139    }
8140
8141    fn flush_staged_uploads_at(
8142        &mut self,
8143        encoder: &mut wgpu::CommandEncoder,
8144        staged_uploads: &StagedBufferUploads,
8145        upload_buffer_offset: u64,
8146    ) {
8147        if staged_uploads.is_empty() {
8148            return;
8149        }
8150        debug_assert_eq!(
8151            upload_buffer_offset % wgpu::COPY_BUFFER_ALIGNMENT,
8152            0,
8153            "upload-buffer base offset must satisfy copy alignment"
8154        );
8155
8156        #[cfg(target_arch = "wasm32")]
8157        {
8158            let _ = upload_buffer_offset;
8159            let _ = encoder;
8160            debug_assert!(
8161                staged_uploads.is_empty(),
8162                "wasm draw uploads use retained per-batch resource slots"
8163            );
8164            return;
8165        }
8166
8167        #[cfg(not(target_arch = "wasm32"))]
8168        {
8169            self.ensure_upload_buffer_capacity(
8170                upload_buffer_offset + staged_uploads.bytes.len() as u64,
8171            );
8172            let upload_stats = self.frame_graph_executor.upload_buffer(
8173                &self.queue,
8174                &self.upload_buffer,
8175                upload_buffer_offset,
8176                &staged_uploads.bytes,
8177            );
8178            self.frame_stats.record_command_stats(upload_stats);
8179
8180            for copy in &staged_uploads.copies {
8181                let target_buffer = match copy.target {
8182                    UploadTarget::Uniform => &self.uniform_buffer,
8183                    UploadTarget::ShapeData => &self.shape_buffers.shape_buffer,
8184                    UploadTarget::ShapeGradient => &self.shape_buffers.gradient_buffer,
8185                    UploadTarget::ImageVertex => &self.image_vertex_buffer,
8186                    UploadTarget::ImageIndex => &self.image_index_buffer,
8187                    UploadTarget::RetainedGlyphUniform => &self.retained_glyph_uniform_buffer,
8188                    UploadTarget::ReplayTransform => &self.replay_slots.transform_buffer,
8189                    UploadTarget::ReplayPaintData(slot) => {
8190                        // A slot released between staging and flush has
8191                        // nothing left to patch.
8192                        let Some(entry) = self.replay_slots.slots.get(&slot) else {
8193                            continue;
8194                        };
8195                        &entry.paint_buffer
8196                    }
8197                };
8198                encoder.copy_buffer_to_buffer(
8199                    &self.upload_buffer,
8200                    upload_buffer_offset + copy.source_offset,
8201                    target_buffer,
8202                    copy.target_offset,
8203                    copy.size,
8204                );
8205            }
8206        }
8207    }
8208
8209    #[allow(clippy::too_many_arguments)]
8210    fn encode_shadow_draw<C: FrameCommandRecorder>(
8211        &mut self,
8212        frame_encoder: &mut C,
8213        target_view: &wgpu::TextureView,
8214        shadow: &ShadowDraw,
8215        width: u32,
8216        height: u32,
8217        root_scale: f32,
8218    ) {
8219        if shadow.shapes.is_empty() && shadow.texts.is_empty() {
8220            return;
8221        }
8222
8223        let shape_bounds_opt = shadow
8224            .shapes
8225            .iter()
8226            .map(|(shape, _)| shape.rect)
8227            .reduce(|a, b| Rect {
8228                x: a.x.min(b.x),
8229                y: a.y.min(b.y),
8230                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
8231                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
8232            });
8233
8234        let text_bounds_opt = shadow
8235            .texts
8236            .iter()
8237            .map(|text| text.rect)
8238            .reduce(|a, b| Rect {
8239                x: a.x.min(b.x),
8240                y: a.y.min(b.y),
8241                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
8242                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
8243            });
8244
8245        let combined_bounds = match (shape_bounds_opt, text_bounds_opt) {
8246            (Some(s), Some(t)) => Some(Rect {
8247                x: s.x.min(t.x),
8248                y: s.y.min(t.y),
8249                width: (s.x + s.width).max(t.x + t.width) - s.x.min(t.x),
8250                height: (s.y + s.height).max(t.y + t.height) - s.y.min(t.y),
8251            }),
8252            (Some(s), None) => Some(s),
8253            (None, Some(t)) => Some(t),
8254            (None, None) => None,
8255        };
8256
8257        let Some(shape_bounds) = combined_bounds else {
8258            return;
8259        };
8260
8261        let blur_margin = blur_extent_margin(shadow.blur_radius);
8262        let source_blur_bounds = Rect {
8263            x: shape_bounds.x - blur_margin,
8264            y: shape_bounds.y - blur_margin,
8265            width: shape_bounds.width + blur_margin * 2.0,
8266            height: shape_bounds.height + blur_margin * 2.0,
8267        };
8268        let mut visible_blur_bounds = source_blur_bounds;
8269        if let Some(clip) = shadow.clip {
8270            let clip_expanded = Rect {
8271                x: clip.x - blur_margin,
8272                y: clip.y - blur_margin,
8273                width: clip.width + blur_margin * 2.0,
8274                height: clip.height + blur_margin * 2.0,
8275            };
8276            let Some(intersection) = visible_blur_bounds.intersect(clip_expanded) else {
8277                return;
8278            };
8279            visible_blur_bounds = intersection;
8280        }
8281        let processing_scissor =
8282            scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
8283        if processing_scissor.is_none() {
8284            return;
8285        }
8286
8287        // Zero blur: render shapes directly to target (fast path).
8288        if shadow.blur_radius <= 0.0 {
8289            for (shape, blend_mode) in &shadow.shapes {
8290                self.encode_shapes_pass(
8291                    frame_encoder,
8292                    target_view,
8293                    std::iter::once(shape),
8294                    *blend_mode,
8295                    width,
8296                    height,
8297                    root_scale,
8298                    wgpu::LoadOp::Load,
8299                    [0.0, 0.0],
8300                );
8301                frame_encoder.record_pass();
8302            }
8303            if !shadow.texts.is_empty() {
8304                let mut staged_uploads = self.take_staged_uploads();
8305                let viewport = ViewportUniformParams {
8306                    width,
8307                    height,
8308                    offset: [0.0, 0.0],
8309                };
8310                match self.prepare_text_image_draw_cmds(
8311                    shadow.texts.iter(),
8312                    viewport,
8313                    root_scale,
8314                    &mut staged_uploads,
8315                ) {
8316                    Ok(prepared_images) if !prepared_images.is_empty() => {
8317                        let upload_offset = frame_encoder
8318                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
8319                        self.flush_staged_uploads_at(
8320                            frame_encoder.encoder(),
8321                            &staged_uploads,
8322                            upload_offset,
8323                        );
8324                        let draw_result = {
8325                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
8326                                &wgpu::RenderPassDescriptor {
8327                                    label: Some("Zero Blur Shadow Text Image Pass"),
8328                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8329                                        view: target_view,
8330                                        resolve_target: None,
8331                                        depth_slice: None,
8332                                        ops: wgpu::Operations {
8333                                            load: wgpu::LoadOp::Load,
8334                                            store: wgpu::StoreOp::Store,
8335                                        },
8336                                    })],
8337                                    depth_stencil_attachment: None,
8338                                    timestamp_writes: None,
8339                                    occlusion_query_set: None,
8340                                    multiview_mask: None,
8341                                },
8342                            );
8343                            self.draw_prepared_images(
8344                                &mut render_pass,
8345                                &prepared_images,
8346                                BlendMode::SrcOver,
8347                            )
8348                        };
8349                        self.scratch_image_cmds = prepared_images.into_cmds();
8350                        if let Err(e) = draw_result {
8351                            eprintln!("Failed to draw text for zero-blur shadow: {}", e);
8352                        } else {
8353                            self.frame_stats.bump_text();
8354                            frame_encoder.record_pass();
8355                        }
8356                    }
8357                    Ok(prepared_images) => {
8358                        self.scratch_image_cmds = prepared_images.into_cmds();
8359                    }
8360                    Err(e) => {
8361                        eprintln!("Failed to prepare text image for zero-blur shadow: {}", e);
8362                    }
8363                }
8364                self.restore_staged_uploads(staged_uploads);
8365            }
8366            return;
8367        }
8368
8369        // Compute pixel-space bounds for the offscreen textures, clamped to viewport.
8370        let Some(device_bounds) =
8371            device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)
8372        else {
8373            return;
8374        };
8375        let bounds_x = device_bounds.x;
8376        let bounds_y = device_bounds.y;
8377        let bounds_w = device_bounds.width;
8378        let bounds_h = device_bounds.height;
8379        let pixel_radius = shadow.blur_radius * root_scale;
8380
8381        if shadow.texts.is_empty() && !shadow.shapes.is_empty() {
8382            if let Some(plan) = shape_shadow_surface_plan(
8383                &shadow.shapes,
8384                shadow.clip,
8385                shadow.blur_radius,
8386                width,
8387                height,
8388                root_scale,
8389                self.max_texture_dim(),
8390            ) {
8391                if self.encode_shape_only_blurred_shadow_draw(
8392                    frame_encoder,
8393                    target_view,
8394                    shadow,
8395                    plan.source_device_bounds,
8396                    plan.pixel_radius,
8397                    plan.processing_scissor,
8398                    width,
8399                    height,
8400                    root_scale,
8401                ) {
8402                    return;
8403                }
8404            }
8405        }
8406
8407        if !shadow.texts.is_empty() {
8408            self.frame_stats.record_shadow_text_blur_fallback();
8409        }
8410
8411        let device = self.device.clone();
8412        let source_descriptor =
8413            self.transient_offscreen_descriptor("Shadow Source", bounds_w, bounds_h);
8414        let source = frame_encoder.acquire_transient_offscreen(&device, source_descriptor);
8415        let viewport_offset = [bounds_x, bounds_y];
8416        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
8417        let source_outcome = self.encode_shadow_shape_source_passes(
8418            frame_encoder,
8419            &source.view,
8420            &shadow.shapes,
8421            bounds_w,
8422            bounds_h,
8423            viewport_offset,
8424            root_scale,
8425            &mut next_load_op,
8426        );
8427        frame_encoder.record_passes(source_outcome.pass_count);
8428        let mut rendered_any = source_outcome.rendered_any;
8429
8430        if !shadow.texts.is_empty() {
8431            let mut shifted_texts = shadow.texts.clone();
8432            for text in &mut shifted_texts {
8433                text.rect.x -= viewport_offset[0] / root_scale;
8434                text.rect.y -= viewport_offset[1] / root_scale;
8435                if let Some(clip) = text.clip.as_mut() {
8436                    clip.x -= viewport_offset[0] / root_scale;
8437                    clip.y -= viewport_offset[1] / root_scale;
8438                }
8439            }
8440
8441            let mut staged_uploads = self.take_staged_uploads();
8442            let viewport = ViewportUniformParams {
8443                width: bounds_w,
8444                height: bounds_h,
8445                offset: [0.0, 0.0],
8446            };
8447            match self.prepare_text_image_draw_cmds(
8448                shifted_texts.iter(),
8449                viewport,
8450                root_scale,
8451                &mut staged_uploads,
8452            ) {
8453                Ok(prepared_images) if !prepared_images.is_empty() => {
8454                    let upload_offset = frame_encoder
8455                        .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
8456                    self.flush_staged_uploads_at(
8457                        frame_encoder.encoder(),
8458                        &staged_uploads,
8459                        upload_offset,
8460                    );
8461                    let draw_result = {
8462                        let mut render_pass = frame_encoder.encoder().begin_render_pass(
8463                            &wgpu::RenderPassDescriptor {
8464                                label: Some("Shadow Source Text Image Pass"),
8465                                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8466                                    view: &source.view,
8467                                    resolve_target: None,
8468                                    depth_slice: None,
8469                                    ops: wgpu::Operations {
8470                                        load: next_load_op,
8471                                        store: wgpu::StoreOp::Store,
8472                                    },
8473                                })],
8474                                depth_stencil_attachment: None,
8475                                timestamp_writes: None,
8476                                occlusion_query_set: None,
8477                                multiview_mask: None,
8478                            },
8479                        );
8480                        self.draw_prepared_images(
8481                            &mut render_pass,
8482                            &prepared_images,
8483                            BlendMode::SrcOver,
8484                        )
8485                    };
8486                    self.scratch_image_cmds = prepared_images.into_cmds();
8487                    if let Err(e) = draw_result {
8488                        eprintln!("Failed to draw text for shadow: {}", e);
8489                    } else {
8490                        self.frame_stats.bump_text();
8491                        frame_encoder.record_pass();
8492                        rendered_any = true;
8493                    }
8494                }
8495                Ok(prepared_images) => {
8496                    self.scratch_image_cmds = prepared_images.into_cmds();
8497                }
8498                Err(e) => {
8499                    eprintln!("Failed to prepare text image for shadow: {}", e);
8500                }
8501            }
8502            self.restore_staged_uploads(staged_uploads);
8503        }
8504
8505        if !rendered_any {
8506            frame_encoder.release_transient_offscreen(source_descriptor, source);
8507            return;
8508        }
8509
8510        let scratch_descriptor =
8511            self.transient_offscreen_descriptor("Shadow Blur Scratch", bounds_w, bounds_h);
8512        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
8513        {
8514            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
8515                frame_encoder,
8516                &device,
8517                &source,
8518                &scratch,
8519                &source.view,
8520                pixel_radius,
8521                pixel_radius,
8522                TileMode::Decal,
8523                None, // No scissor needed — the texture is already bounds-sized
8524            );
8525        }
8526        frame_encoder.record_passes(2);
8527
8528        let clip_scissor = shadow
8529            .clip
8530            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
8531        let scissor = clip_scissor.or(processing_scissor);
8532        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
8533            // Adjust mask coordinates from viewport-space to texture-local space,
8534            // since the blit shader computes world_pos = uv * tex_size.
8535            mask.rect[0] -= viewport_offset[0];
8536            mask.rect[1] -= viewport_offset[1];
8537            mask
8538        });
8539        let dest_viewport = Some((
8540            viewport_offset[0],
8541            viewport_offset[1],
8542            bounds_w as f32,
8543            bounds_h as f32,
8544        ));
8545        {
8546            self.effect_renderer
8547                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8548                    frame_encoder,
8549                    &device,
8550                    &source,
8551                    target_view,
8552                    1.0,
8553                    wgpu::LoadOp::Load,
8554                    scissor,
8555                    rounded_mask,
8556                    BlendMode::SrcOver,
8557                    dest_viewport,
8558                    CompositeSampleMode::Linear,
8559                );
8560        }
8561        frame_encoder.record_pass();
8562        self.effect_renderer.record_blur_pass();
8563        self.effect_renderer.record_composite_pass();
8564        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
8565        frame_encoder.release_transient_offscreen(source_descriptor, source);
8566    }
8567
8568    #[allow(clippy::too_many_arguments)]
8569    fn encode_shadow_shape_source_passes<C: FrameCommandRecorder>(
8570        &mut self,
8571        frame_encoder: &mut C,
8572        source_view: &wgpu::TextureView,
8573        shapes: &[(DrawShape, BlendMode)],
8574        width: u32,
8575        height: u32,
8576        viewport_offset: [f32; 2],
8577        root_scale: f32,
8578        next_load_op: &mut wgpu::LoadOp<wgpu::Color>,
8579    ) -> ShadowSourceRenderOutcome {
8580        if shapes.is_empty() {
8581            return ShadowSourceRenderOutcome {
8582                rendered_any: false,
8583                pass_count: 0,
8584            };
8585        }
8586
8587        let mut staged_uploads = self.take_staged_uploads();
8588        let mut rendered_any = false;
8589        let mut pass_count = 0_u32;
8590        let mut start = 0usize;
8591        while start < shapes.len() {
8592            let blend_mode = supported_blend_mode(shapes[start].1);
8593            let mut end = start + 1;
8594            while end < shapes.len()
8595                && end - start < self.shape_batch_limits.max_shapes_per_batch
8596                && supported_blend_mode(shapes[end].1) == blend_mode
8597            {
8598                end += 1;
8599            }
8600
8601            staged_uploads.clear();
8602            let viewport = ViewportUniformParams {
8603                width,
8604                height,
8605                offset: viewport_offset,
8606            };
8607            let Some(prepared_shape) = self.prepare_shapes_batch(
8608                shapes[start..end]
8609                    .iter()
8610                    .map(|(shape, _blend_mode)| shape)
8611                    .filter(|shape| shape_draw_is_visible_in_viewport(shape, viewport, root_scale)),
8612                root_scale,
8613                viewport,
8614                &mut staged_uploads,
8615            ) else {
8616                start = end;
8617                continue;
8618            };
8619
8620            let upload_offset =
8621                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
8622            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
8623
8624            {
8625                let mut render_pass =
8626                    frame_encoder
8627                        .encoder()
8628                        .begin_render_pass(&wgpu::RenderPassDescriptor {
8629                            label: Some("Shadow Source Shape Pass"),
8630                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8631                                view: source_view,
8632                                resolve_target: None,
8633                                depth_slice: None,
8634                                ops: wgpu::Operations {
8635                                    load: *next_load_op,
8636                                    store: wgpu::StoreOp::Store,
8637                                },
8638                            })],
8639                            depth_stencil_attachment: None,
8640                            timestamp_writes: None,
8641                            occlusion_query_set: None,
8642                            multiview_mask: None,
8643                        });
8644                self.draw_prepared_shapes(
8645                    &mut render_pass,
8646                    blend_mode,
8647                    prepared_shape,
8648                    width,
8649                    height,
8650                );
8651            }
8652
8653            pass_count = pass_count.saturating_add(1);
8654            rendered_any = true;
8655            *next_load_op = wgpu::LoadOp::Load;
8656            start = end;
8657        }
8658
8659        self.restore_staged_uploads(staged_uploads);
8660        ShadowSourceRenderOutcome {
8661            rendered_any,
8662            pass_count,
8663        }
8664    }
8665
8666    #[allow(clippy::too_many_arguments)]
8667    fn encode_shape_only_blurred_shadow_draw<C: FrameCommandRecorder>(
8668        &mut self,
8669        frame_encoder: &mut C,
8670        target_view: &wgpu::TextureView,
8671        shadow: &ShadowDraw,
8672        device_bounds: DevicePixelBounds,
8673        pixel_radius: f32,
8674        processing_scissor: Option<(u32, u32, u32, u32)>,
8675        width: u32,
8676        height: u32,
8677        root_scale: f32,
8678    ) -> bool {
8679        let bounds_w = device_bounds.width;
8680        let bounds_h = device_bounds.height;
8681        let viewport_offset = [device_bounds.x, device_bounds.y];
8682        let cache_key =
8683            shape_shadow_surface_cache_key(&shadow.shapes, device_bounds, pixel_radius, root_scale);
8684
8685        if let Some(key) = cache_key {
8686            if let Some(cached) = self.cached_shadow_surface(&key) {
8687                self.frame_stats
8688                    .record_shadow_shape_cache_hit(bounds_w, bounds_h);
8689                let clip_scissor = shadow
8690                    .clip
8691                    .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
8692                let scissor = clip_scissor.or(processing_scissor);
8693                let rounded_mask =
8694                    inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
8695                        mask.rect[0] -= viewport_offset[0];
8696                        mask.rect[1] -= viewport_offset[1];
8697                        mask
8698                    });
8699                let dest_viewport = Some((
8700                    viewport_offset[0],
8701                    viewport_offset[1],
8702                    bounds_w as f32,
8703                    bounds_h as f32,
8704                ));
8705                {
8706                    self.effect_renderer
8707                        .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8708                            frame_encoder,
8709                            &self.device,
8710                            &cached,
8711                            target_view,
8712                            1.0,
8713                            wgpu::LoadOp::Load,
8714                            scissor,
8715                            rounded_mask,
8716                            BlendMode::SrcOver,
8717                            dest_viewport,
8718                            CompositeSampleMode::Nearest,
8719                        );
8720                }
8721                frame_encoder.record_pass();
8722                self.effect_renderer.record_composite_pass();
8723                return true;
8724            }
8725            self.frame_stats
8726                .record_shadow_shape_cache_miss(bounds_w, bounds_h);
8727            self.frame_stats.maybe_print_shadow_shape_cache_miss(
8728                bounds_w,
8729                bounds_h,
8730                key.content_hash,
8731                pixel_radius,
8732                viewport_offset,
8733                shadow.shapes.len(),
8734                shadow.clip,
8735            );
8736        }
8737
8738        let device = self.device.clone();
8739        let source_descriptor =
8740            self.transient_offscreen_descriptor("Shape Shadow Source", bounds_w, bounds_h);
8741        let source_is_cacheable = cache_key.is_some();
8742        let source = if source_is_cacheable {
8743            self.acquire_retained_surface(bounds_w, bounds_h)
8744        } else {
8745            frame_encoder.acquire_transient_offscreen(&device, source_descriptor)
8746        };
8747        let scratch_descriptor =
8748            self.transient_offscreen_descriptor("Shape Shadow Blur Scratch", bounds_w, bounds_h);
8749        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
8750        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
8751        let source_outcome = self.encode_shadow_shape_source_passes(
8752            frame_encoder,
8753            &source.view,
8754            &shadow.shapes,
8755            bounds_w,
8756            bounds_h,
8757            viewport_offset,
8758            root_scale,
8759            &mut next_load_op,
8760        );
8761        frame_encoder.record_passes(source_outcome.pass_count);
8762
8763        if !source_outcome.rendered_any {
8764            frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
8765            if source_is_cacheable {
8766                self.defer_offscreen_release(source);
8767            } else {
8768                frame_encoder.release_transient_offscreen(source_descriptor, source);
8769            }
8770            return true;
8771        }
8772
8773        {
8774            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
8775                frame_encoder,
8776                &device,
8777                &source,
8778                &scratch,
8779                &source.view,
8780                pixel_radius,
8781                pixel_radius,
8782                TileMode::Decal,
8783                None,
8784            );
8785        }
8786        frame_encoder.record_passes(2);
8787
8788        let clip_scissor = shadow
8789            .clip
8790            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
8791        let scissor = clip_scissor.or(processing_scissor);
8792        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
8793            mask.rect[0] -= viewport_offset[0];
8794            mask.rect[1] -= viewport_offset[1];
8795            mask
8796        });
8797        let dest_viewport = Some((
8798            viewport_offset[0],
8799            viewport_offset[1],
8800            bounds_w as f32,
8801            bounds_h as f32,
8802        ));
8803        {
8804            self.effect_renderer
8805                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8806                    frame_encoder,
8807                    &device,
8808                    &source,
8809                    target_view,
8810                    1.0,
8811                    wgpu::LoadOp::Load,
8812                    scissor,
8813                    rounded_mask,
8814                    BlendMode::SrcOver,
8815                    dest_viewport,
8816                    CompositeSampleMode::Nearest,
8817                );
8818        }
8819        frame_encoder.record_pass();
8820
8821        self.effect_renderer.record_blur_pass();
8822        self.effect_renderer.record_composite_pass();
8823        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
8824        if let Some(key) = cache_key {
8825            self.insert_cached_shadow_surface(key, source);
8826        } else {
8827            frame_encoder.release_transient_offscreen(source_descriptor, source);
8828        }
8829        true
8830    }
8831
8832    fn prepare_shapes_batch<'a, I>(
8833        &mut self,
8834        layer_shapes: I,
8835        root_scale: f32,
8836        viewport: ViewportUniformParams,
8837        staged_uploads: &mut StagedBufferUploads,
8838    ) -> Option<PreparedShapeBatch>
8839    where
8840        I: Iterator<Item = &'a DrawShape>,
8841    {
8842        #[cfg(target_arch = "wasm32")]
8843        let _ = staged_uploads;
8844
8845        // Build shape data for this subset. Callers hand in only shapes visible in
8846        // `viewport`: the segment paths culled at collect time, and the layer and
8847        // shadow-source paths filter at the call site. Re-checking here would run
8848        // the same quad math a second time on every shape of every frame.
8849        let shape_refs: Vec<&DrawShape> = layer_shapes
8850            .take(self.shape_batch_limits.max_shapes_per_batch)
8851            .collect();
8852        let shape_count = shape_refs.len();
8853        if shape_count == 0 {
8854            return None;
8855        }
8856
8857        // Per-shape gradient spans as a prefix sum, so every output slot is
8858        // known before conversion starts and the shapes can convert in
8859        // parallel into disjoint sub-slices.
8860        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
8861        let mut total_gradient_stops = 0u32;
8862        gradient_offsets.push(0);
8863        for shape in &shape_refs {
8864            total_gradient_stops += shape_gradient_stop_count(shape) as u32;
8865            gradient_offsets.push(total_gradient_stops);
8866        }
8867
8868        self.scratch_shape_data.clear();
8869        self.scratch_shape_data
8870            .resize(shape_count, ShapeData::zeroed());
8871        self.scratch_gradients.clear();
8872        self.scratch_gradients
8873            .resize(total_gradient_stops as usize, GradientStop::zeroed());
8874
8875        convert_shapes_into_outputs(
8876            &shape_refs,
8877            &gradient_offsets,
8878            root_scale,
8879            &mut self.scratch_shape_data,
8880            &mut self.scratch_gradients,
8881        );
8882
8883        #[cfg(not(target_arch = "wasm32"))]
8884        {
8885            self.shape_buffers.ensure_capacity(
8886                &self.device,
8887                &self.shape_bind_group_layout,
8888                &self.identity_similarity_buffer,
8889                self.dummy_paint_buffer.as_ref(),
8890                shape_count,
8891                self.scratch_gradients.len().max(1),
8892            );
8893            self.stage_viewport_uniforms(staged_uploads, viewport);
8894            staged_uploads.stage(
8895                UploadTarget::ShapeData,
8896                bytemuck::cast_slice(&self.scratch_shape_data),
8897            );
8898            if !self.scratch_gradients.is_empty() {
8899                staged_uploads.stage(
8900                    UploadTarget::ShapeGradient,
8901                    bytemuck::cast_slice(&self.scratch_gradients),
8902                );
8903            }
8904        }
8905
8906        #[cfg(target_arch = "wasm32")]
8907        let shape_slot = {
8908            let slot = self.claim_wasm_shape_batch();
8909            {
8910                let buffers = &mut self.wasm_shape_batches[slot];
8911                buffers.ensure_capacity(
8912                    &self.device,
8913                    &self.shape_bind_group_layout,
8914                    &self.identity_similarity_buffer,
8915                    self.dummy_paint_buffer.as_ref(),
8916                    shape_count,
8917                    self.scratch_gradients.len().max(1),
8918                );
8919            }
8920            let buffers = &self.wasm_shape_batches[slot];
8921            self.write_wasm_buffer(
8922                &buffers.shape_buffer,
8923                bytemuck::cast_slice(&self.scratch_shape_data),
8924            );
8925            if !self.scratch_gradients.is_empty() {
8926                self.write_wasm_buffer(
8927                    &buffers.gradient_buffer,
8928                    bytemuck::cast_slice(&self.scratch_gradients),
8929                );
8930            }
8931            slot
8932        };
8933
8934        #[cfg(target_arch = "wasm32")]
8935        let uniform_slot = self.prepare_wasm_viewport_uniforms(viewport);
8936
8937        Some(PreparedShapeBatch {
8938            vertex_start: 0,
8939            vertex_count: shape_count as u32 * 6,
8940            #[cfg(target_arch = "wasm32")]
8941            shape_slot,
8942            #[cfg(target_arch = "wasm32")]
8943            uniform_slot,
8944        })
8945    }
8946
8947    /// Like [`Self::prepare_shapes_batch`], but converts shapes straight into
8948    /// mapped regions of the frame upload buffer instead of scratch vectors —
8949    /// one CPU pass over the data instead of three (convert, stage, upload).
8950    /// Returns the prepared batch and the upload-buffer base offset to pass
8951    /// to `flush_staged_uploads_at`; the GPU copies are recorded into
8952    /// `staged_uploads` while its byte blob stays empty.
8953    #[cfg(not(target_arch = "wasm32"))]
8954    fn prepare_shapes_batch_direct<'a, I, C: FrameCommandRecorder>(
8955        &mut self,
8956        frame_encoder: &mut C,
8957        layer_shapes: I,
8958        root_scale: f32,
8959        viewport: ViewportUniformParams,
8960        staged_uploads: &mut StagedBufferUploads,
8961    ) -> Option<(PreparedShapeBatch, u64)>
8962    where
8963        I: Iterator<Item = &'a DrawShape>,
8964    {
8965        let shape_refs: Vec<&DrawShape> = layer_shapes
8966            .take(self.shape_batch_limits.max_shapes_per_batch)
8967            .collect();
8968        let shape_count = shape_refs.len();
8969        if shape_count == 0 {
8970            return None;
8971        }
8972
8973        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
8974        let mut total_gradient_stops = 0u32;
8975        gradient_offsets.push(0);
8976        for shape in &shape_refs {
8977            total_gradient_stops += shape_gradient_stop_count(shape) as u32;
8978            gradient_offsets.push(total_gradient_stops);
8979        }
8980
8981        self.shape_buffers.ensure_capacity(
8982            &self.device,
8983            &self.shape_bind_group_layout,
8984            &self.identity_similarity_buffer,
8985            self.dummy_paint_buffer.as_ref(),
8986            shape_count,
8987            (total_gradient_stops as usize).max(1),
8988        );
8989
8990        self.scratch_shape_data.clear();
8991        self.scratch_shape_data
8992            .resize(shape_count, ShapeData::zeroed());
8993        self.scratch_gradients.clear();
8994        self.scratch_gradients
8995            .resize(total_gradient_stops as usize, GradientStop::zeroed());
8996        convert_shapes_into_outputs(
8997            &shape_refs,
8998            &gradient_offsets,
8999            root_scale,
9000            &mut self.scratch_shape_data,
9001            &mut self.scratch_gradients,
9002        );
9003
9004        // Region layout inside the frame upload buffer. Every element type is
9005        // f32/u32-based, so all lengths are multiples of
9006        // `COPY_BUFFER_ALIGNMENT` and back-to-back packing keeps each offset
9007        // copy-aligned. Writing each scratch slice straight into the upload
9008        // buffer skips the intermediate staged-bytes blob (one fewer CPU pass
9009        // over the batch payload).
9010        let uniform_len = std::mem::size_of::<Uniforms>() as u64;
9011        let shape_len = (shape_count * std::mem::size_of::<ShapeData>()) as u64;
9012        let gradient_len = total_gradient_stops as u64 * std::mem::size_of::<GradientStop>() as u64;
9013        let total_len = uniform_len + shape_len + gradient_len;
9014        let upload_base = frame_encoder.allocate_staged_upload_bytes(total_len);
9015        self.ensure_upload_buffer_capacity(upload_base + total_len);
9016
9017        let shape_off = uniform_len;
9018        let gradient_off = shape_off + shape_len;
9019
9020        let uniforms = Self::viewport_uniforms(viewport);
9021        let mut upload_stats = self.frame_graph_executor.upload_buffer(
9022            &self.queue,
9023            &self.upload_buffer,
9024            upload_base,
9025            bytemuck::bytes_of(&uniforms),
9026        );
9027        upload_stats.upload_bytes += self
9028            .frame_graph_executor
9029            .upload_buffer(
9030                &self.queue,
9031                &self.upload_buffer,
9032                upload_base + shape_off,
9033                bytemuck::cast_slice(&self.scratch_shape_data),
9034            )
9035            .upload_bytes;
9036        if !self.scratch_gradients.is_empty() {
9037            upload_stats.upload_bytes += self
9038                .frame_graph_executor
9039                .upload_buffer(
9040                    &self.queue,
9041                    &self.upload_buffer,
9042                    upload_base + gradient_off,
9043                    bytemuck::cast_slice(&self.scratch_gradients),
9044                )
9045                .upload_bytes;
9046        }
9047        self.frame_stats.record_command_stats(upload_stats);
9048
9049        staged_uploads.record_upload_copy(UploadTarget::Uniform, 0, 0, uniform_len);
9050        staged_uploads.record_upload_copy(UploadTarget::ShapeData, shape_off, 0, shape_len);
9051        staged_uploads.record_upload_copy(
9052            UploadTarget::ShapeGradient,
9053            gradient_off,
9054            0,
9055            gradient_len,
9056        );
9057
9058        Some((
9059            PreparedShapeBatch {
9060                vertex_start: 0,
9061                vertex_count: shape_count as u32 * 6,
9062            },
9063            upload_base,
9064        ))
9065    }
9066
9067    /// Whether retained replay batches can exist on this device: they bind
9068    /// unsized buffers, so they ride the storage-buffer batch mode only.
9069    /// Always `false` on wasm, which has no retained replay path — the
9070    /// method exists on both arches so the packet producer has one
9071    /// architecture.
9072    pub(crate) fn replay_supported(&self) -> bool {
9073        // Deliberately not conditioned on free slot ids: an exhausted pool
9074        // only means new captures fail (handled per capture), while flipping
9075        // this bit would retire every live feed slot.
9076        #[cfg(target_arch = "wasm32")]
9077        {
9078            false
9079        }
9080        #[cfg(not(target_arch = "wasm32"))]
9081        {
9082            self.shape_batch_limits.storage
9083        }
9084    }
9085
9086    /// Return the planner-drained ack confirmations buffer (capacity
9087    /// intact) to the store after the producer applied a frame's
9088    /// [`crate::frame_packet::ReplayAck`] — the ack channel's half of the
9089    /// P4b no-allocation contract, closed by the caller now that ack
9090    /// application lives producer-side. No-op on wasm.
9091    pub(crate) fn restore_replay_ack_confirmations(
9092        &mut self,
9093        confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
9094    ) {
9095        #[cfg(not(target_arch = "wasm32"))]
9096        {
9097            self.replay_ack_confirmations = confirmations;
9098        }
9099        #[cfg(target_arch = "wasm32")]
9100        let _ = confirmations;
9101    }
9102
9103    /// Present-side consumption of one frame's [`ReplayFrameOps`]: frees
9104    /// the plan's releases, then honors its capture requests against the
9105    /// scene they were recorded for, answering with a [`ReplayAck`] of
9106    /// (identity, gpu slot) confirmations plus the batch's emptied buffers
9107    /// for recycling. This is the store half of the split — it touches NO
9108    /// planner state: `feed_slots`, confirmation stamping, displaced-slot
9109    /// release, and age eviction all live in the planner
9110    /// (`take_frame_ops`/`apply_ack`).
9111    ///
9112    /// Ordering is what makes slot release safe: a slot the plan releases
9113    /// is never referenced by a retained op of the same frame (misses
9114    /// release before their op would have been pushed, and rebuild frames
9115    /// release at flush start), so freeing it here — before any encoding —
9116    /// cannot orphan a draw.
9117    #[cfg(not(target_arch = "wasm32"))]
9118    fn consume_replay_ops(
9119        &mut self,
9120        mut ops: crate::frame_packet::ReplayFrameOps,
9121        shapes: &[DrawShape],
9122        root_scale: f32,
9123    ) -> (
9124        crate::frame_packet::ReplayAck,
9125        crate::frame_packet::ReplayFrameOps,
9126    ) {
9127        if ops.generation < self.store_feed_generation {
9128            // Fail-closed: ops planned under an OLDER slot universe name
9129            // slots this store does not hold. Drop the batch whole —
9130            // captures unconfirmed self-heal (the planner never serves
9131            // them), and stale releases must not free live ids.
9132            // Synchronously impossible today; structural for the split.
9133            self.replay_generation_drops += 1;
9134            log::warn!(
9135                "[command-feed] dropping replay ops of generation {} against store \
9136                 generation {} ({} captures, {} patches, {} releases; lifetime drops {})",
9137                ops.generation,
9138                self.store_feed_generation,
9139                ops.captures.len(),
9140                ops.color_patches.len(),
9141                ops.releases.len(),
9142                self.replay_generation_drops,
9143            );
9144            ops.captures.clear();
9145            ops.color_patches.clear();
9146            ops.releases.clear();
9147            return (
9148                crate::frame_packet::ReplayAck {
9149                    generation: self.store_feed_generation,
9150                    confirmations: Vec::new(),
9151                },
9152                ops,
9153            );
9154        }
9155        if ops.generation > self.store_feed_generation {
9156            // Adopt forward: a producer-side bump (scale change,
9157            // `retire_feed`) delivers its whole retirement — the releases
9158            // for every retired slot — THROUGH this very batch, so a
9159            // higher generation is the new universe arriving, not a stale
9160            // one. The store follows the producer's authority; it never
9161            // reads the producer's thread-local.
9162            self.store_feed_generation = ops.generation;
9163        }
9164        let generation = ops.generation;
9165        // Queued releases free first, so their buffers are available before
9166        // this frame's captures ask.
9167        for slot in ops.releases.drain(..) {
9168            self.release_replay_slot(slot);
9169        }
9170        // `take` leaves `Vec::new()` behind (no allocation); the render
9171        // loop restores the vec after the planner drains the ack.
9172        let mut confirmations = std::mem::take(&mut self.replay_ack_confirmations);
9173        debug_assert!(confirmations.is_empty());
9174        for capture in ops.captures.drain(..) {
9175            if capture.frame != ops.frame {
9176                // Defensive: a capture that outlived its frame references
9177                // shape indices of a scene that never rendered; honoring it
9178                // against THIS frame's shapes would retain wrong content
9179                // under a confirmed identity. Categorically drop it. Should
9180                // never fire now that ops travel inside the frame's own
9181                // packet.
9182                log::warn!(
9183                    "[command-feed] dropping stale capture for slot {} of {:?} \
9184                     (queued frame {}, ops frame {})",
9185                    capture.key.1,
9186                    capture.key.0,
9187                    capture.frame,
9188                    ops.frame,
9189                );
9190                continue;
9191            }
9192            let end = capture.shape_start + capture.shape_count;
9193            let Some(slice) = shapes.get(capture.shape_start..end) else {
9194                continue;
9195            };
9196            let refs: Vec<&DrawShape> = slice.iter().collect();
9197            let Some(gpu_slot) = self.capture_replay_slot(&refs, root_scale) else {
9198                continue;
9199            };
9200            confirmations.push((capture.key, gpu_slot));
9201        }
9202        // Park the frame's recolor patches for the retained prepare arms
9203        // (`stage_replay_patches`); the vec swapped out is last frame's,
9204        // already drained empty, and returns to the producer with the ack.
9205        // The defensive clear only bites when no prepare arm ran last
9206        // frame (aborted render): those patches targeted a frame that
9207        // never encoded, and their spans re-queue fresh recolors each
9208        // served frame.
9209        self.replay_color_patches.clear();
9210        std::mem::swap(&mut self.replay_color_patches, &mut ops.color_patches);
9211        (
9212            crate::frame_packet::ReplayAck {
9213                generation,
9214                confirmations,
9215            },
9216            ops,
9217        )
9218    }
9219
9220    /// Test/diagnostic view of the store's lifetime count of replay-ops
9221    /// batches dropped whole by the generation check — the consume gate's
9222    /// proof that Surface frames (default plans, generation 0) are never
9223    /// fed to the store.
9224    #[cfg(not(target_arch = "wasm32"))]
9225    pub(crate) fn replay_generation_drops(&self) -> u64 {
9226        self.replay_generation_drops
9227    }
9228
9229    /// Test hook for the message protocol: runs one planner→store→planner
9230    /// replay cycle outside a frame, with the batch stamped
9231    /// `store_feed_generation + generation_skew`, and returns how many
9232    /// captures the store confirmed. A skew that lands BELOW the store's
9233    /// generation manufactures the fail-closed drop; a skew above it
9234    /// exercises adopt-forward. Both are synchronously impossible through
9235    /// the public render path today.
9236    #[cfg(not(target_arch = "wasm32"))]
9237    pub(crate) fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
9238        let generation = self.store_feed_generation.wrapping_add(generation_skew);
9239        let ops = crate::shape_replay::SHAPE_REPLAY
9240            .with(|state| state.borrow_mut().take_frame_ops(generation));
9241        let (ack, recycled) = self.consume_replay_ops(ops, &[], 1.0);
9242        let confirmed = ack.confirmations.len();
9243        self.replay_ack_confirmations = crate::shape_replay::SHAPE_REPLAY
9244            .with(|state| state.borrow_mut().apply_ack(ack, recycled));
9245        confirmed
9246    }
9247
9248    /// Stages every queued replay recolor patch. Feed recolors are always
9249    /// solid, so every patch rewrites the shape's 16-byte record in the
9250    /// slot's paint buffer; the captured `ShapeData` itself is immutable, so
9251    /// a recolored frame uploads colors, not geometry. Runs in the retained
9252    /// prepare arms so the writes land in the same staged-upload flush that
9253    /// carries the frame's transforms; draining is idempotent across arms.
9254    #[cfg(not(target_arch = "wasm32"))]
9255    fn stage_replay_patches(&mut self, staged_uploads: &mut StagedBufferUploads) {
9256        // Capacity-retaining drain: swap the frame's parked patch buffer
9257        // (see `consume_replay_ops`) against the scratch arena instead of
9258        // `mem::take`, so both keep their high-water capacity across
9259        // frames. The scratch is cleared before every return, which
9260        // preserves drain idempotence across the retained prepare arms: a
9261        // later drain in the same frame swaps one empty-with-capacity
9262        // arena for another and stages nothing.
9263        std::mem::swap(
9264            &mut self.replay_color_patches,
9265            &mut self.color_patch_scratch,
9266        );
9267        let total_patches = self.color_patch_scratch.len();
9268        if total_patches == 0 {
9269            self.replay_upload_stats.note_frame(0, 0, 0, 0, 0);
9270            return;
9271        }
9272
9273        // Patches land in the slot's CPU mirror and upload as one contiguous
9274        // span per slot. Uploading each patch individually would record one
9275        // copy command per patch, and MEGA's twinkle field recolors ~1.7k
9276        // dots a frame — that many commands stall a mobile GPU for longer
9277        // than the spans' untouched bytes ever cost.
9278        #[derive(Clone, Copy)]
9279        struct DirtySpan {
9280            paint_min: u32,
9281            paint_max: u32,
9282        }
9283        const CLEAN: DirtySpan = DirtySpan {
9284            paint_min: u32::MAX,
9285            paint_max: 0,
9286        };
9287        let mut dirty: std::collections::HashMap<
9288            u32,
9289            DirtySpan,
9290            cranpose_ui_graphics::FxBuildHasher,
9291        > = std::collections::HashMap::default();
9292
9293        // One bare 16-byte write into the slot's paint mirror per patch.
9294        for patch in &self.color_patch_scratch {
9295            let Some(slot) = self.replay_slots.slots.get_mut(&patch.slot) else {
9296                continue;
9297            };
9298            let Some(paint) = slot.paint_mirror.get_mut(patch.shape_index as usize) else {
9299                continue;
9300            };
9301            *paint = patch.color;
9302            let span = dirty.entry(patch.slot).or_insert(CLEAN);
9303            span.paint_min = span.paint_min.min(patch.shape_index);
9304            span.paint_max = span.paint_max.max(patch.shape_index);
9305        }
9306
9307        let mut uploaded_records = 0u64;
9308        let mut uploaded_bytes = 0u64;
9309        let slots_touched = dirty.len() as u64;
9310        for (slot_id, span) in dirty {
9311            let Some(slot) = self.replay_slots.slots.get(&slot_id) else {
9312                continue;
9313            };
9314            if span.paint_min <= span.paint_max {
9315                let range = span.paint_min as usize..span.paint_max as usize + 1;
9316                uploaded_records += range.len() as u64;
9317                uploaded_bytes += (range.len() * std::mem::size_of::<[f32; 4]>()) as u64;
9318                staged_uploads.stage_at(
9319                    UploadTarget::ReplayPaintData(slot_id),
9320                    range.start as u64 * std::mem::size_of::<[f32; 4]>() as u64,
9321                    bytemuck::cast_slice(&slot.paint_mirror[range]),
9322                );
9323            }
9324        }
9325        // A patched color is one 16-byte vec4; the staged bytes exceed this
9326        // only by the untouched records inside each coalesced span.
9327        let ideal_bytes = total_patches as u64 * 16;
9328        self.replay_upload_stats.note_frame(
9329            total_patches as u64,
9330            slots_touched,
9331            uploaded_records,
9332            uploaded_bytes,
9333            ideal_bytes,
9334        );
9335        if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
9336            log::warn!(
9337                "[replay-upload] frame: {} patches -> {} records / {:.1} KB staged \
9338                 across {} slots (color-only {:.1} KB)",
9339                total_patches,
9340                uploaded_records,
9341                uploaded_bytes as f64 / 1024.0,
9342                slots_touched,
9343                ideal_bytes as f64 / 1024.0,
9344            );
9345        }
9346        self.color_patch_scratch.clear();
9347    }
9348
9349    /// Converts `shape_refs` once and retains the result on the GPU as a
9350    /// replay slot. Returns the slot id the scene's retained draws reference.
9351    #[cfg(not(target_arch = "wasm32"))]
9352    pub(crate) fn capture_replay_slot(
9353        &mut self,
9354        shape_refs: &[&DrawShape],
9355        root_scale: f32,
9356    ) -> Option<u32> {
9357        if !self.shape_batch_limits.storage || shape_refs.is_empty() {
9358            return None;
9359        }
9360        let id = self.replay_slots.free_ids.pop()?;
9361        let shape_count = shape_refs.len();
9362
9363        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
9364        let mut total_gradient_stops = 0u32;
9365        gradient_offsets.push(0);
9366        for shape in shape_refs {
9367            total_gradient_stops += shape_gradient_stop_count(shape) as u32;
9368            gradient_offsets.push(total_gradient_stops);
9369        }
9370
9371        let mut shape_data = vec![ShapeData::zeroed(); shape_count];
9372        let mut gradients = vec![GradientStop::zeroed(); (total_gradient_stops as usize).max(1)];
9373        convert_shapes_into_outputs(
9374            shape_refs,
9375            &gradient_offsets,
9376            root_scale,
9377            &mut shape_data,
9378            &mut gradients,
9379        );
9380
9381        let shape_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9382            label: Some("Replay Shape Buffer"),
9383            size: (std::mem::size_of::<ShapeData>() * shape_count) as u64,
9384            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
9385            mapped_at_creation: true,
9386        });
9387        shape_buffer
9388            .slice(..)
9389            .get_mapped_range_mut()
9390            .copy_from_slice(bytemuck::cast_slice(&shape_data));
9391        shape_buffer.unmap();
9392
9393        let gradient_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9394            label: Some("Replay Gradient Buffer"),
9395            size: (std::mem::size_of::<GradientStop>() * gradients.len()) as u64,
9396            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
9397            mapped_at_creation: true,
9398        });
9399        gradient_buffer
9400            .slice(..)
9401            .get_mapped_range_mut()
9402            .copy_from_slice(bytemuck::cast_slice(&gradients));
9403        gradient_buffer.unmap();
9404
9405        let mesh = if arc_mesh_enabled() {
9406            match build_arc_mesh_vertices(&shape_data) {
9407                Some(build) => {
9408                    let cut = if build.quad_area > 0.0 {
9409                        (1.0 - build.mesh_area / build.quad_area) * 100.0
9410                    } else {
9411                        0.0
9412                    };
9413                    // Always-on warn: `log::info` is invisible on the desktop
9414                    // console, and captures are rare — one line per slot
9415                    // lifetime. The unique-vert/index counts against the
9416                    // six-per-shape quad baseline are the vertex-amplification
9417                    // instrument P1b exists for.
9418                    log::warn!(
9419                        "[arc-mesh] slot {id}: {} arcs meshed ({} segs), {} passthrough; \
9420                         {} unique verts / {} indices (quad path: {} verts); \
9421                         quad_px {:.0} -> mesh_px {:.0} (-{:.1}%)",
9422                        build.meshed_arcs,
9423                        build.meshed_segments,
9424                        build.passthrough,
9425                        build.vertices.len(),
9426                        build.indices.len(),
9427                        shape_count * 6,
9428                        build.quad_area,
9429                        build.mesh_area,
9430                        cut,
9431                    );
9432                    // A slot that meshed nothing gains nothing over the
9433                    // indexless quad path — skip the buffers.
9434                    (build.meshed_arcs > 0).then(|| {
9435                        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9436                            label: Some("Replay Mesh Vertex Buffer"),
9437                            size: (std::mem::size_of::<MeshVertex>() * build.vertices.len()) as u64,
9438                            usage: wgpu::BufferUsages::VERTEX,
9439                            mapped_at_creation: true,
9440                        });
9441                        vertex_buffer
9442                            .slice(..)
9443                            .get_mapped_range_mut()
9444                            .copy_from_slice(bytemuck::cast_slice(&build.vertices));
9445                        vertex_buffer.unmap();
9446                        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9447                            label: Some("Replay Mesh Index Buffer"),
9448                            size: (std::mem::size_of::<u32>() * build.indices.len()) as u64,
9449                            usage: wgpu::BufferUsages::INDEX,
9450                            mapped_at_creation: true,
9451                        });
9452                        index_buffer
9453                            .slice(..)
9454                            .get_mapped_range_mut()
9455                            .copy_from_slice(bytemuck::cast_slice(&build.indices));
9456                        index_buffer.unmap();
9457                        ReplaySlotMesh {
9458                            vertex_buffer,
9459                            index_buffer,
9460                            index_prefix: build.index_prefix,
9461                        }
9462                    })
9463                }
9464                None => {
9465                    log::warn!(
9466                        "[arc-mesh] slot {id}: geometry byte budget overflowed for \
9467                         {shape_count} shapes; whole slot falls back to quad passthrough"
9468                    );
9469                    None
9470                }
9471            }
9472        } else {
9473            None
9474        };
9475
9476        // Seed the mutable paint from the converted colors, so an unpatched
9477        // replay renders bit-identically to the capture frame.
9478        let paint: Vec<[f32; 4]> = shape_data.iter().map(|shape| shape.color).collect();
9479        let paint_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9480            label: Some("Replay Paint Buffer"),
9481            size: (std::mem::size_of::<[f32; 4]>() * shape_count) as u64,
9482            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
9483            mapped_at_creation: true,
9484        });
9485        paint_buffer
9486            .slice(..)
9487            .get_mapped_range_mut()
9488            .copy_from_slice(bytemuck::cast_slice(&paint));
9489        paint_buffer.unmap();
9490
9491        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
9492            label: Some("Replay Shape Bind Group"),
9493            layout: &self.shape_bind_group_layout,
9494            entries: &[
9495                wgpu::BindGroupEntry {
9496                    binding: 0,
9497                    resource: shape_buffer.as_entire_binding(),
9498                },
9499                wgpu::BindGroupEntry {
9500                    binding: 1,
9501                    resource: gradient_buffer.as_entire_binding(),
9502                },
9503                // The transform slot is selected per draw via the dynamic
9504                // offset, so retained draws sharing this capture can each
9505                // move independently.
9506                wgpu::BindGroupEntry {
9507                    binding: 2,
9508                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
9509                        buffer: &self.replay_slots.transform_buffer,
9510                        offset: 0,
9511                        size: Some(
9512                            std::num::NonZeroU64::new(
9513                                std::mem::size_of::<SimilarityTransform>() as u64
9514                            )
9515                            .expect("similarity transform is non-empty"),
9516                        ),
9517                    }),
9518                },
9519                wgpu::BindGroupEntry {
9520                    binding: 3,
9521                    resource: paint_buffer.as_entire_binding(),
9522                },
9523            ],
9524        });
9525
9526        let capture_epoch = self.replay_slots.next_capture_epoch;
9527        self.replay_slots.next_capture_epoch += 1;
9528        self.replay_slots.slots.insert(
9529            id,
9530            ReplaySlot {
9531                paint_buffer,
9532                bind_group,
9533                shape_count: shape_count as u32,
9534                paint_mirror: paint,
9535                mesh,
9536                capture_epoch,
9537            },
9538        );
9539        Some(id)
9540    }
9541
9542    /// Frees a replay slot's GPU resources and returns its id to the pool.
9543    #[cfg(not(target_arch = "wasm32"))]
9544    pub(crate) fn release_replay_slot(&mut self, id: u32) {
9545        if self.replay_slots.slots.remove(&id).is_some() {
9546            self.replay_slots.free_ids.push(id);
9547            // A cached bundle keeps references on the slot buffers it binds.
9548            // The epoch in each key already makes entries for this capture
9549            // unreachable — releases are rare (churn, retire_feed), so drop
9550            // the whole cache and free those references now rather than one
9551            // frame later through eviction.
9552            self.retained_bundle_cache.clear();
9553        }
9554    }
9555
9556    /// Test/diagnostic view of the latched instanced-quad selection: `true`
9557    /// when this renderer's ordinary shape draws ride `vs_shape_instanced`.
9558    #[cfg(not(target_arch = "wasm32"))]
9559    #[doc(hidden)]
9560    pub fn instanced_quads_active(&self) -> bool {
9561        self.instanced_quads.is_some()
9562    }
9563
9564    /// Test/diagnostic view of retained arc meshes: how many live replay
9565    /// slots hold a mesh, out of all live slots.
9566    #[cfg(not(target_arch = "wasm32"))]
9567    #[doc(hidden)]
9568    pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
9569        let meshed = self
9570            .replay_slots
9571            .slots
9572            .values()
9573            .filter(|slot| slot.mesh.is_some())
9574            .count();
9575        (meshed, self.replay_slots.slots.len())
9576    }
9577
9578    /// Draws one retained replay batch — `retained`'s shape range of its
9579    /// slot's capture, under the transform staged for this draw's index (see
9580    /// the retained arms of the segment paths).
9581    #[cfg(not(target_arch = "wasm32"))]
9582    fn draw_retained_batch(
9583        &self,
9584        render_pass: &mut wgpu::RenderPass<'_>,
9585        retained: &RetainedDraw,
9586        retained_index: usize,
9587        width: u32,
9588        height: u32,
9589    ) {
9590        let Some(slot) = self.replay_slots.slots.get(&retained.slot) else {
9591            return;
9592        };
9593        if retained_index as u32 >= MAX_REPLAY_SLOTS {
9594            return;
9595        }
9596        let first = retained.first_shape.min(slot.shape_count);
9597        let last = retained
9598            .first_shape
9599            .saturating_add(retained.shape_count)
9600            .min(slot.shape_count);
9601        if first >= last {
9602            return;
9603        }
9604        self.frame_stats.bump_shapes();
9605        self.frame_stats.add_draw_calls(1);
9606        render_pass.set_scissor_rect(0, 0, width, height);
9607        // A captured mesh replaces the six-per-shape quad expansion with the
9608        // slot's conservative arc mesh — same bind groups, same SrcOver
9609        // blend, one draw per op over the identical shape range, so z order
9610        // is untouched either way. Slots without a mesh draw through the
9611        // latched instanced-quad path when it exists (four vertex executions
9612        // per shape, shape index from the instance index), else the plain
9613        // six-vertex expansion.
9614        let mesh = slot.mesh.as_ref().zip(self.mesh_pipeline.as_ref());
9615        match &mesh {
9616            Some((_, mesh_pipeline)) => render_pass.set_pipeline(mesh_pipeline),
9617            None => match &self.instanced_quads {
9618                Some(instanced) => render_pass.set_pipeline(&instanced.pipeline),
9619                None => render_pass.set_pipeline(&self.pipeline),
9620            },
9621        }
9622        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
9623        render_pass.set_bind_group(
9624            1,
9625            &slot.bind_group,
9626            &[retained_index as u32 * REPLAY_TRANSFORM_STRIDE as u32],
9627        );
9628        match mesh {
9629            Some((mesh, _)) => {
9630                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
9631                render_pass
9632                    .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
9633                render_pass.draw_indexed(
9634                    mesh.index_prefix[first as usize]..mesh.index_prefix[last as usize],
9635                    0,
9636                    0..1,
9637                );
9638            }
9639            None => match &self.instanced_quads {
9640                Some(instanced) => {
9641                    render_pass.set_index_buffer(
9642                        instanced.index_buffer.slice(..),
9643                        wgpu::IndexFormat::Uint16,
9644                    );
9645                    render_pass.draw_indexed(0..6, 0, first..last);
9646                }
9647                None => render_pass.draw(first * 6..last * 6, 0..1),
9648            },
9649        }
9650    }
9651
9652    /// Key of the retained stretch at `item_range`: one op key per resolved
9653    /// retained item, in draw order, carrying exactly the state that decides
9654    /// the commands [`Self::draw_retained_batch`] would encode for it —
9655    /// clamped range, dynamic-offset index, mesh-vs-quad pipeline choice,
9656    /// and the slot's capture epoch (`None` while the slot is absent, when
9657    /// the op draws nothing on the direct path too).
9658    #[cfg(not(target_arch = "wasm32"))]
9659    fn retained_bundle_key(
9660        &self,
9661        ordered_items: &[(usize, SegmentDrawItem)],
9662        retained_draws: &[RetainedDraw],
9663        item_range: Range<usize>,
9664    ) -> RetainedBundleKey {
9665        let mut ops = Vec::with_capacity(item_range.len());
9666        for (_, item) in &ordered_items[item_range] {
9667            let SegmentDrawItem::Retained(index) = item else {
9668                continue;
9669            };
9670            let Some(retained) = retained_draws.get(*index) else {
9671                continue;
9672            };
9673            let slot = self.replay_slots.slots.get(&retained.slot);
9674            let (first, last) = match slot {
9675                Some(slot) => (
9676                    retained.first_shape.min(slot.shape_count),
9677                    retained
9678                        .first_shape
9679                        .saturating_add(retained.shape_count)
9680                        .min(slot.shape_count),
9681                ),
9682                None => (
9683                    retained.first_shape,
9684                    retained.first_shape.saturating_add(retained.shape_count),
9685                ),
9686            };
9687            ops.push(RetainedBundleOpKey {
9688                slot: retained.slot,
9689                capture_epoch: slot.map(|slot| slot.capture_epoch),
9690                first,
9691                last,
9692                retained_index: *index as u32,
9693                has_mesh: slot.is_some_and(|slot| slot.mesh.is_some())
9694                    && self.mesh_pipeline.is_some(),
9695            });
9696        }
9697        RetainedBundleKey { ops }
9698    }
9699
9700    /// Encodes `key`'s stretch into a render bundle: the IDENTICAL command
9701    /// sequence [`Self::draw_retained_batch`] issues on the pass, minus the
9702    /// scissor reset (bundles cannot set scissor; the caller sets the same
9703    /// full-target scissor on the pass before executing). Must only be
9704    /// called with a key built this frame, so every op with an epoch still
9705    /// resolves to its slot.
9706    #[cfg(not(target_arch = "wasm32"))]
9707    fn build_retained_bundle(&self, key: &RetainedBundleKey) -> wgpu::RenderBundle {
9708        let mut encoder =
9709            self.device
9710                .create_render_bundle_encoder(&wgpu::RenderBundleEncoderDescriptor {
9711                    label: Some("Retained Stretch Bundle"),
9712                    // Every fused-pass target — the swapchain, screenshot
9713                    // textures, pooled layer surfaces — is created with the
9714                    // renderer's one surface format.
9715                    color_formats: &[Some(self.surface_format)],
9716                    depth_stencil: None,
9717                    sample_count: 1,
9718                    multiview: None,
9719                });
9720        for op in &key.ops {
9721            if op.capture_epoch.is_none()
9722                || op.retained_index >= MAX_REPLAY_SLOTS
9723                || op.first >= op.last
9724            {
9725                continue;
9726            }
9727            let Some(slot) = self.replay_slots.slots.get(&op.slot) else {
9728                continue;
9729            };
9730            let mesh = slot.mesh.as_ref().zip(self.mesh_pipeline.as_ref());
9731            match &mesh {
9732                Some((_, mesh_pipeline)) => encoder.set_pipeline(mesh_pipeline),
9733                None => match &self.instanced_quads {
9734                    Some(instanced) => encoder.set_pipeline(&instanced.pipeline),
9735                    None => encoder.set_pipeline(&self.pipeline),
9736                },
9737            }
9738            encoder.set_bind_group(0, &self.uniform_bind_group, &[]);
9739            encoder.set_bind_group(
9740                1,
9741                &slot.bind_group,
9742                &[op.retained_index * REPLAY_TRANSFORM_STRIDE as u32],
9743            );
9744            match mesh {
9745                Some((mesh, _)) => {
9746                    encoder.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
9747                    encoder
9748                        .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
9749                    encoder.draw_indexed(
9750                        mesh.index_prefix[op.first as usize]..mesh.index_prefix[op.last as usize],
9751                        0,
9752                        0..1,
9753                    );
9754                }
9755                // The latched selection is a per-renderer constant, so it
9756                // needs no place in `RetainedBundleOpKey` — every cached
9757                // bundle in this renderer's lifetime encodes the same choice
9758                // the direct path makes.
9759                None => match &self.instanced_quads {
9760                    Some(instanced) => {
9761                        encoder.set_index_buffer(
9762                            instanced.index_buffer.slice(..),
9763                            wgpu::IndexFormat::Uint16,
9764                        );
9765                        encoder.draw_indexed(0..6, 0, op.first..op.last);
9766                    }
9767                    None => encoder.draw(op.first * 6..op.last * 6, 0..1),
9768                },
9769            }
9770        }
9771        encoder.finish(&wgpu::RenderBundleDescriptor {
9772            label: Some("Retained Stretch Bundle"),
9773        })
9774    }
9775
9776    /// Draws one maximal consecutive retained stretch through the bundle
9777    /// cache: key the stretch, rebuild on any mismatch (recapture, reorder,
9778    /// range or count change, slot release), then execute the cached bundle.
9779    /// Replays byte-identical commands to the per-op direct path.
9780    /// `stage_replay_patches` and the per-frame transform staging stay in
9781    /// the prepare arms, untouched — bundles bind buffers whose contents are
9782    /// read at execution.
9783    #[cfg(not(target_arch = "wasm32"))]
9784    fn draw_retained_stretch_bundled(
9785        &mut self,
9786        render_pass: &mut wgpu::RenderPass<'_>,
9787        ordered_items: &[(usize, SegmentDrawItem)],
9788        retained_draws: &[RetainedDraw],
9789        item_range: Range<usize>,
9790        width: u32,
9791        height: u32,
9792    ) {
9793        let key = self.retained_bundle_key(ordered_items, retained_draws, item_range);
9794        if !self.retained_bundle_cache.hit(&key) {
9795            let bundle = self.build_retained_bundle(&key);
9796            self.retained_bundle_cache.insert(key.clone(), bundle);
9797        }
9798        // Mirror the direct path's per-op stats for every op the bundle
9799        // draws, so bundling is invisible to the frame counters.
9800        for op in &key.ops {
9801            if op.capture_epoch.is_some()
9802                && op.retained_index < MAX_REPLAY_SLOTS
9803                && op.first < op.last
9804            {
9805                self.frame_stats.bump_shapes();
9806                self.frame_stats.add_draw_calls(1);
9807            }
9808        }
9809        // Bundles inherit the pass scissor: set the same full-target rect
9810        // the direct path sets before every retained draw. Executing the
9811        // bundle then resets pipeline/bind/vertex state, which is harmless —
9812        // every following fused arm re-binds its own.
9813        render_pass.set_scissor_rect(0, 0, width, height);
9814        if let Some(bundle) = self.retained_bundle_cache.get(&key) {
9815            render_pass.execute_bundles(std::iter::once(bundle));
9816        }
9817    }
9818
9819    /// Test/diagnostic view of the retained bundle cache: lifetime
9820    /// (rebuilds, cached executes).
9821    #[cfg(not(target_arch = "wasm32"))]
9822    #[doc(hidden)]
9823    pub fn retained_bundle_stats(&self) -> (u64, u64) {
9824        self.retained_bundle_cache.stats()
9825    }
9826
9827    fn draw_prepared_shapes(
9828        &self,
9829        render_pass: &mut wgpu::RenderPass<'_>,
9830        blend_mode: BlendMode,
9831        batch: PreparedShapeBatch,
9832        width: u32,
9833        height: u32,
9834    ) {
9835        self.frame_stats.bump_shapes();
9836        self.frame_stats.add_draw_calls(1);
9837        render_pass.set_scissor_rect(0, 0, width, height);
9838        #[cfg(not(target_arch = "wasm32"))]
9839        let (uniform_bind_group, shape_buffers) = (&self.uniform_bind_group, &self.shape_buffers);
9840        #[cfg(target_arch = "wasm32")]
9841        let (uniform_bind_group, shape_buffers) = (
9842            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
9843            &self.wasm_shape_batches[batch.shape_slot],
9844        );
9845        // Latched instanced path (storage mode only): one instance per
9846        // shape, four vertices through the static quad index buffer —
9847        // identical triangles, identical bind groups, still one draw call.
9848        // The uniform/WebGL path never latches it and stays on `vs_main`.
9849        #[cfg(not(target_arch = "wasm32"))]
9850        if let Some(instanced) = &self.instanced_quads {
9851            assert!(
9852                batch.vertex_start.is_multiple_of(6) && batch.vertex_count.is_multiple_of(6),
9853                "shape batches are whole shapes: vertex range {}..+{} must be \
9854                 six-aligned to convert to an instance range",
9855                batch.vertex_start,
9856                batch.vertex_count,
9857            );
9858            render_pass.set_pipeline(match blend_mode {
9859                BlendMode::DstOut => &instanced.pipeline_dst_out,
9860                _ => &instanced.pipeline,
9861            });
9862            render_pass.set_bind_group(0, uniform_bind_group, &[]);
9863            // Dynamic offset 0: ordinary batches read the identity
9864            // similarity transform.
9865            render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
9866            let first_shape = batch.vertex_start / 6;
9867            let shape_count = batch.vertex_count / 6;
9868            render_pass
9869                .set_index_buffer(instanced.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
9870            render_pass.draw_indexed(0..6, 0, first_shape..first_shape + shape_count);
9871            return;
9872        }
9873        render_pass.set_pipeline(match blend_mode {
9874            BlendMode::DstOut => &self.pipeline_dst_out,
9875            _ => &self.pipeline,
9876        });
9877        render_pass.set_bind_group(0, uniform_bind_group, &[]);
9878        // Dynamic offset 0: ordinary batches read the identity similarity
9879        // transform.
9880        render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
9881        // Six unindexed vertices per shape; `vs_main` derives the corner from
9882        // `vertex_index` and pulls the quad out of `ShapeData`.
9883        render_pass.draw(
9884            batch.vertex_start..batch.vertex_start + batch.vertex_count,
9885            0..1,
9886        );
9887    }
9888
9889    /// Stage shape buffer writes and record a shape render pass onto the
9890    /// provided encoder. The caller is responsible for submitting.
9891    #[allow(clippy::too_many_arguments)]
9892    fn encode_shapes_pass<'a, I, C: FrameCommandRecorder>(
9893        &mut self,
9894        frame_encoder: &mut C,
9895        target_view: &wgpu::TextureView,
9896        layer_shapes: I,
9897        blend_mode: BlendMode,
9898        width: u32,
9899        height: u32,
9900        root_scale: f32,
9901        load_op: wgpu::LoadOp<wgpu::Color>,
9902        viewport_offset: [f32; 2],
9903    ) where
9904        I: Iterator<Item = &'a DrawShape>,
9905    {
9906        let mut staged_uploads = self.take_staged_uploads();
9907        let viewport = ViewportUniformParams {
9908            width,
9909            height,
9910            offset: viewport_offset,
9911        };
9912        let Some(batch) = self.prepare_shapes_batch(
9913            layer_shapes
9914                .filter(|shape| shape_draw_is_visible_in_viewport(shape, viewport, root_scale)),
9915            root_scale,
9916            viewport,
9917            &mut staged_uploads,
9918        ) else {
9919            self.restore_staged_uploads(staged_uploads);
9920            return;
9921        };
9922        let upload_offset =
9923            frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
9924        self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
9925        self.restore_staged_uploads(staged_uploads);
9926        let mut render_pass =
9927            frame_encoder
9928                .encoder()
9929                .begin_render_pass(&wgpu::RenderPassDescriptor {
9930                    label: Some("Shape Pass"),
9931                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
9932                        view: target_view,
9933                        resolve_target: None,
9934                        depth_slice: None,
9935                        ops: wgpu::Operations {
9936                            load: load_op,
9937                            store: wgpu::StoreOp::Store,
9938                        },
9939                    })],
9940                    depth_stencil_attachment: None,
9941                    timestamp_writes: None,
9942                    occlusion_query_set: None,
9943                    multiview_mask: None,
9944                });
9945        self.draw_prepared_shapes(&mut render_pass, blend_mode, batch, width, height);
9946    }
9947
9948    fn draw_prepared_images(
9949        &mut self,
9950        render_pass: &mut wgpu::RenderPass<'_>,
9951        batch: &PreparedImageBatch,
9952        blend_mode: BlendMode,
9953    ) -> Result<(), String> {
9954        if batch.cmds.is_empty() {
9955            return Ok(());
9956        }
9957        self.frame_stats.bump_images();
9958        self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
9959        render_pass.set_pipeline(match blend_mode {
9960            BlendMode::DstOut => &self.image_pipeline_dst_out,
9961            _ => &self.image_pipeline,
9962        });
9963        #[cfg(not(target_arch = "wasm32"))]
9964        let (uniform_bind_group, vertex_buffer, index_buffer) = (
9965            &self.uniform_bind_group,
9966            &self.image_vertex_buffer,
9967            &self.image_index_buffer,
9968        );
9969        #[cfg(target_arch = "wasm32")]
9970        let (uniform_bind_group, vertex_buffer, index_buffer) = (
9971            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
9972            &self.wasm_image_batches[batch.image_slot].vertex_buffer,
9973            &self.wasm_image_batches[batch.image_slot].index_buffer,
9974        );
9975        render_pass.set_bind_group(0, uniform_bind_group, &[]);
9976        render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
9977        render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
9978
9979        for cmd in &batch.cmds {
9980            let (sx, sy, sw, sh) = cmd.scissor;
9981            render_pass.set_scissor_rect(sx, sy, sw, sh);
9982
9983            let cached = self
9984                .image_texture_cache
9985                .get(&cmd.image_id)
9986                .ok_or_else(|| "image texture missing from cache".to_string())?;
9987            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
9988            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
9989        }
9990        Ok(())
9991    }
9992
9993    fn draw_prepared_glyphs(
9994        &mut self,
9995        render_pass: &mut wgpu::RenderPass<'_>,
9996        batch: &PreparedGlyphBatch,
9997    ) -> Result<(), String> {
9998        if batch.cmds.is_empty() {
9999            return Ok(());
10000        }
10001        #[cfg(not(target_arch = "wasm32"))]
10002        {
10003            self.draw_native_prepared_glyph_cmd_range(
10004                render_pass,
10005                &batch.cmds,
10006                0..batch.cmds.len(),
10007            )?;
10008        }
10009        #[cfg(target_arch = "wasm32")]
10010        {
10011            self.frame_stats.bump_text();
10012            self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
10013            render_pass.set_pipeline(&self.glyph_atlas_pipeline);
10014            let (uniform_bind_group, vertex_buffer, index_buffer) = (
10015                &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
10016                &self.wasm_image_batches[batch.image_slot].vertex_buffer,
10017                &self.wasm_image_batches[batch.image_slot].index_buffer,
10018            );
10019            render_pass.set_bind_group(0, uniform_bind_group, &[]);
10020            render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
10021            render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
10022            render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
10023
10024            for cmd in &batch.cmds {
10025                let (sx, sy, sw, sh) = cmd.scissor;
10026                render_pass.set_scissor_rect(sx, sy, sw, sh);
10027                let GlyphDrawSource::Shared {
10028                    index_start,
10029                    index_count,
10030                } = cmd.source;
10031                render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
10032            }
10033        }
10034        Ok(())
10035    }
10036
10037    #[cfg(not(target_arch = "wasm32"))]
10038    fn draw_native_prepared_image_cmd_range(
10039        &mut self,
10040        render_pass: &mut wgpu::RenderPass<'_>,
10041        cmds: &[ImageDrawCmd],
10042        cmd_range: Range<usize>,
10043        blend_mode: BlendMode,
10044    ) -> Result<(), String> {
10045        let Some(cmds) = cmds.get(cmd_range) else {
10046            return Err("image command range is outside the prepared command buffer".to_string());
10047        };
10048        if cmds.is_empty() {
10049            return Ok(());
10050        }
10051
10052        self.frame_stats.bump_images();
10053        self.frame_stats.add_draw_calls(cmds.len() as u32);
10054        render_pass.set_pipeline(match blend_mode {
10055            BlendMode::DstOut => &self.image_pipeline_dst_out,
10056            _ => &self.image_pipeline,
10057        });
10058        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
10059        render_pass.set_index_buffer(self.image_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
10060        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
10061
10062        for cmd in cmds {
10063            let (sx, sy, sw, sh) = cmd.scissor;
10064            render_pass.set_scissor_rect(sx, sy, sw, sh);
10065
10066            let cached = self
10067                .image_texture_cache
10068                .get(&cmd.image_id)
10069                .ok_or_else(|| "image texture missing from cache".to_string())?;
10070            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
10071            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
10072        }
10073        Ok(())
10074    }
10075
10076    #[cfg(not(target_arch = "wasm32"))]
10077    fn draw_native_prepared_glyph_cmd_range(
10078        &mut self,
10079        render_pass: &mut wgpu::RenderPass<'_>,
10080        cmds: &[GlyphDrawCmd],
10081        cmd_range: Range<usize>,
10082    ) -> Result<(), String> {
10083        let Some(cmds) = cmds.get(cmd_range) else {
10084            return Err("glyph command range is outside the prepared command buffer".to_string());
10085        };
10086        if cmds.is_empty() {
10087            return Ok(());
10088        }
10089
10090        self.frame_stats.bump_text();
10091        self.frame_stats.add_draw_calls(cmds.len() as u32);
10092
10093        let mut shared_buffers_bound = false;
10094        let mut retained_pipeline_bound = false;
10095        for cmd in cmds {
10096            let (sx, sy, sw, sh) = cmd.scissor;
10097            render_pass.set_scissor_rect(sx, sy, sw, sh);
10098            match cmd.source {
10099                GlyphDrawSource::Shared {
10100                    index_start,
10101                    index_count,
10102                } => {
10103                    if retained_pipeline_bound || !shared_buffers_bound {
10104                        render_pass.set_pipeline(&self.glyph_atlas_pipeline);
10105                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
10106                        retained_pipeline_bound = false;
10107                    }
10108                    if !shared_buffers_bound {
10109                        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
10110                        render_pass.set_index_buffer(
10111                            self.image_index_buffer.slice(..),
10112                            wgpu::IndexFormat::Uint32,
10113                        );
10114                        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
10115                        shared_buffers_bound = true;
10116                    }
10117                    render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
10118                }
10119                GlyphDrawSource::Retained {
10120                    cache_key,
10121                    uniform_slot,
10122                } => {
10123                    shared_buffers_bound = false;
10124                    if !retained_pipeline_bound {
10125                        render_pass.set_pipeline(&self.retained_glyph_atlas_pipeline);
10126                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
10127                        retained_pipeline_bound = true;
10128                    }
10129                    let cached = self
10130                        .text_glyph_gpu_run_cache
10131                        .peek(&cache_key)
10132                        .ok_or_else(|| "retained glyph buffer missing from cache".to_string())?;
10133                    let dynamic_offset =
10134                        self.retained_glyph_uniform_dynamic_offset(uniform_slot)?;
10135                    render_pass.set_bind_group(
10136                        0,
10137                        &self.retained_glyph_uniform_bind_group,
10138                        &[dynamic_offset],
10139                    );
10140                    render_pass
10141                        .set_index_buffer(cached.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
10142                    render_pass.set_vertex_buffer(0, cached.vertex_buffer.slice(..));
10143                    render_pass.draw_indexed(0..cached.index_count, 0, 0..1);
10144                }
10145            }
10146        }
10147        Ok(())
10148    }
10149
10150    fn append_image_draw_cmd(
10151        &mut self,
10152        image_draw: &ImageDraw,
10153        viewport: ViewportUniformParams,
10154        root_scale: f32,
10155        image_vertices: &mut Vec<Vertex>,
10156        image_indices: &mut Vec<u32>,
10157        image_cmds: &mut Vec<ImageDrawCmd>,
10158    ) -> Result<(), String> {
10159        let snap_delta = image_draw
10160            .snap_anchor
10161            .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
10162            .unwrap_or_default();
10163        let rect = image_draw.rect.translate(snap_delta.x, snap_delta.y);
10164        if rect.width <= 0.0 || rect.height <= 0.0 || image_draw.alpha <= 0.0 {
10165            return Ok(());
10166        }
10167
10168        let (tint, cpu_filter) = tint_for_image(image_draw.color_filter, image_draw.alpha);
10169        if tint[3] <= 0.0 {
10170            return Ok(());
10171        }
10172
10173        let prepared_image = if let Some(filter) = cpu_filter {
10174            apply_filter_to_bitmap(&image_draw.image, filter)?
10175        } else {
10176            image_draw.image.clone()
10177        };
10178        self.ensure_image_cached(&prepared_image)?;
10179
10180        let mut adjusted_image = ImageDraw {
10181            rect,
10182            local_rect: image_draw.local_rect.translate(snap_delta.x, snap_delta.y),
10183            quad: translate_quad(image_draw.quad, snap_delta),
10184            snap_anchor: image_draw.snap_anchor,
10185            image: image_draw.image.clone(),
10186            alpha: image_draw.alpha,
10187            color_filter: image_draw.color_filter,
10188            sampling: image_draw.sampling,
10189            z_index: image_draw.z_index,
10190            clip: image_draw.clip,
10191            blend_mode: image_draw.blend_mode,
10192            src_rect: image_draw.src_rect,
10193            motion_context_animated: image_draw.motion_context_animated,
10194        };
10195        snap_nearest_image_to_device_pixels(&mut adjusted_image, root_scale);
10196        let Some(scissor) =
10197            scissor_rect_for_image(&adjusted_image, root_scale, viewport.width, viewport.height)
10198        else {
10199            return Ok(());
10200        };
10201
10202        let Some(uv_rect) = image_uv_rect(&image_draw.image, image_draw.src_rect) else {
10203            return Ok(());
10204        };
10205        let device_quad =
10206            nearest_image_device_quad(&adjusted_image, root_scale).unwrap_or_else(|| {
10207                if adjusted_image.snap_anchor.is_some() {
10208                    canonicalized_scaled_quad(adjusted_image.quad, root_scale)
10209                } else {
10210                    scaled_quad(adjusted_image.quad, root_scale)
10211                }
10212            });
10213
10214        let base_vertex = image_vertices.len() as u32;
10215        let index_start = image_indices.len() as u32;
10216        image_indices.extend_from_slice(&[
10217            base_vertex,
10218            base_vertex + 1,
10219            base_vertex + 2,
10220            base_vertex + 2,
10221            base_vertex + 1,
10222            base_vertex + 3,
10223        ]);
10224        image_vertices.extend_from_slice(&[
10225            Vertex {
10226                position: device_quad[0],
10227                color: tint,
10228                uv: [uv_rect.min[0], uv_rect.min[1]],
10229                uv_bounds: uv_rect.sample_bounds,
10230            },
10231            Vertex {
10232                position: device_quad[1],
10233                color: tint,
10234                uv: [uv_rect.max[0], uv_rect.min[1]],
10235                uv_bounds: uv_rect.sample_bounds,
10236            },
10237            Vertex {
10238                position: device_quad[2],
10239                color: tint,
10240                uv: [uv_rect.min[0], uv_rect.max[1]],
10241                uv_bounds: uv_rect.sample_bounds,
10242            },
10243            Vertex {
10244                position: device_quad[3],
10245                color: tint,
10246                uv: [uv_rect.max[0], uv_rect.max[1]],
10247                uv_bounds: uv_rect.sample_bounds,
10248            },
10249        ]);
10250
10251        image_cmds.push(ImageDrawCmd {
10252            index_start,
10253            scissor,
10254            image_id: prepared_image.id(),
10255            sampling: image_draw.sampling,
10256        });
10257        Ok(())
10258    }
10259
10260    #[cfg(not(target_arch = "wasm32"))]
10261    fn stage_native_image_buffers(
10262        &mut self,
10263        staged_uploads: &mut StagedBufferUploads,
10264        viewport: ViewportUniformParams,
10265        image_vertices: &[Vertex],
10266        image_indices: &[u32],
10267    ) {
10268        if image_indices.is_empty() {
10269            return;
10270        }
10271
10272        self.stage_viewport_uniforms(staged_uploads, viewport);
10273        // Grow to a power of two, as the shape batch and frame upload buffers
10274        // do. Sizing these to the exact byte count instead means one more glyph
10275        // quad than the last frame destroys and recreates both buffers, and a
10276        // caption that grows a character at a time does it on every frame.
10277        let needed_bytes = std::mem::size_of_val(image_vertices) as u64;
10278        if needed_bytes > self.image_vertex_buffer.size() {
10279            self.image_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10280                label: Some("Image Vertex Buffer"),
10281                size: needed_bytes.next_power_of_two(),
10282                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
10283                mapped_at_creation: false,
10284            });
10285        }
10286        let needed_index_bytes = std::mem::size_of_val(image_indices) as u64;
10287        if needed_index_bytes > self.image_index_buffer.size() {
10288            self.image_index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10289                label: Some("Image Index Buffer"),
10290                size: needed_index_bytes.next_power_of_two(),
10291                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
10292                mapped_at_creation: false,
10293            });
10294        }
10295
10296        staged_uploads.stage(
10297            UploadTarget::ImageVertex,
10298            bytemuck::cast_slice(image_vertices),
10299        );
10300        staged_uploads.stage(
10301            UploadTarget::ImageIndex,
10302            bytemuck::cast_slice(image_indices),
10303        );
10304    }
10305
10306    /// Prepare image vertices, indices, ensure caching, and write to GPU buffers.
10307    /// Returns the draw commands needed by `encode_images_pass`.
10308    fn prepare_image_draw_cmds<'a, I>(
10309        &mut self,
10310        layer_images: I,
10311        viewport: ViewportUniformParams,
10312        root_scale: f32,
10313        staged_uploads: &mut StagedBufferUploads,
10314    ) -> Result<PreparedImageBatch, String>
10315    where
10316        I: Iterator<Item = &'a ImageDraw>,
10317    {
10318        #[cfg(target_arch = "wasm32")]
10319        let _ = staged_uploads;
10320
10321        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
10322        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
10323        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
10324        image_vertices.clear();
10325        image_indices.clear();
10326        image_cmds.clear();
10327
10328        for image_draw in layer_images {
10329            self.append_image_draw_cmd(
10330                image_draw,
10331                viewport,
10332                root_scale,
10333                &mut image_vertices,
10334                &mut image_indices,
10335                &mut image_cmds,
10336            )?;
10337        }
10338
10339        #[cfg(not(target_arch = "wasm32"))]
10340        if !image_cmds.is_empty() {
10341            self.stage_native_image_buffers(
10342                staged_uploads,
10343                viewport,
10344                &image_vertices,
10345                &image_indices,
10346            );
10347        }
10348
10349        #[cfg(target_arch = "wasm32")]
10350        let image_slot = if image_cmds.is_empty() {
10351            0
10352        } else {
10353            let slot = self.claim_wasm_image_batch();
10354            {
10355                let buffers = &mut self.wasm_image_batches[slot];
10356                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
10357            }
10358            let buffers = &self.wasm_image_batches[slot];
10359            self.write_wasm_buffer(
10360                &buffers.vertex_buffer,
10361                bytemuck::cast_slice(&image_vertices),
10362            );
10363            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
10364            slot
10365        };
10366
10367        #[cfg(target_arch = "wasm32")]
10368        let uniform_slot = if image_cmds.is_empty() {
10369            0
10370        } else {
10371            self.prepare_wasm_viewport_uniforms(viewport)
10372        };
10373
10374        self.scratch_image_vertices = image_vertices;
10375        self.scratch_image_indices = image_indices;
10376        Ok(PreparedImageBatch {
10377            cmds: image_cmds,
10378            #[cfg(target_arch = "wasm32")]
10379            image_slot,
10380            #[cfg(target_arch = "wasm32")]
10381            uniform_slot,
10382        })
10383    }
10384
10385    fn glyph_atlas_entry_for(
10386        &mut self,
10387        glyph: &SoftwareGlyphAtlasGlyph,
10388    ) -> Result<GlyphAtlasEntry, String> {
10389        if let Some(entry) = self.text_glyph_atlas.upload_glyph(
10390            glyph.key,
10391            glyph,
10392            &self.queue,
10393            &mut self.frame_graph_executor,
10394            &mut self.frame_stats,
10395        ) {
10396            return Ok(entry);
10397        }
10398
10399        self.text_glyph_atlas.reset(
10400            &self.device,
10401            &self.image_bind_group_layout,
10402            &self.image_nearest_sampler,
10403        );
10404        Err("text glyph atlas filled and was reset".to_string())
10405    }
10406
10407    fn glyph_atlas_entry_for_cached(
10408        &mut self,
10409        glyph: &SoftwareGlyphAtlasPlacement,
10410    ) -> Option<GlyphAtlasEntry> {
10411        let entry = self.text_glyph_atlas.entry(&glyph.key)?;
10412        self.frame_stats.record_text_glyph_atlas_hit();
10413        Some(entry)
10414    }
10415
10416    fn glyph_atlas_entry_for_placement(
10417        &mut self,
10418        glyph: &SoftwareGlyphAtlasPlacement,
10419    ) -> Result<GlyphAtlasEntry, String> {
10420        if let Some(entry) = self.glyph_atlas_entry_for_cached(glyph) {
10421            return Ok(entry);
10422        }
10423
10424        let Some(upload_glyph) = self.text_glyph_mask_cache.atlas_glyph_for_placement(glyph) else {
10425            return Err("text glyph placement has no retained raster mask".to_string());
10426        };
10427        self.glyph_atlas_entry_for(&upload_glyph)
10428    }
10429
10430    fn prepare_text_glyph_quads(
10431        &mut self,
10432        run_key: TextGlyphRunCacheKey,
10433        atlas_generation: u64,
10434        cached_glyph_run: Option<&[SoftwareGlyphAtlasPlacement]>,
10435        collected_run: &[SoftwareGlyphAtlasRunGlyph],
10436        generated_quads: &mut Vec<CachedTextGlyphQuad>,
10437    ) -> Result<Rc<[CachedTextGlyphQuad]>, String> {
10438        generated_quads.clear();
10439        if let Some(glyph_run) = cached_glyph_run {
10440            for glyph in glyph_run {
10441                if glyph.width == 0 || glyph.height == 0 || glyph.color.3 <= 0.0 {
10442                    continue;
10443                }
10444                let entry = self.glyph_atlas_entry_for_placement(glyph)?;
10445                // Read the size after the entry is in hand: the only path that
10446                // resizes the atlas is the overflow reset, which returns `Err`
10447                // above, so `entry` is always normalised against the atlas it
10448                // was placed in.
10449                generated_quads.push(cached_text_glyph_quad(
10450                    glyph,
10451                    entry,
10452                    self.text_glyph_atlas.size(),
10453                ));
10454            }
10455        } else {
10456            for run_glyph in collected_run {
10457                let placement = run_glyph.placement();
10458                if placement.width == 0 || placement.height == 0 || placement.color.3 <= 0.0 {
10459                    continue;
10460                }
10461                let entry = match run_glyph {
10462                    SoftwareGlyphAtlasRunGlyph::Cached(placement) => {
10463                        self.glyph_atlas_entry_for_placement(placement)?
10464                    }
10465                    SoftwareGlyphAtlasRunGlyph::New(glyph) => self.glyph_atlas_entry_for(glyph)?,
10466                };
10467                generated_quads.push(cached_text_glyph_quad(
10468                    &placement,
10469                    entry,
10470                    self.text_glyph_atlas.size(),
10471                ));
10472            }
10473        }
10474
10475        let quads: Rc<[CachedTextGlyphQuad]> = Rc::from(generated_quads.clone().into_boxed_slice());
10476        if let Some(cached) = self.text_glyph_run_cache.get_mut(&run_key) {
10477            cached.quads = Some(Rc::clone(&quads));
10478            cached.atlas_generation = atlas_generation;
10479        }
10480        Ok(quads)
10481    }
10482
10483    #[allow(clippy::too_many_arguments)]
10484    fn append_text_glyph_quad_run(
10485        &mut self,
10486        source_raster_rect: Rect,
10487        quads: &[CachedTextGlyphQuad],
10488        clip: Option<Rect>,
10489        viewport: ViewportUniformParams,
10490        root_scale: f32,
10491        image_vertices: &mut Vec<Vertex>,
10492        image_indices: &mut Vec<u32>,
10493        record_cached_hits: bool,
10494    ) -> usize {
10495        let mut appended = 0usize;
10496        for quad in quads {
10497            if !cached_text_glyph_quad_is_visible_in_viewport(
10498                source_raster_rect,
10499                quad,
10500                clip,
10501                viewport,
10502                root_scale,
10503            ) {
10504                continue;
10505            }
10506            if append_cached_text_glyph_quad(
10507                source_raster_rect,
10508                quad,
10509                image_vertices,
10510                image_indices,
10511            ) {
10512                if record_cached_hits {
10513                    self.frame_stats.record_text_glyph_atlas_hit();
10514                }
10515                appended = appended.saturating_add(1);
10516            }
10517        }
10518        appended
10519    }
10520
10521    #[cfg(not(target_arch = "wasm32"))]
10522    fn retained_glyph_viewport(
10523        viewport: ViewportUniformParams,
10524        source_raster_rect: Rect,
10525    ) -> ViewportUniformParams {
10526        ViewportUniformParams {
10527            width: viewport.width,
10528            height: viewport.height,
10529            offset: [
10530                viewport.offset[0] - source_raster_rect.x,
10531                viewport.offset[1] - source_raster_rect.y,
10532            ],
10533        }
10534    }
10535
10536    #[cfg(not(target_arch = "wasm32"))]
10537    fn retained_text_glyph_run_ready(&mut self, cache_key: TextGlyphRunCacheKey) -> bool {
10538        let atlas_generation = self.text_glyph_atlas.generation();
10539        self.text_glyph_gpu_run_cache
10540            .peek(&cache_key)
10541            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
10542    }
10543
10544    #[cfg(not(target_arch = "wasm32"))]
10545    #[allow(clippy::too_many_arguments)]
10546    fn emit_retained_text_glyph_run_if_ready(
10547        &mut self,
10548        cache_key: TextGlyphRunCacheKey,
10549        quads: &[CachedTextGlyphQuad],
10550        clip: Option<Rect>,
10551        viewport: ViewportUniformParams,
10552        source_raster_rect: Rect,
10553        scissor: (u32, u32, u32, u32),
10554        staged_uploads: &mut StagedBufferUploads,
10555        glyph_cmds: &mut Vec<GlyphDrawCmd>,
10556    ) -> bool {
10557        if !should_use_retained_text_glyph_run(quads.len(), clip) {
10558            return false;
10559        }
10560        if !self.retained_text_glyph_run_ready(cache_key)
10561            && !self.ensure_retained_text_glyph_run(cache_key, quads)
10562        {
10563            return false;
10564        }
10565
10566        let uniform_slot = self.stage_retained_glyph_viewport_uniforms(
10567            staged_uploads,
10568            Self::retained_glyph_viewport(viewport, source_raster_rect),
10569        );
10570        glyph_cmds.push(GlyphDrawCmd::retained(cache_key, uniform_slot, scissor));
10571        true
10572    }
10573
10574    #[cfg(not(target_arch = "wasm32"))]
10575    fn ensure_retained_text_glyph_run(
10576        &mut self,
10577        cache_key: TextGlyphRunCacheKey,
10578        quads: &[CachedTextGlyphQuad],
10579    ) -> bool {
10580        let atlas_generation = self.text_glyph_atlas.generation();
10581        if self
10582            .text_glyph_gpu_run_cache
10583            .peek(&cache_key)
10584            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
10585        {
10586            return true;
10587        }
10588
10589        let mut vertices = Vec::with_capacity(quads.len().saturating_mul(4));
10590        let mut indices = Vec::with_capacity(quads.len().saturating_mul(6));
10591        let origin = Rect {
10592            x: 0.0,
10593            y: 0.0,
10594            width: 0.0,
10595            height: 0.0,
10596        };
10597        for quad in quads {
10598            append_cached_text_glyph_quad(origin, quad, &mut vertices, &mut indices);
10599        }
10600        if indices.is_empty() {
10601            return false;
10602        }
10603
10604        let vertex_bytes = bytemuck::cast_slice(&vertices);
10605        let index_bytes = bytemuck::cast_slice(&indices);
10606        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10607            label: Some("Retained Text Glyph Vertex Buffer"),
10608            size: vertex_bytes.len() as u64,
10609            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
10610            mapped_at_creation: false,
10611        });
10612        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10613            label: Some("Retained Text Glyph Index Buffer"),
10614            size: index_bytes.len() as u64,
10615            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
10616            mapped_at_creation: false,
10617        });
10618        let vertex_upload =
10619            self.frame_graph_executor
10620                .upload_buffer(&self.queue, &vertex_buffer, 0, vertex_bytes);
10621        self.frame_stats.record_command_stats(vertex_upload);
10622        let index_upload =
10623            self.frame_graph_executor
10624                .upload_buffer(&self.queue, &index_buffer, 0, index_bytes);
10625        self.frame_stats.record_command_stats(index_upload);
10626
10627        self.text_glyph_gpu_run_cache.put(
10628            cache_key,
10629            CachedGpuTextGlyphRun {
10630                vertex_buffer,
10631                index_buffer,
10632                index_count: indices.len() as u32,
10633                atlas_generation,
10634            },
10635        );
10636        true
10637    }
10638
10639    #[allow(clippy::too_many_arguments)]
10640    fn append_text_glyph_draws<'a, I>(
10641        &mut self,
10642        layer_texts: I,
10643        viewport: ViewportUniformParams,
10644        root_scale: f32,
10645        allow_offscreen_prewarm: bool,
10646        staged_uploads: &mut StagedBufferUploads,
10647        image_vertices: &mut Vec<Vertex>,
10648        image_indices: &mut Vec<u32>,
10649        glyph_cmds: &mut Vec<GlyphDrawCmd>,
10650    ) -> Result<bool, String>
10651    where
10652        I: IntoIterator<Item = &'a TextDraw>,
10653    {
10654        let append_start = Instant::now();
10655        let initial_vertex_len = image_vertices.len();
10656        let initial_index_len = image_indices.len();
10657        let initial_cmd_len = glyph_cmds.len();
10658        let initial_staged_bytes_len = staged_uploads.bytes.len();
10659        let initial_staged_copies_len = staged_uploads.copies.len();
10660        let mut collected_run = std::mem::take(&mut self.scratch_text_glyph_run);
10661        let mut collected_placements = std::mem::take(&mut self.scratch_text_glyph_placements);
10662        let mut generated_quads = std::mem::take(&mut self.scratch_text_glyph_quads);
10663        generated_quads.clear();
10664        let mut visited = 0usize;
10665        let mut emitted_glyphs = 0usize;
10666        let mut prewarmed_glyphs = 0usize;
10667        let mut run_hits = 0usize;
10668        let mut run_misses = 0usize;
10669
10670        for text_draw in layer_texts {
10671            visited = visited.saturating_add(1);
10672            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
10673                self.text_raster_geometry(text_draw, root_scale)
10674            else {
10675                continue;
10676            };
10677            if !static_text_motion {
10678                image_vertices.truncate(initial_vertex_len);
10679                image_indices.truncate(initial_index_len);
10680                glyph_cmds.truncate(initial_cmd_len);
10681                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
10682                self.scratch_text_glyph_run = collected_run;
10683                self.scratch_text_glyph_placements = collected_placements;
10684                self.scratch_text_glyph_quads = generated_quads;
10685                return Ok(false);
10686            }
10687            let is_visible =
10688                text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale);
10689            let draw_action = text_glyph_draw_action(
10690                is_visible,
10691                text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale),
10692                allow_offscreen_prewarm,
10693            );
10694            if draw_action == TextGlyphDrawAction::Skip {
10695                continue;
10696            }
10697
10698            let raster_source = text_glyph_raster_source(text_draw, raster_rect);
10699            let source_draw = raster_source.draw.as_ref();
10700            let source_raster_rect = raster_source.raster_rect;
10701
10702            let run_key = Self::text_glyph_run_cache_key(
10703                source_draw,
10704                source_raster_rect,
10705                text_scale,
10706                static_text_motion,
10707            );
10708            let atlas_generation = self.text_glyph_atlas.generation();
10709            let mut cached_quad_run = None;
10710            let mut miss_collect_ms = None;
10711            let mut miss_cached_glyphs = 0usize;
10712            let mut miss_new_glyphs = 0usize;
10713            let cached_glyph_run = if let Some(cached) = self.text_glyph_run_cache.get(&run_key) {
10714                run_hits = run_hits.saturating_add(1);
10715                if cached.atlas_generation == atlas_generation {
10716                    cached_quad_run = cached.quads.as_ref().map(Rc::clone);
10717                }
10718                Some(Rc::clone(&cached.glyphs))
10719            } else {
10720                run_misses = run_misses.saturating_add(1);
10721                collected_run.clear();
10722                let collect_start = Instant::now();
10723                let collect_result = collect_solid_text_atlas_run(
10724                    source_draw.text.as_ref(),
10725                    source_raster_rect,
10726                    &source_draw.text_style,
10727                    source_draw.color,
10728                    source_draw.font_size,
10729                    text_scale,
10730                    &self.text_fonts,
10731                    &mut self.text_glyph_mask_cache,
10732                    &mut collected_run,
10733                );
10734                miss_collect_ms = Some(instant_ms(collect_start, Instant::now()));
10735                if collect_result.is_none() {
10736                    if text_atlas_fallback_diag_enabled() {
10737                        let preview: String = source_draw.text.text.chars().take(96).collect();
10738                        log::warn!(
10739                            "[text-atlas-fallback] node={:?} visible={} prewarm={} spans={} links={} text_len={} preview={:?} span_style={:?} paragraph_style={:?}",
10740                            source_draw.node_id,
10741                            is_visible,
10742                            draw_action == TextGlyphDrawAction::PrewarmOffscreen,
10743                            source_draw.text.span_styles.len(),
10744                            source_draw.text.links.len(),
10745                            source_draw.text.text.len(),
10746                            preview,
10747                            source_draw.text_style.span_style,
10748                            source_draw.text_style.paragraph_style,
10749                        );
10750                    }
10751                    if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
10752                        continue;
10753                    }
10754                    image_vertices.truncate(initial_vertex_len);
10755                    image_indices.truncate(initial_index_len);
10756                    glyph_cmds.truncate(initial_cmd_len);
10757                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
10758                    self.scratch_text_glyph_run = collected_run;
10759                    self.scratch_text_glyph_placements = collected_placements;
10760                    self.scratch_text_glyph_quads = generated_quads;
10761                    return Ok(false);
10762                }
10763                if text_glyph_run_diag_enabled() {
10764                    miss_cached_glyphs = collected_run
10765                        .iter()
10766                        .filter(|glyph| matches!(glyph, SoftwareGlyphAtlasRunGlyph::Cached(_)))
10767                        .count();
10768                    miss_new_glyphs = collected_run.len().saturating_sub(miss_cached_glyphs);
10769                }
10770                collected_placements.clear();
10771                collected_placements.extend(
10772                    collected_run
10773                        .iter()
10774                        .map(SoftwareGlyphAtlasRunGlyph::placement),
10775                );
10776                let glyphs: Rc<[SoftwareGlyphAtlasPlacement]> =
10777                    Rc::from(collected_placements.clone().into_boxed_slice());
10778                self.text_glyph_run_cache.put(
10779                    run_key,
10780                    CachedTextGlyphRun {
10781                        glyphs,
10782                        quads: None,
10783                        atlas_generation: 0,
10784                    },
10785                );
10786                None
10787            };
10788
10789            if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
10790                let prewarm_quads = if let Some(quad_run) = cached_quad_run {
10791                    quad_run
10792                } else {
10793                    let prepare_start = Instant::now();
10794                    match self.prepare_text_glyph_quads(
10795                        run_key,
10796                        atlas_generation,
10797                        cached_glyph_run.as_deref(),
10798                        &collected_run,
10799                        &mut generated_quads,
10800                    ) {
10801                        Ok(quads) => {
10802                            if let Some(collect_ms) = miss_collect_ms {
10803                                if text_glyph_run_diag_enabled() {
10804                                    log::warn!(
10805                                        "[text-glyph-run-diag] visible=false glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
10806                                        quads.len(),
10807                                        miss_cached_glyphs,
10808                                        miss_new_glyphs,
10809                                        collect_ms,
10810                                        instant_ms(prepare_start, Instant::now()),
10811                                    );
10812                                }
10813                            }
10814                            quads
10815                        }
10816                        Err(_) => continue,
10817                    }
10818                };
10819                #[cfg(not(target_arch = "wasm32"))]
10820                if should_use_retained_text_glyph_run(prewarm_quads.len(), source_draw.clip) {
10821                    self.ensure_retained_text_glyph_run(run_key, prewarm_quads.as_ref());
10822                }
10823                prewarmed_glyphs = prewarmed_glyphs.saturating_add(prewarm_quads.len());
10824                continue;
10825            }
10826
10827            let draw_rect = Rect {
10828                x: source_raster_rect.x / root_scale,
10829                y: source_raster_rect.y / root_scale,
10830                width: source_raster_rect.width / root_scale,
10831                height: source_raster_rect.height / root_scale,
10832            };
10833            let Some(scissor) = scissor_rect_for_layer(
10834                draw_rect,
10835                source_draw.clip,
10836                root_scale,
10837                viewport.width,
10838                viewport.height,
10839            ) else {
10840                continue;
10841            };
10842
10843            #[cfg(not(target_arch = "wasm32"))]
10844            if let Some(quad_run) = cached_quad_run.as_ref() {
10845                if should_use_retained_text_glyph_run(quad_run.len(), source_draw.clip)
10846                    && self.emit_retained_text_glyph_run_if_ready(
10847                        run_key,
10848                        quad_run.as_ref(),
10849                        source_draw.clip,
10850                        viewport,
10851                        source_raster_rect,
10852                        scissor,
10853                        staged_uploads,
10854                        glyph_cmds,
10855                    )
10856                {
10857                    emitted_glyphs = emitted_glyphs.saturating_add(quad_run.len());
10858                    continue;
10859                }
10860            }
10861
10862            let index_start = image_indices.len() as u32;
10863            if let Some(quad_run) = cached_quad_run {
10864                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
10865                    source_raster_rect,
10866                    quad_run.as_ref(),
10867                    source_draw.clip,
10868                    viewport,
10869                    root_scale,
10870                    image_vertices,
10871                    image_indices,
10872                    true,
10873                ));
10874            } else {
10875                let prepare_start = Instant::now();
10876                let Ok(quad_run) = self.prepare_text_glyph_quads(
10877                    run_key,
10878                    atlas_generation,
10879                    cached_glyph_run.as_deref(),
10880                    &collected_run,
10881                    &mut generated_quads,
10882                ) else {
10883                    image_vertices.truncate(initial_vertex_len);
10884                    image_indices.truncate(initial_index_len);
10885                    glyph_cmds.truncate(initial_cmd_len);
10886                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
10887                    self.scratch_text_glyph_run = collected_run;
10888                    self.scratch_text_glyph_placements = collected_placements;
10889                    self.scratch_text_glyph_quads = generated_quads;
10890                    return Ok(false);
10891                };
10892                if let Some(collect_ms) = miss_collect_ms {
10893                    if text_glyph_run_diag_enabled() {
10894                        log::warn!(
10895                            "[text-glyph-run-diag] visible=true glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
10896                            quad_run.len(),
10897                            miss_cached_glyphs,
10898                            miss_new_glyphs,
10899                            collect_ms,
10900                            instant_ms(prepare_start, Instant::now()),
10901                        );
10902                    }
10903                }
10904                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
10905                    source_raster_rect,
10906                    quad_run.as_ref(),
10907                    source_draw.clip,
10908                    viewport,
10909                    root_scale,
10910                    image_vertices,
10911                    image_indices,
10912                    false,
10913                ));
10914            }
10915            let index_count = image_indices.len() as u32 - index_start;
10916            if index_count > 0 {
10917                glyph_cmds.push(GlyphDrawCmd::shared(index_start, index_count, scissor));
10918            }
10919        }
10920
10921        self.scratch_text_glyph_run = collected_run;
10922        self.scratch_text_glyph_placements = collected_placements;
10923        self.scratch_text_glyph_quads = generated_quads;
10924        let append_end = Instant::now();
10925        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
10926            log::warn!(
10927                "[wgpu-render-stage:text-glyph-atlas] total_ms={total_ms:.2} visited={} cmds={} glyphs={} prewarmed={} run_hits={} run_misses={}",
10928                visited,
10929                glyph_cmds.len().saturating_sub(initial_cmd_len),
10930                emitted_glyphs,
10931                prewarmed_glyphs,
10932                run_hits,
10933                run_misses,
10934            );
10935        }
10936        Ok(true)
10937    }
10938
10939    #[cfg(not(target_arch = "wasm32"))]
10940    fn text_glyph_prewarm_decision(
10941        &self,
10942        text_draw: &TextDraw,
10943        viewport: ViewportUniformParams,
10944        root_scale: f32,
10945    ) -> TextGlyphPrewarmDecision {
10946        let Some((logical_rect, _, clip, _, static_text_motion)) =
10947            self.text_raster_geometry(text_draw, root_scale)
10948        else {
10949            return TextGlyphPrewarmDecision::MissingGeometry;
10950        };
10951        if !static_text_motion {
10952            return TextGlyphPrewarmDecision::DynamicMotion;
10953        }
10954        if text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
10955            return TextGlyphPrewarmDecision::Visible;
10956        }
10957        if text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale) {
10958            TextGlyphPrewarmDecision::Candidate
10959        } else {
10960            TextGlyphPrewarmDecision::OutsidePrewarmWindow
10961        }
10962    }
10963
10964    #[cfg(not(target_arch = "wasm32"))]
10965    #[allow(clippy::too_many_arguments)]
10966    fn prewarm_offscreen_text_glyph_draws_in_chunk(
10967        &mut self,
10968        ordered_items: &[(usize, SegmentDrawItem)],
10969        texts: &[TextDraw],
10970        chunk: &SegmentDrawChunkPlan,
10971        viewport: ViewportUniformParams,
10972        root_scale: f32,
10973        staged_uploads: &mut StagedBufferUploads,
10974        image_vertices: &mut Vec<Vertex>,
10975        image_indices: &mut Vec<u32>,
10976        glyph_cmds: &mut Vec<GlyphDrawCmd>,
10977    ) -> Result<(), String> {
10978        let prewarm_start = Instant::now();
10979        let diag_enabled = cranpose_core::env_flag!("CRANPOSE_TEXT_PREWARM_DIAG");
10980        let mut text_items = 0usize;
10981        let mut candidates = 0usize;
10982        let mut missing_geometry = 0usize;
10983        let mut dynamic_motion = 0usize;
10984        let mut visible = 0usize;
10985        let mut outside = 0usize;
10986        let mut already_prepared = 0usize;
10987        let mut admitted_candidates = 0usize;
10988        let mut skipped_unbounded = 0usize;
10989        let mut skipped_budget = 0usize;
10990        let initial_vertex_len = image_vertices.len();
10991        let initial_index_len = image_indices.len();
10992        let initial_cmd_len = glyph_cmds.len();
10993        let initial_staged_bytes_len = staged_uploads.bytes.len();
10994        let initial_staged_copies_len = staged_uploads.copies.len();
10995        'batches: for batch in chunk.iter() {
10996            let SegmentBatchPlan::Text { start, end } = batch else {
10997                continue;
10998            };
10999            for (_, item) in &ordered_items[start..end] {
11000                if offscreen_text_glyph_prewarm_budget_exhausted(prewarm_start, admitted_candidates)
11001                {
11002                    skipped_budget = skipped_budget.saturating_add(1);
11003                    break 'batches;
11004                }
11005                let SegmentDrawItem::Text(text_index) = item else {
11006                    return Err(format!(
11007                        "text prewarm batch contains non-text draw item: {item:?}"
11008                    ));
11009                };
11010                let Some(text_draw) = texts.get(*text_index) else {
11011                    continue;
11012                };
11013                text_items = text_items.saturating_add(1);
11014                match self.text_glyph_prewarm_decision(text_draw, viewport, root_scale) {
11015                    TextGlyphPrewarmDecision::Candidate => {}
11016                    TextGlyphPrewarmDecision::MissingGeometry => {
11017                        missing_geometry = missing_geometry.saturating_add(1);
11018                        continue;
11019                    }
11020                    TextGlyphPrewarmDecision::DynamicMotion => {
11021                        dynamic_motion = dynamic_motion.saturating_add(1);
11022                        continue;
11023                    }
11024                    TextGlyphPrewarmDecision::Visible => {
11025                        visible = visible.saturating_add(1);
11026                        continue;
11027                    }
11028                    TextGlyphPrewarmDecision::OutsidePrewarmWindow => {
11029                        outside = outside.saturating_add(1);
11030                        continue;
11031                    }
11032                }
11033
11034                candidates = candidates.saturating_add(1);
11035                let Some((_, raster_rect, _, text_scale, static_text_motion)) =
11036                    self.text_raster_geometry(text_draw, root_scale)
11037                else {
11038                    missing_geometry = missing_geometry.saturating_add(1);
11039                    continue;
11040                };
11041                let raster_source = text_glyph_raster_source(text_draw, raster_rect);
11042                let source_draw = raster_source.draw.as_ref();
11043                let run_key = Self::text_glyph_run_cache_key(
11044                    source_draw,
11045                    raster_source.raster_rect,
11046                    text_scale,
11047                    static_text_motion,
11048                );
11049                let atlas_generation = self.text_glyph_atlas.generation();
11050                let cached_glyphs = if let Some(cached) = self.text_glyph_run_cache.peek(&run_key) {
11051                    if cached.atlas_generation == atlas_generation && cached.quads.is_some() {
11052                        already_prepared = already_prepared.saturating_add(1);
11053                        continue;
11054                    }
11055                    Some(cached.glyphs.len())
11056                } else {
11057                    None
11058                };
11059                if !offscreen_text_glyph_prewarm_work_is_bounded(
11060                    cached_glyphs,
11061                    source_draw.text.text.len(),
11062                ) {
11063                    skipped_unbounded = skipped_unbounded.saturating_add(1);
11064                    continue;
11065                }
11066                admitted_candidates = admitted_candidates.saturating_add(1);
11067                self.append_text_glyph_draws(
11068                    std::iter::once(text_draw),
11069                    viewport,
11070                    root_scale,
11071                    true,
11072                    staged_uploads,
11073                    image_vertices,
11074                    image_indices,
11075                    glyph_cmds,
11076                )?;
11077                image_vertices.truncate(initial_vertex_len);
11078                image_indices.truncate(initial_index_len);
11079                glyph_cmds.truncate(initial_cmd_len);
11080                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
11081            }
11082        }
11083
11084        if diag_enabled && text_items > 0 {
11085            log::warn!(
11086                "[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}"
11087            );
11088        }
11089        if admitted_candidates > 0 {
11090            if let Some(total_ms) = should_log_wgpu_render_stage(prewarm_start, Instant::now()) {
11091                log::warn!(
11092                    "[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}"
11093                );
11094            }
11095        }
11096        Ok(())
11097    }
11098
11099    fn prepare_text_glyph_draw_cmds<'a, I>(
11100        &mut self,
11101        layer_texts: I,
11102        viewport: ViewportUniformParams,
11103        root_scale: f32,
11104        staged_uploads: &mut StagedBufferUploads,
11105    ) -> Result<Option<PreparedGlyphBatch>, String>
11106    where
11107        I: IntoIterator<Item = &'a TextDraw>,
11108    {
11109        #[cfg(target_arch = "wasm32")]
11110        let _ = staged_uploads;
11111
11112        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
11113        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
11114        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
11115        image_vertices.clear();
11116        image_indices.clear();
11117        glyph_cmds.clear();
11118
11119        if !self.append_text_glyph_draws(
11120            layer_texts,
11121            viewport,
11122            root_scale,
11123            false,
11124            staged_uploads,
11125            &mut image_vertices,
11126            &mut image_indices,
11127            &mut glyph_cmds,
11128        )? {
11129            self.scratch_image_vertices = image_vertices;
11130            self.scratch_image_indices = image_indices;
11131            self.scratch_glyph_cmds = glyph_cmds;
11132            return Ok(None);
11133        }
11134
11135        #[cfg(not(target_arch = "wasm32"))]
11136        if !image_indices.is_empty() {
11137            self.stage_native_image_buffers(
11138                staged_uploads,
11139                viewport,
11140                &image_vertices,
11141                &image_indices,
11142            );
11143        }
11144
11145        #[cfg(target_arch = "wasm32")]
11146        let image_slot = if glyph_cmds.is_empty() {
11147            0
11148        } else {
11149            let slot = self.claim_wasm_image_batch();
11150            {
11151                let buffers = &mut self.wasm_image_batches[slot];
11152                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
11153            }
11154            let buffers = &self.wasm_image_batches[slot];
11155            self.write_wasm_buffer(
11156                &buffers.vertex_buffer,
11157                bytemuck::cast_slice(&image_vertices),
11158            );
11159            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
11160            slot
11161        };
11162
11163        #[cfg(target_arch = "wasm32")]
11164        let uniform_slot = if glyph_cmds.is_empty() {
11165            0
11166        } else {
11167            self.prepare_wasm_viewport_uniforms(viewport)
11168        };
11169
11170        self.scratch_image_vertices = image_vertices;
11171        self.scratch_image_indices = image_indices;
11172        Ok(Some(PreparedGlyphBatch {
11173            cmds: glyph_cmds,
11174            #[cfg(target_arch = "wasm32")]
11175            image_slot,
11176            #[cfg(target_arch = "wasm32")]
11177            uniform_slot,
11178        }))
11179    }
11180
11181    #[allow(clippy::too_many_arguments)]
11182    fn append_image_bitmap_draw_cmd(
11183        &mut self,
11184        image: &ImageBitmap,
11185        rect: Rect,
11186        clip: Option<Rect>,
11187        sampling: ImageSampling,
11188        viewport: ViewportUniformParams,
11189        root_scale: f32,
11190        image_vertices: &mut Vec<Vertex>,
11191        image_indices: &mut Vec<u32>,
11192        image_cmds: &mut Vec<ImageDrawCmd>,
11193    ) -> Result<(), String> {
11194        if rect.width <= 0.0 || rect.height <= 0.0 {
11195            return Ok(());
11196        }
11197
11198        self.ensure_image_cached(image)?;
11199
11200        let (device_quad, scissor_rect) =
11201            if sampling == ImageSampling::Nearest && root_scale.is_finite() && root_scale > 0.0 {
11202                let left_px = (rect.x * root_scale).round();
11203                let top_px = (rect.y * root_scale).round();
11204                let width_px = (rect.width * root_scale).round().max(1.0);
11205                let height_px = (rect.height * root_scale).round().max(1.0);
11206                let snapped_rect = Rect {
11207                    x: left_px / root_scale,
11208                    y: top_px / root_scale,
11209                    width: width_px / root_scale,
11210                    height: height_px / root_scale,
11211                };
11212                let right_px = left_px + width_px;
11213                let bottom_px = top_px + height_px;
11214                (
11215                    [
11216                        [left_px, top_px],
11217                        [right_px, top_px],
11218                        [left_px, bottom_px],
11219                        [right_px, bottom_px],
11220                    ],
11221                    snapped_rect,
11222                )
11223            } else {
11224                (
11225                    rect_to_quad(rect).map(|[x, y]| [x * root_scale, y * root_scale]),
11226                    rect,
11227                )
11228            };
11229
11230        let Some(scissor) = scissor_rect_for_layer(
11231            scissor_rect,
11232            clip,
11233            root_scale,
11234            viewport.width,
11235            viewport.height,
11236        ) else {
11237            return Ok(());
11238        };
11239        let Some(uv_rect) = image_uv_rect(image, None) else {
11240            return Ok(());
11241        };
11242
11243        let base_vertex = image_vertices.len() as u32;
11244        let index_start = image_indices.len() as u32;
11245        image_indices.extend_from_slice(&[
11246            base_vertex,
11247            base_vertex + 1,
11248            base_vertex + 2,
11249            base_vertex + 2,
11250            base_vertex + 1,
11251            base_vertex + 3,
11252        ]);
11253        let color = [1.0, 1.0, 1.0, 1.0];
11254        image_vertices.extend_from_slice(&[
11255            Vertex {
11256                position: device_quad[0],
11257                color,
11258                uv: [uv_rect.min[0], uv_rect.min[1]],
11259                uv_bounds: uv_rect.sample_bounds,
11260            },
11261            Vertex {
11262                position: device_quad[1],
11263                color,
11264                uv: [uv_rect.max[0], uv_rect.min[1]],
11265                uv_bounds: uv_rect.sample_bounds,
11266            },
11267            Vertex {
11268                position: device_quad[2],
11269                color,
11270                uv: [uv_rect.min[0], uv_rect.max[1]],
11271                uv_bounds: uv_rect.sample_bounds,
11272            },
11273            Vertex {
11274                position: device_quad[3],
11275                color,
11276                uv: [uv_rect.max[0], uv_rect.max[1]],
11277                uv_bounds: uv_rect.sample_bounds,
11278            },
11279        ]);
11280        image_cmds.push(ImageDrawCmd {
11281            index_start,
11282            scissor,
11283            image_id: image.id(),
11284            sampling,
11285        });
11286        Ok(())
11287    }
11288
11289    #[allow(clippy::too_many_arguments)]
11290    fn append_text_image_draw_cmds<'a, I>(
11291        &mut self,
11292        layer_texts: I,
11293        viewport: ViewportUniformParams,
11294        root_scale: f32,
11295        image_vertices: &mut Vec<Vertex>,
11296        image_indices: &mut Vec<u32>,
11297        image_cmds: &mut Vec<ImageDrawCmd>,
11298    ) -> Result<(), String>
11299    where
11300        I: Iterator<Item = &'a TextDraw>,
11301    {
11302        let append_start = Instant::now();
11303        let initial_len = image_cmds.len();
11304        let mut visited = 0usize;
11305        let mut hit_count = 0usize;
11306        let mut miss_count = 0usize;
11307        for text_draw in layer_texts {
11308            visited = visited.saturating_add(1);
11309            let _ = text_draw.node_id;
11310            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
11311                self.text_raster_geometry(text_draw, root_scale)
11312            else {
11313                continue;
11314            };
11315            if !text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
11316                continue;
11317            }
11318
11319            let raster_source = self.text_image_raster_source(
11320                text_draw,
11321                logical_rect,
11322                raster_rect,
11323                clip,
11324                root_scale,
11325                static_text_motion,
11326            );
11327            let source_draw = raster_source.draw.as_ref();
11328            let source_raster_rect = raster_source.raster_rect;
11329
11330            let cache_key = Self::text_image_cache_key(
11331                source_draw,
11332                source_raster_rect,
11333                text_scale,
11334                static_text_motion,
11335            );
11336            let image = if let Some(cached) = self.text_image_cache.get(&cache_key) {
11337                self.frame_stats
11338                    .record_text_image_cache_hit(cached.image.width(), cached.image.height());
11339                hit_count = hit_count.saturating_add(1);
11340                cached.image.clone()
11341            } else {
11342                let Some(image) =
11343                    self.rasterize_text_draw_to_image(source_draw, source_raster_rect, text_scale)
11344                else {
11345                    continue;
11346                };
11347                self.frame_stats
11348                    .record_text_image_cache_miss(image.width(), image.height());
11349                miss_count = miss_count.saturating_add(1);
11350                self.text_image_cache.put(
11351                    cache_key,
11352                    CachedTextImage {
11353                        image: image.clone(),
11354                    },
11355                );
11356                image
11357            };
11358
11359            let draw_origin = if static_text_motion {
11360                Point::new(
11361                    source_raster_rect.x / root_scale,
11362                    source_raster_rect.y / root_scale,
11363                )
11364            } else {
11365                Point::new(logical_rect.x, logical_rect.y)
11366            };
11367            let draw_rect = Rect {
11368                x: draw_origin.x,
11369                y: draw_origin.y,
11370                width: image.width() as f32 / root_scale,
11371                height: image.height() as f32 / root_scale,
11372            };
11373            self.append_image_bitmap_draw_cmd(
11374                &image,
11375                draw_rect,
11376                clip,
11377                ImageSampling::Nearest,
11378                viewport,
11379                root_scale,
11380                image_vertices,
11381                image_indices,
11382                image_cmds,
11383            )?;
11384        }
11385        let append_end = Instant::now();
11386        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
11387            log::warn!(
11388                "[wgpu-render-stage:text-images] total_ms={total_ms:.2} visited={} emitted={} hits={} misses={}",
11389                visited,
11390                image_cmds.len().saturating_sub(initial_len),
11391                hit_count,
11392                miss_count,
11393            );
11394        }
11395        Ok(())
11396    }
11397
11398    fn text_image_raster_source<'a>(
11399        &mut self,
11400        text_draw: &'a TextDraw,
11401        logical_rect: Rect,
11402        raster_rect: Rect,
11403        clip: Option<Rect>,
11404        root_scale: f32,
11405        static_text_motion: bool,
11406    ) -> TextRasterSource<'a> {
11407        let Some(clip) = clip else {
11408            return TextRasterSource {
11409                draw: Cow::Borrowed(text_draw),
11410                raster_rect,
11411            };
11412        };
11413        if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
11414            return TextRasterSource {
11415                draw: Cow::Borrowed(text_draw),
11416                raster_rect,
11417            };
11418        }
11419
11420        let line_starts = self.text_line_index_cache.line_starts(&text_draw.text);
11421        clipped_text_raster_source_with_line_starts(
11422            text_draw,
11423            logical_rect,
11424            raster_rect,
11425            clip,
11426            root_scale,
11427            line_starts.as_ref(),
11428        )
11429    }
11430
11431    fn prepare_text_image_draw_cmds<'a, I>(
11432        &mut self,
11433        layer_texts: I,
11434        viewport: ViewportUniformParams,
11435        root_scale: f32,
11436        staged_uploads: &mut StagedBufferUploads,
11437    ) -> Result<PreparedImageBatch, String>
11438    where
11439        I: Iterator<Item = &'a TextDraw>,
11440    {
11441        #[cfg(target_arch = "wasm32")]
11442        let _ = staged_uploads;
11443
11444        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
11445        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
11446        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
11447        image_vertices.clear();
11448        image_indices.clear();
11449        image_cmds.clear();
11450
11451        self.append_text_image_draw_cmds(
11452            layer_texts,
11453            viewport,
11454            root_scale,
11455            &mut image_vertices,
11456            &mut image_indices,
11457            &mut image_cmds,
11458        )?;
11459
11460        #[cfg(not(target_arch = "wasm32"))]
11461        if !image_cmds.is_empty() {
11462            self.stage_native_image_buffers(
11463                staged_uploads,
11464                viewport,
11465                &image_vertices,
11466                &image_indices,
11467            );
11468        }
11469
11470        #[cfg(target_arch = "wasm32")]
11471        let image_slot = if image_cmds.is_empty() {
11472            0
11473        } else {
11474            let slot = self.claim_wasm_image_batch();
11475            {
11476                let buffers = &mut self.wasm_image_batches[slot];
11477                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
11478            }
11479            let buffers = &self.wasm_image_batches[slot];
11480            self.write_wasm_buffer(
11481                &buffers.vertex_buffer,
11482                bytemuck::cast_slice(&image_vertices),
11483            );
11484            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
11485            slot
11486        };
11487
11488        #[cfg(target_arch = "wasm32")]
11489        let uniform_slot = if image_cmds.is_empty() {
11490            0
11491        } else {
11492            self.prepare_wasm_viewport_uniforms(viewport)
11493        };
11494
11495        self.scratch_image_vertices = image_vertices;
11496        self.scratch_image_indices = image_indices;
11497        Ok(PreparedImageBatch {
11498            cmds: image_cmds,
11499            #[cfg(target_arch = "wasm32")]
11500            image_slot,
11501            #[cfg(target_arch = "wasm32")]
11502            uniform_slot,
11503        })
11504    }
11505
11506    fn text_raster_geometry(
11507        &self,
11508        text_draw: &TextDraw,
11509        root_scale: f32,
11510    ) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
11511        text_raster_geometry_for_draw(text_draw, root_scale)
11512    }
11513
11514    fn text_image_cache_key(
11515        text_draw: &TextDraw,
11516        raster_rect: Rect,
11517        text_scale: f32,
11518        static_text_motion: bool,
11519    ) -> TextImageCacheKey {
11520        let mut state = default_hash::new();
11521        text_draw.text.render_hash().hash(&mut state);
11522        text_draw.text_style.render_hash().hash(&mut state);
11523        text_draw.color.render_hash().hash(&mut state);
11524        hash_text_raster_geometry_for_cache(raster_rect, static_text_motion, &mut state);
11525        text_draw.font_size.to_bits().hash(&mut state);
11526        text_scale.to_bits().hash(&mut state);
11527        text_draw.layout_options.hash(&mut state);
11528        TextImageCacheKey(state.finish())
11529    }
11530
11531    fn text_glyph_run_cache_key(
11532        text_draw: &TextDraw,
11533        raster_rect: Rect,
11534        text_scale: f32,
11535        static_text_motion: bool,
11536    ) -> TextGlyphRunCacheKey {
11537        TextGlyphRunCacheKey(
11538            Self::text_image_cache_key(text_draw, raster_rect, text_scale, static_text_motion).0,
11539        )
11540    }
11541
11542    fn rasterize_text_draw_to_image(
11543        &mut self,
11544        text_draw: &TextDraw,
11545        raster_rect: Rect,
11546        text_scale: f32,
11547    ) -> Option<ImageBitmap> {
11548        if text_draw.text.span_styles.is_empty() {
11549            let font = self.text_fonts.resolve(&text_draw.text_style)?;
11550            return rasterize_text_to_image_with_glyph_cache(
11551                text_draw.text.text.as_str(),
11552                raster_rect,
11553                &text_draw.text_style,
11554                text_draw.color,
11555                text_draw.font_size,
11556                text_scale,
11557                font,
11558                &mut self.text_glyph_mask_cache,
11559            );
11560        }
11561
11562        if let Some(image) = rasterize_annotated_text_to_image_with_glyph_cache(
11563            text_draw.text.as_ref(),
11564            raster_rect,
11565            &text_draw.text_style,
11566            text_draw.color,
11567            text_draw.font_size,
11568            text_scale,
11569            &self.text_fonts,
11570            &mut self.text_glyph_mask_cache,
11571        ) {
11572            return Some(image);
11573        }
11574
11575        rasterize_spanned_text_to_image(
11576            text_draw,
11577            raster_rect,
11578            text_scale,
11579            &self.text_fonts,
11580            &mut self.text_glyph_mask_cache,
11581        )
11582    }
11583}
11584
11585fn rasterize_spanned_text_to_image(
11586    text_draw: &TextDraw,
11587    raster_rect: Rect,
11588    text_scale: f32,
11589    fonts: &SoftwareTextFontSet,
11590    glyph_cache: &mut SoftwareGlyphRasterCache,
11591) -> Option<ImageBitmap> {
11592    let width = raster_rect.width.ceil().max(1.0) as u32;
11593    let height = raster_rect.height.ceil().max(1.0) as u32;
11594    let mut canvas = vec![0_u8; (width as usize) * (height as usize) * 4];
11595    let boundaries = text_draw.text.span_boundaries();
11596    let base_line_height = text_draw
11597        .text_style
11598        .resolve_line_height(14.0, text_draw.font_size)
11599        .max(1.0);
11600    let mut current_line_height = base_line_height;
11601    let mut cursor_x = raster_rect.x;
11602    let mut cursor_y = raster_rect.y;
11603
11604    for window in boundaries.windows(2) {
11605        let start = window[0];
11606        let end = window[1];
11607        if start == end {
11608            continue;
11609        }
11610
11611        let chunk = &text_draw.text.text[start..end];
11612        let mut merged_span = text_draw.text_style.span_style.clone();
11613        for span in &text_draw.text.span_styles {
11614            if span.range.start <= start && span.range.end >= end {
11615                merged_span = merged_span.merge(&span.item);
11616            }
11617        }
11618
11619        let mut chunk_style = text_draw.text_style.clone();
11620        chunk_style.span_style = merged_span;
11621
11622        for part in chunk.split_inclusive('\n') {
11623            let has_newline = part.ends_with('\n');
11624            let content = if has_newline {
11625                &part[..part.len().saturating_sub(1)]
11626            } else {
11627                part
11628            };
11629
11630            if !content.is_empty() {
11631                let chunk_font_size = chunk_style.resolve_font_size(text_draw.font_size);
11632                let Some(font) = fonts.resolve(&chunk_style) else {
11633                    continue;
11634                };
11635                let metrics = measure_text_with_font(content, &chunk_style, chunk_font_size, font);
11636                let segment_rect = Rect {
11637                    x: cursor_x,
11638                    y: cursor_y,
11639                    width: (metrics.width * text_scale).ceil().max(1.0),
11640                    height: (metrics.height * text_scale).ceil().max(1.0),
11641                };
11642                if let Some(segment_image) = rasterize_text_to_image_with_glyph_cache(
11643                    content,
11644                    segment_rect,
11645                    &chunk_style,
11646                    chunk_style.resolve_text_color(text_draw.color),
11647                    chunk_font_size,
11648                    text_scale,
11649                    font,
11650                    glyph_cache,
11651                ) {
11652                    composite_text_segment(
11653                        &mut canvas,
11654                        width,
11655                        height,
11656                        raster_rect,
11657                        segment_rect,
11658                        &segment_image,
11659                    );
11660                }
11661                cursor_x += metrics.width * text_scale;
11662                current_line_height = current_line_height.max(metrics.line_height.max(1.0));
11663            }
11664
11665            if has_newline {
11666                cursor_x = raster_rect.x;
11667                cursor_y += current_line_height * text_scale;
11668                current_line_height = base_line_height;
11669            }
11670        }
11671    }
11672
11673    ImageBitmap::from_rgba8(width, height, canvas).ok()
11674}
11675
11676struct TextRasterSource<'a> {
11677    draw: Cow<'a, TextDraw>,
11678    raster_rect: Rect,
11679}
11680
11681fn text_glyph_raster_source(text_draw: &TextDraw, raster_rect: Rect) -> TextRasterSource<'_> {
11682    TextRasterSource {
11683        draw: Cow::Borrowed(text_draw),
11684        raster_rect,
11685    }
11686}
11687
11688#[cfg(test)]
11689fn clipped_text_raster_source<'a>(
11690    text_draw: &'a TextDraw,
11691    logical_rect: Rect,
11692    raster_rect: Rect,
11693    clip: Option<Rect>,
11694    root_scale: f32,
11695    static_text_motion: bool,
11696) -> TextRasterSource<'a> {
11697    let Some(clip) = clip else {
11698        return TextRasterSource {
11699            draw: Cow::Borrowed(text_draw),
11700            raster_rect,
11701        };
11702    };
11703    if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
11704        return TextRasterSource {
11705            draw: Cow::Borrowed(text_draw),
11706            raster_rect,
11707        };
11708    }
11709    let line_starts = line_start_offsets(text_draw.text.text.as_str());
11710    clipped_text_raster_source_with_line_starts(
11711        text_draw,
11712        logical_rect,
11713        raster_rect,
11714        clip,
11715        root_scale,
11716        &line_starts,
11717    )
11718}
11719
11720fn clipped_text_raster_source_with_line_starts<'a>(
11721    text_draw: &'a TextDraw,
11722    logical_rect: Rect,
11723    raster_rect: Rect,
11724    clip: Rect,
11725    root_scale: f32,
11726    line_starts: &[usize],
11727) -> TextRasterSource<'a> {
11728    if line_starts.len() < MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER {
11729        return TextRasterSource {
11730            draw: Cow::Borrowed(text_draw),
11731            raster_rect,
11732        };
11733    }
11734
11735    let Some(visible_rect) = logical_rect.intersect(clip) else {
11736        return TextRasterSource {
11737            draw: Cow::Borrowed(text_draw),
11738            raster_rect,
11739        };
11740    };
11741
11742    let line_count = line_starts.len().max(1);
11743    let line_height = logical_rect.height / line_count as f32;
11744    if !line_height.is_finite() || line_height <= 0.0 {
11745        return TextRasterSource {
11746            draw: Cow::Borrowed(text_draw),
11747            raster_rect,
11748        };
11749    }
11750
11751    let visible_top = ((visible_rect.y - logical_rect.y) / line_height).floor() as isize;
11752    let visible_bottom =
11753        ((visible_rect.y + visible_rect.height - logical_rect.y) / line_height).ceil() as isize;
11754    let start_line = visible_top.saturating_sub(1).max(0) as usize;
11755    let end_line = (visible_bottom + 1).max(start_line as isize + 1) as usize;
11756    let end_line = end_line.min(line_count);
11757    if start_line == 0 && end_line >= line_count {
11758        return TextRasterSource {
11759            draw: Cow::Borrowed(text_draw),
11760            raster_rect,
11761        };
11762    }
11763
11764    let byte_start = line_starts[start_line];
11765    let byte_end = line_end_offset(text_draw.text.text.as_str(), line_starts, end_line - 1);
11766    if byte_start >= byte_end {
11767        return TextRasterSource {
11768            draw: Cow::Borrowed(text_draw),
11769            raster_rect,
11770        };
11771    }
11772
11773    let slice_y = logical_rect.y + start_line as f32 * line_height;
11774    let slice_height = (end_line - start_line) as f32 * line_height;
11775    let mut slice_raster_rect = Rect {
11776        x: logical_rect.x * root_scale,
11777        y: slice_y * root_scale,
11778        width: logical_rect.width * root_scale,
11779        height: slice_height * root_scale,
11780    };
11781    slice_raster_rect.x = slice_raster_rect.x.round();
11782    slice_raster_rect.y = slice_raster_rect.y.round();
11783    slice_raster_rect.width = slice_raster_rect.width.ceil().max(1.0);
11784    slice_raster_rect.height = slice_raster_rect.height.ceil().max(1.0);
11785
11786    let mut sliced_draw = text_draw.clone();
11787    sliced_draw.rect = Rect {
11788        x: logical_rect.x,
11789        y: slice_y,
11790        width: logical_rect.width,
11791        height: slice_height,
11792    };
11793    sliced_draw.text = Arc::new(text_draw.text.subsequence(byte_start..byte_end));
11794
11795    TextRasterSource {
11796        draw: Cow::Owned(sliced_draw),
11797        raster_rect: slice_raster_rect,
11798    }
11799}
11800
11801fn line_start_offsets(text: &str) -> Vec<usize> {
11802    let mut starts =
11803        Vec::with_capacity(text.as_bytes().iter().filter(|b| **b == b'\n').count() + 1);
11804    starts.push(0);
11805    starts.extend(
11806        text.char_indices()
11807            .filter_map(|(index, ch)| (ch == '\n').then_some(index + ch.len_utf8())),
11808    );
11809    starts
11810}
11811
11812fn line_end_offset(text: &str, line_starts: &[usize], line: usize) -> usize {
11813    line_starts.get(line + 1).copied().unwrap_or(text.len())
11814}
11815
11816fn composite_text_segment(
11817    canvas: &mut [u8],
11818    canvas_width: u32,
11819    canvas_height: u32,
11820    canvas_rect: Rect,
11821    segment_rect: Rect,
11822    segment_image: &ImageBitmap,
11823) {
11824    let offset_x = (segment_rect.x - canvas_rect.x).round() as i32;
11825    let offset_y = (segment_rect.y - canvas_rect.y).round() as i32;
11826    let src = segment_image.pixels();
11827    for sy in 0..segment_image.height() as i32 {
11828        let dy = offset_y + sy;
11829        if dy < 0 || dy >= canvas_height as i32 {
11830            continue;
11831        }
11832        for sx in 0..segment_image.width() as i32 {
11833            let dx = offset_x + sx;
11834            if dx < 0 || dx >= canvas_width as i32 {
11835                continue;
11836            }
11837            let src_index = ((sy as u32 * segment_image.width() + sx as u32) * 4) as usize;
11838            let dst_index = ((dy as u32 * canvas_width + dx as u32) * 4) as usize;
11839            blend_rgba_pixel(
11840                &mut canvas[dst_index..dst_index + 4],
11841                &src[src_index..src_index + 4],
11842            );
11843        }
11844    }
11845}
11846
11847fn blend_rgba_pixel(dst: &mut [u8], src: &[u8]) {
11848    let src_alpha = src[3] as f32 / 255.0;
11849    if src_alpha <= 0.0 {
11850        return;
11851    }
11852    let dst_alpha = dst[3] as f32 / 255.0;
11853    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
11854    if out_alpha <= f32::EPSILON {
11855        dst.copy_from_slice(&[0, 0, 0, 0]);
11856        return;
11857    }
11858
11859    for channel in 0..3 {
11860        let src_channel = src[channel] as f32 / 255.0;
11861        let dst_channel = dst[channel] as f32 / 255.0;
11862        let src_premult = src_channel * src_alpha;
11863        let dst_premult = dst_channel * dst_alpha;
11864        dst[channel] =
11865            (((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0) * 255.0)
11866                .round() as u8;
11867    }
11868    dst[3] = (out_alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
11869}
11870
11871fn align_to(value: u32, alignment: u32) -> u32 {
11872    debug_assert!(alignment > 0);
11873    value.div_ceil(alignment) * alignment
11874}
11875
11876#[cfg(not(target_arch = "wasm32"))]
11877fn align_usize_to(value: usize, alignment: usize) -> usize {
11878    debug_assert!(alignment > 0);
11879    value.div_ceil(alignment) * alignment
11880}
11881
11882impl GpuRenderer {
11883    fn convert_surface_pixels_to_rgba(&self, pixels: &mut [u8]) -> Result<(), String> {
11884        match self.surface_format {
11885            wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => Ok(()),
11886            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb => {
11887                for pixel in pixels.chunks_exact_mut(4) {
11888                    pixel.swap(0, 2);
11889                }
11890                Ok(())
11891            }
11892            format => Err(format!(
11893                "Screenshot readback unsupported for texture format: {format:?}"
11894            )),
11895        }
11896    }
11897}
11898
11899fn is_in_effect_range(z_index: usize, effect_z_ranges: &[Range<usize>]) -> bool {
11900    effect_z_ranges.iter().any(|range| range.contains(&z_index))
11901}
11902
11903#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11904enum SegmentDrawItem {
11905    Shape(usize),
11906    Image(usize),
11907    Text(usize),
11908    Shadow(usize),
11909    Composite(usize),
11910    ShaderComposite(usize),
11911    Retained(usize),
11912}
11913
11914#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11915enum SegmentBatchPlan {
11916    Shape {
11917        start: usize,
11918        end: usize,
11919        blend_mode: BlendMode,
11920    },
11921    Image {
11922        start: usize,
11923        end: usize,
11924        blend_mode: BlendMode,
11925    },
11926    Text {
11927        start: usize,
11928        end: usize,
11929    },
11930    Composite {
11931        start: usize,
11932        end: usize,
11933    },
11934    ShaderComposite {
11935        start: usize,
11936        end: usize,
11937    },
11938    /// Retained replay batches: each item is one bind + draw of GPU slots
11939    /// captured on an earlier frame, so they never merge and cost no budget.
11940    Retained {
11941        start: usize,
11942        end: usize,
11943    },
11944}
11945
11946#[derive(Clone, Debug, Default, PartialEq, Eq)]
11947struct SegmentDrawChunkPlan {
11948    batches: Vec<SegmentBatchPlan>,
11949}
11950
11951struct SegmentRenderOutcome {
11952    rendered_any: bool,
11953    pass_count: u32,
11954}
11955
11956struct SegmentCommandEncodeOutcome {
11957    first_batch: bool,
11958}
11959
11960#[cfg(not(target_arch = "wasm32"))]
11961#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11962enum TextGlyphPrewarmDecision {
11963    Candidate,
11964    MissingGeometry,
11965    DynamicMotion,
11966    Visible,
11967    OutsidePrewarmWindow,
11968}
11969
11970#[cfg(not(target_arch = "wasm32"))]
11971#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11972struct NativeSegmentFusionBudget {
11973    shape_count: usize,
11974    gradient_stop_count: usize,
11975}
11976
11977#[cfg(not(target_arch = "wasm32"))]
11978#[derive(Clone, Debug, PartialEq, Eq)]
11979struct NativeSegmentFusionPartition {
11980    chunk: SegmentDrawChunkPlan,
11981    budget: NativeSegmentFusionBudget,
11982}
11983
11984#[cfg(not(target_arch = "wasm32"))]
11985#[derive(Clone, Debug, PartialEq, Eq)]
11986enum FusedSegmentBatch {
11987    Shape {
11988        batch: PreparedShapeBatch,
11989        blend_mode: BlendMode,
11990    },
11991    Image {
11992        cmd_range: Range<usize>,
11993        blend_mode: BlendMode,
11994    },
11995    Text {
11996        image_cmd_range: Range<usize>,
11997        glyph_cmd_range: Range<usize>,
11998    },
11999    Composite {
12000        draw_range: Range<usize>,
12001    },
12002    ShaderComposite {
12003        draw_range: Range<usize>,
12004    },
12005    Retained {
12006        item_range: Range<usize>,
12007    },
12008}
12009
12010struct ShadowSourceRenderOutcome {
12011    rendered_any: bool,
12012    pass_count: u32,
12013}
12014
12015impl SegmentDrawChunkPlan {
12016    fn is_empty(&self) -> bool {
12017        self.batches.is_empty()
12018    }
12019
12020    fn push(&mut self, batch: SegmentBatchPlan) {
12021        self.batches.push(batch);
12022    }
12023
12024    fn iter(&self) -> impl Iterator<Item = SegmentBatchPlan> + '_ {
12025        self.batches.iter().copied()
12026    }
12027}
12028
12029#[derive(Clone, Debug, PartialEq, Eq)]
12030enum SegmentRenderCommand {
12031    DrawChunk(SegmentDrawChunkPlan),
12032    Shadow(usize),
12033}
12034
12035struct SegmentCommandIter<'a> {
12036    ordered_items: &'a [(usize, SegmentDrawItem)],
12037    shapes: &'a [DrawShape],
12038    images: &'a [ImageDraw],
12039    cursor: usize,
12040    batch_limits: ShapeBatchLimits,
12041}
12042
12043impl<'a> SegmentCommandIter<'a> {
12044    fn new(
12045        ordered_items: &'a [(usize, SegmentDrawItem)],
12046        shapes: &'a [DrawShape],
12047        images: &'a [ImageDraw],
12048        batch_limits: ShapeBatchLimits,
12049    ) -> Self {
12050        Self {
12051            ordered_items,
12052            shapes,
12053            images,
12054            cursor: 0,
12055            batch_limits,
12056        }
12057    }
12058}
12059
12060impl Iterator for SegmentCommandIter<'_> {
12061    type Item = SegmentRenderCommand;
12062
12063    fn next(&mut self) -> Option<Self::Item> {
12064        if self.cursor >= self.ordered_items.len() {
12065            return None;
12066        }
12067
12068        if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
12069            self.cursor += 1;
12070            return Some(SegmentRenderCommand::Shadow(index));
12071        }
12072
12073        let mut chunk = SegmentDrawChunkPlan::default();
12074        while self.cursor < self.ordered_items.len() {
12075            if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
12076                if chunk.is_empty() {
12077                    self.cursor += 1;
12078                    return Some(SegmentRenderCommand::Shadow(index));
12079                }
12080                break;
12081            }
12082
12083            let Some((batch, next_cursor)) = segment_batch_plan_at_cursor(
12084                self.ordered_items,
12085                self.shapes,
12086                self.images,
12087                self.cursor,
12088                self.batch_limits,
12089            ) else {
12090                break;
12091            };
12092            chunk.push(batch);
12093            self.cursor = next_cursor;
12094        }
12095
12096        Some(SegmentRenderCommand::DrawChunk(chunk))
12097    }
12098}
12099
12100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12101struct PreparedShapeBatch {
12102    /// First vertex and vertex count for the unindexed shape draw; always
12103    /// multiples of 6 so `vs_main`'s `vertex_index / 6` lands on whole shapes.
12104    vertex_start: u32,
12105    vertex_count: u32,
12106    #[cfg(target_arch = "wasm32")]
12107    shape_slot: usize,
12108    #[cfg(target_arch = "wasm32")]
12109    uniform_slot: usize,
12110}
12111
12112struct PreparedImageBatch {
12113    cmds: Vec<ImageDrawCmd>,
12114    #[cfg(target_arch = "wasm32")]
12115    image_slot: usize,
12116    #[cfg(target_arch = "wasm32")]
12117    uniform_slot: usize,
12118}
12119
12120impl PreparedImageBatch {
12121    fn is_empty(&self) -> bool {
12122        self.cmds.is_empty()
12123    }
12124
12125    fn into_cmds(self) -> Vec<ImageDrawCmd> {
12126        self.cmds
12127    }
12128}
12129
12130struct PreparedGlyphBatch {
12131    cmds: Vec<GlyphDrawCmd>,
12132    #[cfg(target_arch = "wasm32")]
12133    image_slot: usize,
12134    #[cfg(target_arch = "wasm32")]
12135    uniform_slot: usize,
12136}
12137
12138impl PreparedGlyphBatch {
12139    fn is_empty(&self) -> bool {
12140        self.cmds.is_empty()
12141    }
12142
12143    fn into_cmds(self) -> Vec<GlyphDrawCmd> {
12144        self.cmds
12145    }
12146}
12147
12148#[cfg(not(target_arch = "wasm32"))]
12149fn gradient_stop_count_for_shape(shape: &DrawShape) -> usize {
12150    match &shape.brush {
12151        Brush::Solid(_) => 0,
12152        Brush::LinearGradient { colors, .. }
12153        | Brush::RadialGradient { colors, .. }
12154        | Brush::SweepGradient { colors, .. } => colors.len(),
12155    }
12156}
12157
12158#[cfg(not(target_arch = "wasm32"))]
12159fn native_segment_fusion_budget(
12160    ordered_items: &[(usize, SegmentDrawItem)],
12161    shapes: &[DrawShape],
12162    chunk: &SegmentDrawChunkPlan,
12163    batch_limits: ShapeBatchLimits,
12164) -> Result<Option<NativeSegmentFusionBudget>, String> {
12165    let mut shape_count = 0usize;
12166    let mut gradient_stop_count = 0usize;
12167
12168    for batch in chunk.iter() {
12169        let SegmentBatchPlan::Shape { start, end, .. } = batch else {
12170            continue;
12171        };
12172        for (_, item) in &ordered_items[start..end] {
12173            let SegmentDrawItem::Shape(shape_index) = item else {
12174                return Err(format!(
12175                    "shape batch contains non-shape draw item: {item:?}"
12176                ));
12177            };
12178            let shape = &shapes[*shape_index];
12179            shape_count = shape_count.saturating_add(1);
12180            gradient_stop_count =
12181                gradient_stop_count.saturating_add(gradient_stop_count_for_shape(shape));
12182        }
12183    }
12184
12185    if shape_count > batch_limits.max_shapes_per_batch
12186        || gradient_stop_count > batch_limits.max_gradient_stops
12187    {
12188        return Ok(None);
12189    }
12190
12191    Ok(Some(NativeSegmentFusionBudget {
12192        shape_count,
12193        gradient_stop_count,
12194    }))
12195}
12196
12197#[cfg(not(target_arch = "wasm32"))]
12198fn push_native_segment_fusion_partition(
12199    partitions: &mut Vec<NativeSegmentFusionPartition>,
12200    current: &mut SegmentDrawChunkPlan,
12201    current_budget: &mut NativeSegmentFusionBudget,
12202) {
12203    if current.is_empty() {
12204        return;
12205    }
12206
12207    partitions.push(NativeSegmentFusionPartition {
12208        chunk: std::mem::take(current),
12209        budget: *current_budget,
12210    });
12211    *current_budget = NativeSegmentFusionBudget {
12212        shape_count: 0,
12213        gradient_stop_count: 0,
12214    };
12215}
12216
12217#[cfg(not(target_arch = "wasm32"))]
12218fn native_segment_fusion_partitions(
12219    ordered_items: &[(usize, SegmentDrawItem)],
12220    shapes: &[DrawShape],
12221    chunk: &SegmentDrawChunkPlan,
12222    batch_limits: ShapeBatchLimits,
12223) -> Result<Option<Vec<NativeSegmentFusionPartition>>, String> {
12224    if let Some(budget) = native_segment_fusion_budget(ordered_items, shapes, chunk, batch_limits)?
12225    {
12226        return Ok(Some(vec![NativeSegmentFusionPartition {
12227            chunk: chunk.clone(),
12228            budget,
12229        }]));
12230    }
12231
12232    let mut partitions = Vec::new();
12233    let mut current = SegmentDrawChunkPlan::default();
12234    let mut current_budget = NativeSegmentFusionBudget {
12235        shape_count: 0,
12236        gradient_stop_count: 0,
12237    };
12238
12239    for batch in chunk.iter() {
12240        let SegmentBatchPlan::Shape {
12241            start,
12242            end,
12243            blend_mode,
12244        } = batch
12245        else {
12246            current.push(batch);
12247            continue;
12248        };
12249
12250        let mut run_start = start;
12251        for (item_cursor, (_, item)) in ordered_items.iter().enumerate().take(end).skip(start) {
12252            let SegmentDrawItem::Shape(shape_index) = *item else {
12253                return Err(format!(
12254                    "shape batch contains non-shape draw item: {:?}",
12255                    item
12256                ));
12257            };
12258            let gradient_stop_count = gradient_stop_count_for_shape(&shapes[shape_index]);
12259            if gradient_stop_count > batch_limits.max_gradient_stops {
12260                return Ok(None);
12261            }
12262
12263            let fits_shape_count =
12264                current_budget.shape_count.saturating_add(1) <= batch_limits.max_shapes_per_batch;
12265            let fits_gradient_count = current_budget
12266                .gradient_stop_count
12267                .saturating_add(gradient_stop_count)
12268                <= batch_limits.max_gradient_stops;
12269            if !fits_shape_count || !fits_gradient_count {
12270                if run_start < item_cursor {
12271                    current.push(SegmentBatchPlan::Shape {
12272                        start: run_start,
12273                        end: item_cursor,
12274                        blend_mode,
12275                    });
12276                }
12277                push_native_segment_fusion_partition(
12278                    &mut partitions,
12279                    &mut current,
12280                    &mut current_budget,
12281                );
12282                run_start = item_cursor;
12283            }
12284
12285            current_budget.shape_count = current_budget.shape_count.saturating_add(1);
12286            current_budget.gradient_stop_count = current_budget
12287                .gradient_stop_count
12288                .saturating_add(gradient_stop_count);
12289        }
12290
12291        if run_start < end {
12292            current.push(SegmentBatchPlan::Shape {
12293                start: run_start,
12294                end,
12295                blend_mode,
12296            });
12297        }
12298    }
12299
12300    push_native_segment_fusion_partition(&mut partitions, &mut current, &mut current_budget);
12301    Ok(Some(partitions))
12302}
12303
12304fn segment_batch_plan_at_cursor(
12305    ordered_items: &[(usize, SegmentDrawItem)],
12306    shapes: &[DrawShape],
12307    images: &[ImageDraw],
12308    start: usize,
12309    batch_limits: ShapeBatchLimits,
12310) -> Option<(SegmentBatchPlan, usize)> {
12311    match ordered_items[start].1 {
12312        SegmentDrawItem::Shape(index) => {
12313            let blend_mode = supported_blend_mode(shapes[index].blend_mode);
12314            let mut end = start + 1;
12315            let shape_limit = (start + batch_limits.max_shapes_per_batch).min(ordered_items.len());
12316            while end < shape_limit {
12317                match ordered_items[end].1 {
12318                    SegmentDrawItem::Shape(next_index)
12319                        if supported_blend_mode(shapes[next_index].blend_mode) == blend_mode =>
12320                    {
12321                        end += 1;
12322                    }
12323                    _ => break,
12324                }
12325            }
12326            Some((
12327                SegmentBatchPlan::Shape {
12328                    start,
12329                    end,
12330                    blend_mode,
12331                },
12332                end,
12333            ))
12334        }
12335        SegmentDrawItem::Image(index) => {
12336            let blend_mode = supported_blend_mode(images[index].blend_mode);
12337            let mut end = start + 1;
12338            while end < ordered_items.len() {
12339                match ordered_items[end].1 {
12340                    SegmentDrawItem::Image(next_index)
12341                        if supported_blend_mode(images[next_index].blend_mode) == blend_mode =>
12342                    {
12343                        end += 1;
12344                    }
12345                    _ => break,
12346                }
12347            }
12348            Some((
12349                SegmentBatchPlan::Image {
12350                    start,
12351                    end,
12352                    blend_mode,
12353                },
12354                end,
12355            ))
12356        }
12357        SegmentDrawItem::Text(_) => {
12358            let mut end = start + 1;
12359            while end < ordered_items.len() {
12360                if matches!(ordered_items[end].1, SegmentDrawItem::Text(_)) {
12361                    end += 1;
12362                } else {
12363                    break;
12364                }
12365            }
12366            Some((SegmentBatchPlan::Text { start, end }, end))
12367        }
12368        SegmentDrawItem::Composite(_) => {
12369            let mut end = start + 1;
12370            while end < ordered_items.len() {
12371                if matches!(ordered_items[end].1, SegmentDrawItem::Composite(_)) {
12372                    end += 1;
12373                } else {
12374                    break;
12375                }
12376            }
12377            Some((SegmentBatchPlan::Composite { start, end }, end))
12378        }
12379        SegmentDrawItem::ShaderComposite(_) => {
12380            let mut end = start + 1;
12381            while end < ordered_items.len() {
12382                if matches!(ordered_items[end].1, SegmentDrawItem::ShaderComposite(_)) {
12383                    end += 1;
12384                } else {
12385                    break;
12386                }
12387            }
12388            Some((SegmentBatchPlan::ShaderComposite { start, end }, end))
12389        }
12390        SegmentDrawItem::Retained(_) => {
12391            let mut end = start + 1;
12392            while end < ordered_items.len() {
12393                if matches!(ordered_items[end].1, SegmentDrawItem::Retained(_)) {
12394                    end += 1;
12395                } else {
12396                    break;
12397                }
12398            }
12399            Some((SegmentBatchPlan::Retained { start, end }, end))
12400        }
12401        SegmentDrawItem::Shadow(_) => None,
12402    }
12403}
12404
12405#[allow(clippy::too_many_arguments)]
12406fn collect_non_effect_segment_items(
12407    shapes: &[DrawShape],
12408    _images: &[ImageDraw],
12409    _texts: &[TextDraw],
12410    _shadow_draws: &[ShadowDraw],
12411    draw_ops: &[DrawOp],
12412    z_start: usize,
12413    z_end: usize,
12414    effect_z_ranges: &[Range<usize>],
12415    width: u32,
12416    height: u32,
12417    root_scale: f32,
12418    scratch: &mut Vec<(usize, SegmentDrawItem)>,
12419) {
12420    scratch.clear();
12421    let viewport = ViewportUniformParams {
12422        width,
12423        height,
12424        offset: [0.0, 0.0],
12425    };
12426
12427    scratch.extend(draw_ops.iter().filter_map(|op| {
12428        if op.z_index < z_start
12429            || op.z_index >= z_end
12430            || is_in_effect_range(op.z_index, effect_z_ranges)
12431        {
12432            return None;
12433        }
12434        let item = match op.kind {
12435            DrawOpKind::Shape(index) => {
12436                let shape = shapes.get(index)?;
12437                if !shape_draw_is_visible_in_viewport(shape, viewport, root_scale) {
12438                    return None;
12439                }
12440                SegmentDrawItem::Shape(index)
12441            }
12442            DrawOpKind::Image(index) => SegmentDrawItem::Image(index),
12443            DrawOpKind::Text(index) => SegmentDrawItem::Text(index),
12444            DrawOpKind::Shadow(index) => SegmentDrawItem::Shadow(index),
12445            DrawOpKind::Retained(index) => SegmentDrawItem::Retained(index),
12446        };
12447        Some((op.z_index, item))
12448    }));
12449}
12450
12451fn retain_renderable_shadow_items(
12452    ordered_items: &mut Vec<(usize, SegmentDrawItem)>,
12453    shadow_draws: &[ShadowDraw],
12454    width: u32,
12455    height: u32,
12456    root_scale: f32,
12457    max_texture_dim: u32,
12458) -> usize {
12459    let original_len = ordered_items.len();
12460    ordered_items.retain(|(_, item)| match item {
12461        SegmentDrawItem::Shadow(index) => shadow_draws.get(*index).is_some_and(|shadow| {
12462            shadow_draw_may_render(shadow, width, height, root_scale, max_texture_dim)
12463        }),
12464        _ => true,
12465    });
12466    original_len.saturating_sub(ordered_items.len())
12467}
12468
12469#[cfg(not(target_arch = "wasm32"))]
12470#[derive(Clone, Copy)]
12471struct SegmentDiagCounts {
12472    raw_shadow_items: usize,
12473    culled_shadow_items: usize,
12474    cached_shadow_composites: usize,
12475    composite_items: usize,
12476    shader_composite_items: usize,
12477}
12478
12479#[cfg(not(target_arch = "wasm32"))]
12480fn maybe_print_segment_diag(
12481    z_range: Range<usize>,
12482    ordered_items: &[(usize, SegmentDrawItem)],
12483    shapes: &[DrawShape],
12484    images: &[ImageDraw],
12485    counts: SegmentDiagCounts,
12486    batch_limits: ShapeBatchLimits,
12487) {
12488    if !cranpose_core::env_flag!("CRANPOSE_SEGMENT_DIAG") {
12489        return;
12490    }
12491    let line = SEGMENT_DIAG_LINES.fetch_add(1, Ordering::Relaxed);
12492    if line >= 64 {
12493        return;
12494    }
12495
12496    let remaining_shadow_items = ordered_items
12497        .iter()
12498        .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
12499        .count();
12500    let commands: Vec<_> =
12501        SegmentCommandIter::new(ordered_items, shapes, images, batch_limits).collect();
12502    let draw_chunks = commands
12503        .iter()
12504        .filter(|command| matches!(command, SegmentRenderCommand::DrawChunk(_)))
12505        .count();
12506    let shadow_commands = commands
12507        .iter()
12508        .filter(|command| matches!(command, SegmentRenderCommand::Shadow(_)))
12509        .count();
12510    let mut native_partitions = 0usize;
12511    let mut native_unfused_chunks = 0usize;
12512    for command in &commands {
12513        let SegmentRenderCommand::DrawChunk(chunk) = command else {
12514            continue;
12515        };
12516        match native_segment_fusion_partitions(ordered_items, shapes, chunk, batch_limits) {
12517            Ok(Some(partitions)) => native_partitions += partitions.len(),
12518            Ok(None) | Err(_) => native_unfused_chunks += 1,
12519        }
12520    }
12521
12522    eprintln!(
12523        "[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={}",
12524        z_range.start,
12525        z_range.end,
12526        ordered_items.len(),
12527        counts.raw_shadow_items,
12528        counts.culled_shadow_items,
12529        counts.cached_shadow_composites,
12530        remaining_shadow_items,
12531        counts.composite_items,
12532        counts.shader_composite_items,
12533        draw_chunks,
12534        shadow_commands,
12535        native_partitions,
12536        native_unfused_chunks,
12537    );
12538}
12539
12540pub(crate) fn has_backdrop_layer_in_range(
12541    backdrop_layers: &[BackdropLayer],
12542    z_start: usize,
12543    z_end: usize,
12544) -> bool {
12545    backdrop_layers
12546        .iter()
12547        .any(|layer| layer.z_index >= z_start && layer.z_index < z_end)
12548}
12549
12550pub(crate) fn scissor_rect_for_rect(
12551    rect: Rect,
12552    root_scale: f32,
12553    width: u32,
12554    height: u32,
12555) -> Option<(u32, u32, u32, u32)> {
12556    let mut left = canonicalize_device_coordinate(rect.x * root_scale);
12557    let mut top = canonicalize_device_coordinate(rect.y * root_scale);
12558    let mut right = canonicalize_device_coordinate((rect.x + rect.width) * root_scale);
12559    let mut bottom = canonicalize_device_coordinate((rect.y + rect.height) * root_scale);
12560
12561    left = left.max(0.0).min(width as f32).floor();
12562    top = top.max(0.0).min(height as f32).floor();
12563    right = right.max(0.0).min(width as f32).ceil();
12564    bottom = bottom.max(0.0).min(height as f32).ceil();
12565
12566    if right <= left || bottom <= top {
12567        return None;
12568    }
12569
12570    Some((
12571        left as u32,
12572        top as u32,
12573        (right - left) as u32,
12574        (bottom - top) as u32,
12575    ))
12576}
12577
12578fn scissor_rect_for_layer(
12579    rect: Rect,
12580    clip: Option<Rect>,
12581    root_scale: f32,
12582    width: u32,
12583    height: u32,
12584) -> Option<(u32, u32, u32, u32)> {
12585    let clipped_rect = match clip {
12586        Some(clip_rect) => rect.intersect(clip_rect)?,
12587        None => rect,
12588    };
12589
12590    scissor_rect_for_rect(clipped_rect, root_scale, width, height)
12591}
12592
12593fn tint_for_image(
12594    color_filter: Option<ColorFilter>,
12595    alpha: f32,
12596) -> ([f32; 4], Option<ColorFilter>) {
12597    let alpha = alpha.clamp(0.0, 1.0);
12598    match color_filter {
12599        Some(filter) if filter.supports_gpu_vertex_modulation() => {
12600            let Some(tint) = filter.gpu_vertex_tint() else {
12601                return ([1.0, 1.0, 1.0, alpha], Some(filter));
12602            };
12603            (
12604                [
12605                    tint[0].clamp(0.0, 1.0),
12606                    tint[1].clamp(0.0, 1.0),
12607                    tint[2].clamp(0.0, 1.0),
12608                    (tint[3] * alpha).clamp(0.0, 1.0),
12609                ],
12610                None,
12611            )
12612        }
12613        Some(filter) => ([1.0, 1.0, 1.0, alpha], Some(filter)),
12614        None => ([1.0, 1.0, 1.0, alpha], None),
12615    }
12616}
12617
12618fn image_uv_rect(image: &ImageBitmap, src_rect: Option<Rect>) -> Option<ImageUvRect> {
12619    let Some(src) = src_rect else {
12620        return Some(ImageUvRect {
12621            min: [0.0, 0.0],
12622            max: [1.0, 1.0],
12623            sample_bounds: [0.0, 0.0, 1.0, 1.0],
12624        });
12625    };
12626
12627    let (u_min, u_max, u_bound_min, u_bound_max) =
12628        source_axis_uv(src.x, src.width, image.width() as f32)?;
12629    let (v_min, v_max, v_bound_min, v_bound_max) =
12630        source_axis_uv(src.y, src.height, image.height() as f32)?;
12631
12632    Some(ImageUvRect {
12633        min: [u_min, v_min],
12634        max: [u_max, v_max],
12635        sample_bounds: [u_bound_min, v_bound_min, u_bound_max, v_bound_max],
12636    })
12637}
12638
12639/// Normalises an atlas entry against `atlas_size`, the side length of the
12640/// texture the entry was placed in. The atlas grows on overflow, so the size
12641/// has to be read from the live atlas rather than a constant — a UV computed
12642/// against the wrong size samples the wrong glyph.
12643fn glyph_atlas_uv_rect(entry: GlyphAtlasEntry, atlas_size: u32) -> ImageUvRect {
12644    let atlas_width = atlas_size as f32;
12645    let atlas_height = atlas_size as f32;
12646    let min = [entry.x as f32 / atlas_width, entry.y as f32 / atlas_height];
12647    let max = [
12648        (entry.x + entry.width) as f32 / atlas_width,
12649        (entry.y + entry.height) as f32 / atlas_height,
12650    ];
12651    let center_min = [
12652        (entry.x as f32 + 0.5) / atlas_width,
12653        (entry.y as f32 + 0.5) / atlas_height,
12654    ];
12655    let center_max = [
12656        (entry.x as f32 + entry.width as f32 - 0.5).max(entry.x as f32 + 0.5) / atlas_width,
12657        (entry.y as f32 + entry.height as f32 - 0.5).max(entry.y as f32 + 0.5) / atlas_height,
12658    ];
12659    ImageUvRect {
12660        min,
12661        max,
12662        sample_bounds: [center_min[0], center_min[1], center_max[0], center_max[1]],
12663    }
12664}
12665
12666fn snap_nearest_image_to_device_pixels(image: &mut ImageDraw, root_scale: f32) {
12667    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
12668        return;
12669    }
12670
12671    let Some(rect) = axis_aligned_quad_rect(image.quad) else {
12672        return;
12673    };
12674
12675    let left_px = (rect.x * root_scale).round();
12676    let top_px = (rect.y * root_scale).round();
12677    let width_px = (rect.width * root_scale).round().max(1.0);
12678    let height_px = (rect.height * root_scale).round().max(1.0);
12679    let snapped = Rect {
12680        x: left_px / root_scale,
12681        y: top_px / root_scale,
12682        width: width_px / root_scale,
12683        height: height_px / root_scale,
12684    };
12685
12686    image.rect = snapped;
12687    image.local_rect = Rect {
12688        x: image.local_rect.x + snapped.x - rect.x,
12689        y: image.local_rect.y + snapped.y - rect.y,
12690        width: snapped.width,
12691        height: snapped.height,
12692    };
12693    image.quad = crate::rect_to_quad(snapped);
12694}
12695
12696fn nearest_image_device_quad(image: &ImageDraw, root_scale: f32) -> Option<[[f32; 2]; 4]> {
12697    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
12698        return None;
12699    }
12700
12701    let rect = axis_aligned_quad_rect(image.quad)?;
12702    let left_px = (rect.x * root_scale).round();
12703    let top_px = (rect.y * root_scale).round();
12704    let width_px = (rect.width * root_scale).round().max(1.0);
12705    let height_px = (rect.height * root_scale).round().max(1.0);
12706    let right_px = left_px + width_px;
12707    let bottom_px = top_px + height_px;
12708    Some([
12709        [left_px, top_px],
12710        [right_px, top_px],
12711        [left_px, bottom_px],
12712        [right_px, bottom_px],
12713    ])
12714}
12715
12716fn source_axis_uv(start: f32, extent: f32, image_extent: f32) -> Option<(f32, f32, f32, f32)> {
12717    if !start.is_finite()
12718        || !extent.is_finite()
12719        || !image_extent.is_finite()
12720        || extent == 0.0
12721        || image_extent <= 0.0
12722    {
12723        return None;
12724    }
12725
12726    let end = start + extent;
12727    let edge_min = start.min(end).clamp(0.0, image_extent);
12728    let edge_max = start.max(end).clamp(0.0, image_extent);
12729    if edge_max <= edge_min {
12730        return None;
12731    }
12732
12733    let center_min = edge_min + 0.5;
12734    let center_max = edge_max - 0.5;
12735    let (bound_min, bound_max) = if center_min <= center_max {
12736        (center_min, center_max)
12737    } else {
12738        let center = (edge_min + edge_max) * 0.5;
12739        (center, center)
12740    };
12741
12742    Some((
12743        edge_min / image_extent,
12744        edge_max / image_extent,
12745        bound_min / image_extent,
12746        bound_max / image_extent,
12747    ))
12748}
12749
12750fn apply_filter_to_bitmap(image: &ImageBitmap, filter: ColorFilter) -> Result<ImageBitmap, String> {
12751    let mut filtered = Vec::with_capacity(image.pixels().len());
12752    for pixel in image.pixels().chunks_exact(4) {
12753        let rgba = [
12754            pixel[0] as f32 / 255.0,
12755            pixel[1] as f32 / 255.0,
12756            pixel[2] as f32 / 255.0,
12757            pixel[3] as f32 / 255.0,
12758        ];
12759        let out = filter.apply_rgba(rgba);
12760        filtered.push((out[0].clamp(0.0, 1.0) * 255.0).round() as u8);
12761        filtered.push((out[1].clamp(0.0, 1.0) * 255.0).round() as u8);
12762        filtered.push((out[2].clamp(0.0, 1.0) * 255.0).round() as u8);
12763        filtered.push((out[3].clamp(0.0, 1.0) * 255.0).round() as u8);
12764    }
12765    ImageBitmap::from_rgba8(image.width(), image.height(), filtered)
12766        .map_err(|error| format!("failed to build filtered bitmap: {error}"))
12767}
12768
12769fn scissor_rect_for_image(
12770    image: &ImageDraw,
12771    root_scale: f32,
12772    width: u32,
12773    height: u32,
12774) -> Option<(u32, u32, u32, u32)> {
12775    scissor_rect_for_layer(image.rect, image.clip, root_scale, width, height)
12776}
12777
12778fn inner_shadow_composite_mask(
12779    shadow: &ShadowDraw,
12780    root_scale: f32,
12781) -> Option<RoundedCompositeMask> {
12782    if !shadow
12783        .shapes
12784        .iter()
12785        .any(|(_, mode)| *mode == BlendMode::DstOut)
12786    {
12787        return None;
12788    }
12789    let (fill, _) = shadow.shapes.first()?;
12790    let rect = fill.local_rect;
12791    if rect.width <= 0.0 || rect.height <= 0.0 {
12792        return None;
12793    }
12794
12795    let radii = fill.shape.map_or([0.0; 4], |rounded| {
12796        let resolved = rounded.resolve(rect.width, rect.height);
12797        [
12798            resolved.top_left * root_scale,
12799            resolved.top_right * root_scale,
12800            resolved.bottom_left * root_scale,
12801            resolved.bottom_right * root_scale,
12802        ]
12803    });
12804
12805    Some(RoundedCompositeMask {
12806        rect: [
12807            rect.x * root_scale,
12808            rect.y * root_scale,
12809            rect.width * root_scale,
12810            rect.height * root_scale,
12811        ],
12812        radii,
12813    })
12814}
12815
12816#[cfg(test)]
12817mod tests {
12818    use super::*;
12819    use crate::normalized_scene::visible_draw_rect;
12820    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
12821    use cranpose_render_common::graph::{DrawPrimitiveNode, IsolationReasons, TextPrimitiveNode};
12822    use cranpose_render_common::raster_cache::LayerRasterCacheHashes;
12823    use cranpose_render_common::scene_builder::build_graph_from_applier;
12824    use cranpose_ui::text::{
12825        AnnotatedString, BaselineShift, RangeStyle, Shadow, SpanStyle, TextDecoration,
12826        TextDrawStyle, TextGeometricTransform, TextMotion, TextUnit,
12827    };
12828    use cranpose_ui::{
12829        LayoutEngine, LazyColumn, LazyColumnSpec, Modifier, Size, Text, TextLayoutOptions,
12830        TextStyle,
12831    };
12832    use cranpose_ui_graphics::{
12833        Brush, Color, CornerRadii, DrawPrimitive, Rect, RenderEffect, RoundedCornerShape,
12834        RuntimeShader,
12835    };
12836
12837    fn chunk(batches: &[SegmentBatchPlan]) -> SegmentDrawChunkPlan {
12838        let mut chunk = SegmentDrawChunkPlan::default();
12839        for batch in batches {
12840            chunk.push(*batch);
12841        }
12842        chunk
12843    }
12844
12845    fn with_test_app_context<R>(block: impl FnOnce() -> R) -> R {
12846        let app_context = cranpose_ui::AppContext::new();
12847        app_context.enter(block)
12848    }
12849
12850    fn assert_snap_anchor_close(actual: Option<SnapAnchor>, expected_origin: Point, message: &str) {
12851        let Some(actual) = actual else {
12852            panic!("{message}: missing snap anchor");
12853        };
12854        let expected = SnapAnchor::rigid(expected_origin);
12855        assert_eq!(
12856            actual.device_pixel_step, expected.device_pixel_step,
12857            "{message}: device pixel step changed"
12858        );
12859        assert!(
12860            (actual.origin.x - expected.origin.x).abs() <= 1e-4
12861                && (actual.origin.y - expected.origin.y).abs() <= 1e-4,
12862            "{message}: expected origin {:?}, got {:?}",
12863            expected.origin,
12864            actual.origin
12865        );
12866    }
12867
12868    fn effect_layer(z_start: usize, z_end: usize) -> EffectLayer {
12869        EffectLayer {
12870            rect: Rect {
12871                x: 0.0,
12872                y: 0.0,
12873                width: 10.0,
12874                height: 10.0,
12875            },
12876            clip: None,
12877            snap_anchor: None,
12878            effect: Some(RenderEffect::blur(4.0)),
12879            blend_mode: BlendMode::SrcOver,
12880            composite_alpha: 1.0,
12881            z_start,
12882            z_end,
12883            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
12884        }
12885    }
12886
12887    #[test]
12888    fn direct_shader_composite_accepts_box4_when_viewport_preserves_source_pixels() {
12889        assert_eq!(
12890            direct_shader_composite_viewport(
12891                1.0,
12892                BlendMode::SrcOver,
12893                Some((12.0, 18.0, 64.0, 32.0)),
12894                CompositeSampleMode::Box4,
12895                (64, 32),
12896            ),
12897            Some((12.0, 18.0, 64.0, 32.0))
12898        );
12899    }
12900
12901    #[test]
12902    fn direct_shader_composite_rejects_box4_when_viewport_resamples_source() {
12903        assert_eq!(
12904            direct_shader_composite_viewport(
12905                1.0,
12906                BlendMode::SrcOver,
12907                Some((12.0, 18.0, 64.5, 32.0)),
12908                CompositeSampleMode::Box4,
12909                (64, 32),
12910            ),
12911            None
12912        );
12913        assert_eq!(
12914            direct_shader_composite_viewport(
12915                1.0,
12916                BlendMode::SrcOver,
12917                Some((12.25, 18.0, 64.0, 32.0)),
12918                CompositeSampleMode::Box4,
12919                (64, 32),
12920            ),
12921            None
12922        );
12923    }
12924
12925    fn test_text_draw(rect: Rect, text_motion: TextMotion) -> TextDraw {
12926        let mut text_style = TextStyle::default();
12927        text_style.paragraph_style.text_motion = Some(text_motion);
12928        TextDraw {
12929            node_id: 42,
12930            rect,
12931            snap_anchor: None,
12932            translated_content_context: false,
12933            text: Arc::new(AnnotatedString::new("stable markdown row".to_string()).render_string()),
12934            color: Color::WHITE,
12935            text_style,
12936            font_size: 14.0,
12937            scale: 1.0,
12938            layout_options: TextLayoutOptions::default(),
12939            z_index: 0,
12940            clip: None,
12941        }
12942    }
12943
12944    #[test]
12945    fn static_text_image_cache_key_ignores_absolute_scroll_position() {
12946        let base = test_text_draw(
12947            Rect {
12948                x: 12.25,
12949                y: 40.75,
12950                width: 220.0,
12951                height: 24.0,
12952            },
12953            TextMotion::Static,
12954        );
12955        let scrolled = test_text_draw(
12956            Rect {
12957                x: 12.75,
12958                y: -318.5,
12959                width: 220.0,
12960                height: 24.0,
12961            },
12962            TextMotion::Static,
12963        );
12964
12965        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, true);
12966        let scrolled_key = GpuRenderer::text_image_cache_key(&scrolled, scrolled.rect, 1.0, true);
12967
12968        assert_eq!(
12969            base_key, scrolled_key,
12970            "scrolling static text must reuse the same raster cache entry"
12971        );
12972    }
12973
12974    #[test]
12975    fn static_text_glyph_run_cache_key_ignores_absolute_scroll_position() {
12976        let base = test_text_draw(
12977            Rect {
12978                x: 12.25,
12979                y: 40.75,
12980                width: 220.0,
12981                height: 24.0,
12982            },
12983            TextMotion::Static,
12984        );
12985        let scrolled = test_text_draw(
12986            Rect {
12987                x: 12.75,
12988                y: -318.5,
12989                width: 220.0,
12990                height: 24.0,
12991            },
12992            TextMotion::Static,
12993        );
12994
12995        let base_key = GpuRenderer::text_glyph_run_cache_key(&base, base.rect, 1.0, true);
12996        let scrolled_key =
12997            GpuRenderer::text_glyph_run_cache_key(&scrolled, scrolled.rect, 1.0, true);
12998
12999        assert_eq!(
13000            base_key, scrolled_key,
13001            "scrolling static text must reuse the same retained glyph run"
13002        );
13003    }
13004
13005    #[test]
13006    fn static_multiline_text_glyph_source_keeps_full_text_when_image_source_slices() {
13007        let rect = Rect {
13008            x: 8.0,
13009            y: 100.0,
13010            width: 240.0,
13011            height: 1_000.0,
13012        };
13013        let mut draw = test_text_draw(rect, TextMotion::Static);
13014        let lines = (0..100)
13015            .map(|line| format!("line-{line:03}"))
13016            .collect::<Vec<_>>()
13017            .join("\n");
13018        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
13019
13020        let raster_rect = Rect {
13021            x: 16.0,
13022            y: 200.0,
13023            width: 480.0,
13024            height: 2_000.0,
13025        };
13026        let clipped = clipped_text_raster_source(
13027            &draw,
13028            rect,
13029            raster_rect,
13030            Some(Rect {
13031                x: 0.0,
13032                y: 610.0,
13033                width: 800.0,
13034                height: 40.0,
13035            }),
13036            2.0,
13037            true,
13038        );
13039        let glyph = text_glyph_raster_source(&draw, raster_rect);
13040
13041        assert!(
13042            matches!(clipped.draw, Cow::Owned(_)),
13043            "the image source should still slice large clipped multiline text"
13044        );
13045        assert!(
13046            matches!(glyph.draw, Cow::Borrowed(_)),
13047            "the glyph source must keep a stable full-text run key while scrolling"
13048        );
13049
13050        let clipped_key = GpuRenderer::text_glyph_run_cache_key(
13051            clipped.draw.as_ref(),
13052            clipped.raster_rect,
13053            2.0,
13054            true,
13055        );
13056        let glyph_key = GpuRenderer::text_glyph_run_cache_key(
13057            glyph.draw.as_ref(),
13058            glyph.raster_rect,
13059            2.0,
13060            true,
13061        );
13062
13063        assert_ne!(
13064            clipped_key, glyph_key,
13065            "image slicing must not force glyph rendering onto per-scroll line-window cache keys"
13066        );
13067    }
13068
13069    #[cfg(not(target_arch = "wasm32"))]
13070    #[test]
13071    fn retained_glyph_viewport_offsets_relative_vertices_by_source_origin() {
13072        let viewport = ViewportUniformParams {
13073            width: 800,
13074            height: 600,
13075            offset: [10.0, 20.0],
13076        };
13077        let source = Rect {
13078            x: 40.0,
13079            y: 90.0,
13080            width: 120.0,
13081            height: 48.0,
13082        };
13083
13084        let retained = GpuRenderer::retained_glyph_viewport(viewport, source);
13085
13086        assert_eq!(retained.width, viewport.width);
13087        assert_eq!(retained.height, viewport.height);
13088        assert_eq!(retained.offset, [-30.0, -70.0]);
13089    }
13090
13091    #[cfg(not(target_arch = "wasm32"))]
13092    #[test]
13093    fn tiny_text_glyph_runs_stay_in_shared_uploads() {
13094        assert!(
13095            !should_use_retained_text_glyph_run(8, None),
13096            "tiny labels must stay in the shared fused batch"
13097        );
13098    }
13099
13100    #[cfg(not(target_arch = "wasm32"))]
13101    #[test]
13102    fn line_sized_text_glyph_runs_stay_in_shared_uploads() {
13103        assert!(
13104            !should_use_retained_text_glyph_run(64, None),
13105            "Markdown scroll frames contain many line-sized text runs; retaining each one creates per-run buffer binds instead of one shared glyph batch"
13106        );
13107    }
13108
13109    #[cfg(not(target_arch = "wasm32"))]
13110    #[test]
13111    fn large_clipped_text_glyph_runs_stay_in_shared_uploads() {
13112        assert!(
13113            !should_use_retained_text_glyph_run(
13114                MIN_RETAINED_TEXT_GLYPH_QUADS.saturating_mul(2),
13115                Some(Rect {
13116                    x: 0.0,
13117                    y: 0.0,
13118                    width: 200.0,
13119                    height: 100.0,
13120                }),
13121            ),
13122            "clipped lazy-list text must not draw a full retained run outside the viewport"
13123        );
13124    }
13125
13126    #[test]
13127    fn normal_text_glyph_draw_skips_offscreen_prewarm_candidates() {
13128        assert_eq!(
13129            text_glyph_draw_action(false, true, false),
13130            TextGlyphDrawAction::Skip,
13131            "normal draw traversal must not prepare offscreen text"
13132        );
13133    }
13134
13135    #[test]
13136    fn bounded_text_glyph_prewarm_admits_offscreen_candidates() {
13137        assert_eq!(
13138            text_glyph_draw_action(false, true, true),
13139            TextGlyphDrawAction::PrewarmOffscreen,
13140            "only the bounded prewarm path may prepare offscreen text"
13141        );
13142    }
13143
13144    #[test]
13145    fn visible_text_glyph_draws_are_always_admitted() {
13146        assert_eq!(
13147            text_glyph_draw_action(true, false, false),
13148            TextGlyphDrawAction::DrawVisible
13149        );
13150        assert_eq!(
13151            text_glyph_draw_action(true, true, true),
13152            TextGlyphDrawAction::DrawVisible
13153        );
13154    }
13155
13156    #[cfg(not(target_arch = "wasm32"))]
13157    #[test]
13158    fn offscreen_text_prewarm_skips_large_uncached_text_runs() {
13159        assert!(
13160            !offscreen_text_glyph_prewarm_work_is_bounded(
13161                None,
13162                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS + 1,
13163            ),
13164            "offscreen prewarm must not collect large uncached text runs in an input frame"
13165        );
13166    }
13167
13168    #[cfg(not(target_arch = "wasm32"))]
13169    #[test]
13170    fn offscreen_text_prewarm_admits_small_uncached_text_runs() {
13171        assert!(
13172            offscreen_text_glyph_prewarm_work_is_bounded(
13173                None,
13174                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
13175            ),
13176            "small labels can be warmed without risking a frame-budget spike"
13177        );
13178    }
13179
13180    #[cfg(not(target_arch = "wasm32"))]
13181    #[test]
13182    fn offscreen_text_prewarm_skips_large_cached_runs_without_quads() {
13183        assert!(
13184            !offscreen_text_glyph_prewarm_work_is_bounded(
13185                Some(MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS + 1),
13186                0,
13187            ),
13188            "cached glyph placements can still be too large to prepare during input frames"
13189        );
13190    }
13191
13192    #[cfg(not(target_arch = "wasm32"))]
13193    #[test]
13194    fn offscreen_text_prewarm_stops_after_candidate_budget() {
13195        assert!(
13196            offscreen_text_glyph_prewarm_budget_exhausted(
13197                Instant::now(),
13198                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES,
13199            ),
13200            "prewarm must be bounded by candidate count even when each candidate is cheap"
13201        );
13202    }
13203
13204    #[test]
13205    fn clipped_cached_glyph_quads_are_filtered_to_viewport() {
13206        fn quad(y: i32) -> CachedTextGlyphQuad {
13207            CachedTextGlyphQuad {
13208                x: 8,
13209                y,
13210                width: 20,
13211                height: 10,
13212                color: (1.0, 1.0, 1.0, 1.0),
13213                uv: ImageUvRect {
13214                    min: [0.0, 0.0],
13215                    max: [1.0, 1.0],
13216                    sample_bounds: [0.0, 0.0, 1.0, 1.0],
13217                },
13218            }
13219        }
13220
13221        let source = Rect {
13222            x: 0.0,
13223            y: 0.0,
13224            width: 320.0,
13225            height: 400.0,
13226        };
13227        let clip = Some(Rect {
13228            x: 0.0,
13229            y: 0.0,
13230            width: 320.0,
13231            height: 80.0,
13232        });
13233        let viewport = ViewportUniformParams {
13234            width: 320,
13235            height: 80,
13236            offset: [0.0, 0.0],
13237        };
13238
13239        assert!(cached_text_glyph_quad_is_visible_in_viewport(
13240            source,
13241            &quad(40),
13242            clip,
13243            viewport,
13244            1.0,
13245        ));
13246        assert!(
13247            !cached_text_glyph_quad_is_visible_in_viewport(source, &quad(140), clip, viewport, 1.0,),
13248            "glyphs outside the effective clip should not enter the frame command stream"
13249        );
13250    }
13251
13252    #[test]
13253    fn small_scene_range_cache_miss_observes_first_render() {
13254        let key = LayerRasterCacheKey::scene_range(
13255            0xCACE,
13256            Rect {
13257                x: 0.0,
13258                y: 0.0,
13259                width: 120.0,
13260                height: 80.0,
13261            },
13262            (120, 80),
13263            ScaleBucket::from_scale(1.0),
13264        );
13265
13266        assert!(
13267            !first_cache_miss_admission(&key),
13268            "a small scene-range miss should render directly first instead of materializing a tiny one-frame retained target"
13269        );
13270        assert!(
13271            repeated_cache_miss_admission(&key),
13272            "a repeated small scene-range miss is stable enough to materialize into the retained cache"
13273        );
13274    }
13275
13276    #[test]
13277    fn large_scene_range_cache_miss_requires_repeated_stable_key() {
13278        let key = LayerRasterCacheKey::scene_range(
13279            0xCACE,
13280            Rect {
13281                x: 0.0,
13282                y: 0.0,
13283                width: 1200.0,
13284                height: 900.0,
13285            },
13286            (1200, 900),
13287            ScaleBucket::from_scale(1.0),
13288        );
13289
13290        assert!(
13291            !first_cache_miss_admission(&key),
13292            "a large first scene-range miss should render directly instead of materializing a multi-MB one-frame cache entry"
13293        );
13294        assert!(
13295            repeated_cache_miss_admission(&key),
13296            "a repeated scene-range miss is stable enough to materialize into the retained cache"
13297        );
13298    }
13299
13300    #[test]
13301    fn renderer_warmup_frame_is_requested_for_cache_miss_stats_only() {
13302        let stats = gpu_stats::FrameStats::default();
13303        let mut snapshot = stats.snapshot();
13304        assert!(
13305            !frame_stats_need_warmup_frame(&snapshot),
13306            "a clean frame must not keep a static scene redrawing"
13307        );
13308
13309        snapshot.layer_cache_misses = 1;
13310        assert!(frame_stats_need_warmup_frame(&snapshot));
13311        snapshot.layer_cache_misses = 0;
13312
13313        snapshot.shadow_shape_cache_misses = 1;
13314        assert!(frame_stats_need_warmup_frame(&snapshot));
13315        snapshot.shadow_shape_cache_misses = 0;
13316
13317        snapshot.text_image_cache_misses = 1;
13318        assert!(frame_stats_need_warmup_frame(&snapshot));
13319        snapshot.text_image_cache_misses = 0;
13320
13321        snapshot.text_glyph_atlas_misses = 1;
13322        assert!(frame_stats_need_warmup_frame(&snapshot));
13323    }
13324
13325    #[test]
13326    fn renderer_warmup_budget_is_consumed_by_a_repeated_cache_miss() {
13327        let stats = gpu_stats::FrameStats::default();
13328        let mut snapshot = stats.snapshot();
13329        snapshot.layer_cache_misses = 1;
13330        let mut pending_frames = 0;
13331
13332        update_frame_warmup_budget(&mut pending_frames, &snapshot);
13333        assert_eq!(pending_frames, CACHE_MISS_WARMUP_FRAMES);
13334
13335        update_frame_warmup_budget(&mut pending_frames, &snapshot);
13336        assert_eq!(
13337            pending_frames, 0,
13338            "a cache miss during the warmup frame must not replenish its budget"
13339        );
13340    }
13341
13342    #[test]
13343    fn non_scene_layer_surface_cache_miss_admits_first_render() {
13344        let key = LayerRasterCacheKey::new(
13345            Some(77),
13346            0xC0FFEE,
13347            0,
13348            Rect {
13349                x: 0.0,
13350                y: 0.0,
13351                width: 120.0,
13352                height: 80.0,
13353            },
13354            (120, 80),
13355            ScaleBucket::from_scale(1.0),
13356        );
13357
13358        assert!(
13359            first_cache_miss_admission(&key),
13360            "ordinary retained layer surfaces should still cache on first miss"
13361        );
13362    }
13363
13364    #[test]
13365    fn text_image_cache_key_is_content_addressed_not_node_addressed() {
13366        let first = test_text_draw(
13367            Rect {
13368                x: 12.25,
13369                y: 40.75,
13370                width: 220.0,
13371                height: 24.0,
13372            },
13373            TextMotion::Static,
13374        );
13375        let mut second = first.clone();
13376        second.node_id = first.node_id + 1;
13377
13378        let first_key = GpuRenderer::text_image_cache_key(&first, first.rect, 1.0, true);
13379        let second_key = GpuRenderer::text_image_cache_key(&second, second.rect, 1.0, true);
13380
13381        assert_eq!(
13382            first_key, second_key,
13383            "text raster cache keys must be based on rendered pixels, not node identity"
13384        );
13385    }
13386
13387    #[test]
13388    fn animated_text_image_cache_key_keeps_fractional_phase_only() {
13389        let base = test_text_draw(
13390            Rect {
13391                x: 12.25,
13392                y: 40.75,
13393                width: 220.0,
13394                height: 24.0,
13395            },
13396            TextMotion::Animated,
13397        );
13398        let integer_translated = test_text_draw(
13399            Rect {
13400                x: 44.25,
13401                y: 88.75,
13402                width: 220.0,
13403                height: 24.0,
13404            },
13405            TextMotion::Animated,
13406        );
13407        let phase_shifted = test_text_draw(
13408            Rect {
13409                x: 44.5,
13410                y: 88.75,
13411                width: 220.0,
13412                height: 24.0,
13413            },
13414            TextMotion::Animated,
13415        );
13416
13417        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, false);
13418        let translated_key = GpuRenderer::text_image_cache_key(
13419            &integer_translated,
13420            integer_translated.rect,
13421            1.0,
13422            false,
13423        );
13424        let phase_shifted_key =
13425            GpuRenderer::text_image_cache_key(&phase_shifted, phase_shifted.rect, 1.0, false);
13426
13427        assert_eq!(
13428            base_key, translated_key,
13429            "integer translation should not invalidate animated text raster cache entries"
13430        );
13431        assert_ne!(
13432            base_key, phase_shifted_key,
13433            "fractional phase affects animated text rasterization and must stay in the key"
13434        );
13435    }
13436
13437    #[test]
13438    fn animated_translated_text_raster_geometry_applies_snap_anchor() {
13439        let mut base = test_text_draw(
13440            Rect {
13441                x: 14.25,
13442                y: 16.50,
13443                width: 220.0,
13444                height: 24.0,
13445            },
13446            TextMotion::Animated,
13447        );
13448        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.50)));
13449
13450        let mut scrolled = test_text_draw(
13451            Rect {
13452                x: 14.25,
13453                y: 15.80,
13454                width: 220.0,
13455                height: 24.0,
13456            },
13457            TextMotion::Animated,
13458        );
13459        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 15.80)));
13460
13461        let (base_logical, base_raster, _, _, base_static) =
13462            text_raster_geometry_for_draw(&base, 1.0).expect("base text geometry");
13463        let (scrolled_logical, scrolled_raster, _, _, scrolled_static) =
13464            text_raster_geometry_for_draw(&scrolled, 1.0).expect("scrolled text geometry");
13465
13466        assert!(!base_static);
13467        assert!(!scrolled_static);
13468        assert!((base_logical.x - 14.0).abs() < f32::EPSILON);
13469        assert!((base_logical.y - 17.0).abs() < f32::EPSILON);
13470        assert!((scrolled_logical.x - 14.0).abs() < f32::EPSILON);
13471        assert!((scrolled_logical.y - 16.0).abs() < f32::EPSILON);
13472        assert_eq!(base_raster.x.fract(), 0.0);
13473        assert_eq!(base_raster.y.fract(), 0.0);
13474        assert_eq!(scrolled_raster.x.fract(), 0.0);
13475        assert_eq!(scrolled_raster.y.fract(), 0.0);
13476
13477        let base_key = GpuRenderer::text_image_cache_key(&base, base_raster, 1.0, false);
13478        let scrolled_key =
13479            GpuRenderer::text_image_cache_key(&scrolled, scrolled_raster, 1.0, false);
13480        assert_eq!(
13481            base_key, scrolled_key,
13482            "translated animated text should keep a stable raster phase while scrolling"
13483        );
13484    }
13485
13486    #[test]
13487    fn translated_static_text_moves_one_device_pixel_at_half_pixel_phase() {
13488        let root_scale = 1.25;
13489        let mut base = test_text_draw(
13490            Rect {
13491                x: 14.0,
13492                y: 276.0,
13493                width: 220.0,
13494                height: 24.0,
13495            },
13496            TextMotion::Static,
13497        );
13498        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.600_006)));
13499
13500        let mut scrolled = test_text_draw(
13501            Rect {
13502                x: 14.0,
13503                y: 275.2,
13504                width: 220.0,
13505                height: 24.0,
13506            },
13507            TextMotion::Static,
13508        );
13509        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 126.799_99)));
13510
13511        let (_, base_raster, _, _, _) =
13512            text_raster_geometry_for_draw(&base, root_scale).expect("base text geometry");
13513        let (_, scrolled_raster, _, _, _) =
13514            text_raster_geometry_for_draw(&scrolled, root_scale).expect("scrolled text geometry");
13515
13516        assert_eq!(
13517            base_raster.y - scrolled_raster.y,
13518            1.0,
13519            "one physical pixel of rigid scrolling must move static text by one raster pixel"
13520        );
13521    }
13522
13523    #[test]
13524    fn translated_text_snap_does_not_move_its_fixed_ancestor_clip() {
13525        let root_scale = 1.25;
13526        let fixed_clip = Rect {
13527            x: 8.0,
13528            y: 20.0,
13529            width: 300.0,
13530            height: 680.0,
13531        };
13532        let mut draw = test_text_draw(
13533            Rect {
13534                x: 14.0,
13535                y: 276.0,
13536                width: 220.0,
13537                height: 24.0,
13538            },
13539            TextMotion::Static,
13540        );
13541        draw.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.4)));
13542        draw.clip = Some(fixed_clip);
13543
13544        let (_, _, clip, _, _) =
13545            text_raster_geometry_for_draw(&draw, root_scale).expect("clipped text geometry");
13546
13547        assert_eq!(
13548            clip,
13549            Some(fixed_clip),
13550            "content pixel snapping must not translate a fixed ancestor clip"
13551        );
13552    }
13553
13554    #[test]
13555    fn clipped_static_multiline_text_raster_source_limits_visible_line_window() {
13556        let rect = Rect {
13557            x: 8.0,
13558            y: 100.0,
13559            width: 240.0,
13560            height: 1_000.0,
13561        };
13562        let mut draw = test_text_draw(rect, TextMotion::Static);
13563        let lines = (0..100)
13564            .map(|line| format!("line-{line:03}"))
13565            .collect::<Vec<_>>()
13566            .join("\n");
13567        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
13568
13569        let raster_rect = Rect {
13570            x: 16.0,
13571            y: 200.0,
13572            width: 480.0,
13573            height: 2_000.0,
13574        };
13575        let source = clipped_text_raster_source(
13576            &draw,
13577            rect,
13578            raster_rect,
13579            Some(Rect {
13580                x: 0.0,
13581                y: 610.0,
13582                width: 800.0,
13583                height: 40.0,
13584            }),
13585            2.0,
13586            true,
13587        );
13588
13589        let Cow::Owned(sliced_draw) = source.draw else {
13590            panic!("clipped static multiline text should rasterize only the visible line window");
13591        };
13592        let sliced_text = sliced_draw.text.text.as_str();
13593        assert!(sliced_text.contains("line-050"));
13594        assert!(sliced_text.contains("line-055"));
13595        assert!(!sliced_text.contains("line-000"));
13596        assert!(!sliced_text.contains("line-099"));
13597        assert_eq!(source.raster_rect.x, raster_rect.x);
13598        assert!(source.raster_rect.y > raster_rect.y);
13599        assert!(source.raster_rect.height < raster_rect.height);
13600    }
13601
13602    #[test]
13603    fn clipped_static_multiline_text_raster_source_slices_short_multiline_text() {
13604        let rect = Rect {
13605            x: 8.0,
13606            y: 100.0,
13607            width: 240.0,
13608            height: 320.0,
13609        };
13610        let mut draw = test_text_draw(rect, TextMotion::Static);
13611        let lines = (0..24)
13612            .map(|line| format!("code-line-{line:02}"))
13613            .collect::<Vec<_>>()
13614            .join("\n");
13615        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
13616
13617        let raster_rect = Rect {
13618            x: 16.0,
13619            y: 200.0,
13620            width: 480.0,
13621            height: 640.0,
13622        };
13623        let source = clipped_text_raster_source(
13624            &draw,
13625            rect,
13626            raster_rect,
13627            Some(Rect {
13628                x: 0.0,
13629                y: 190.0,
13630                width: 800.0,
13631                height: 120.0,
13632            }),
13633            2.0,
13634            true,
13635        );
13636
13637        let Cow::Owned(sliced_draw) = source.draw else {
13638            panic!("clipped multiline text should rasterize only the visible line window");
13639        };
13640        assert!(sliced_draw.text.text.as_str().contains("code-line-06"));
13641        assert!(!sliced_draw.text.text.as_str().contains("code-line-00"));
13642        assert!(!sliced_draw.text.text.as_str().contains("code-line-23"));
13643        assert_eq!(source.raster_rect.x, raster_rect.x);
13644        assert!(source.raster_rect.y > raster_rect.y);
13645        assert!(source.raster_rect.height < raster_rect.height);
13646    }
13647
13648    #[test]
13649    fn text_line_index_cache_reuses_retained_index_for_same_text_instance() {
13650        let mut cache = TextLineIndexCache::new(4);
13651        let text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
13652
13653        let first = cache.line_starts(&text);
13654        let second = cache.line_starts(&text);
13655
13656        assert_eq!(first.as_ref(), &[0, 2, 4]);
13657        assert!(
13658            Rc::ptr_eq(&first, &second),
13659            "retained text should not rebuild its line index on every clipped frame"
13660        );
13661    }
13662
13663    #[test]
13664    fn text_line_index_cache_is_retained_text_instance_local() {
13665        let mut cache = TextLineIndexCache::new(4);
13666        let first_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
13667        let second_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
13668
13669        let first = cache.line_starts(&first_text);
13670        let second = cache.line_starts(&second_text);
13671
13672        assert_eq!(first.as_ref(), second.as_ref());
13673        assert!(
13674            !Rc::ptr_eq(&first, &second),
13675            "line index lookup should not hash large text contents to find unrelated retained nodes"
13676        );
13677    }
13678
13679    #[test]
13680    fn device_pixel_bounds_for_rect_snaps_origin_and_extents() {
13681        let bounds = device_pixel_bounds_for_rect(
13682            Rect {
13683                x: 10.25,
13684                y: 14.6,
13685                width: 20.1,
13686                height: 9.2,
13687            },
13688            200,
13689            120,
13690            2.0,
13691        )
13692        .expect("rect should intersect the viewport");
13693
13694        assert_eq!(
13695            bounds,
13696            DevicePixelBounds {
13697                x: 20.0,
13698                y: 29.0,
13699                width: 41,
13700                height: 19,
13701            }
13702        );
13703    }
13704
13705    #[test]
13706    fn visible_layer_rect_intersects_clip_and_viewport() {
13707        let visible = visible_layer_rect(
13708            Rect {
13709                x: -10.0,
13710                y: 5.0,
13711                width: 80.0,
13712                height: 40.0,
13713            },
13714            Some(Rect {
13715                x: 4.0,
13716                y: 8.0,
13717                width: 20.0,
13718                height: 50.0,
13719            }),
13720            2.0,
13721            60,
13722            40,
13723        )
13724        .expect("visible rect");
13725
13726        assert_eq!(
13727            visible,
13728            Rect {
13729                x: 4.0,
13730                y: 8.0,
13731                width: 20.0,
13732                height: 12.0,
13733            }
13734        );
13735    }
13736
13737    #[test]
13738    fn clamp_effect_surface_scale_caps_large_surfaces_but_keeps_base_scale() {
13739        let clamped = clamp_effect_surface_scale(
13740            Rect {
13741                x: 0.0,
13742                y: 0.0,
13743                width: 1200.0,
13744                height: 900.0,
13745            },
13746            1.0,
13747            8.0,
13748            16_384,
13749        );
13750
13751        assert!(
13752            clamped < 8.0,
13753            "large translated effect layers must be capped to avoid OOM, got {clamped}"
13754        );
13755        assert!(
13756            clamped >= 1.0,
13757            "effect surfaces must not fall below destination resolution, got {clamped}"
13758        );
13759    }
13760
13761    #[test]
13762    fn clamp_effect_surface_scale_keeps_decorated_text_capture_scale() {
13763        let clamped = clamp_effect_surface_scale(
13764            Rect {
13765                x: 0.0,
13766                y: 0.0,
13767                width: 446.0,
13768                height: 44.0,
13769            },
13770            1.0,
13771            9.0,
13772            16_384,
13773        );
13774
13775        assert_eq!(
13776            clamped, 9.0,
13777            "decorated text motion-stable captures must keep full scale"
13778        );
13779    }
13780
13781    fn backdrop_layer(z_index: usize) -> BackdropLayer {
13782        BackdropLayer {
13783            node_id: Some(700 + z_index),
13784            rect: Rect {
13785                x: 0.0,
13786                y: 0.0,
13787                width: 10.0,
13788                height: 10.0,
13789            },
13790            clip: None,
13791            snap_anchor: None,
13792            effect: RenderEffect::blur(2.0),
13793            z_index,
13794        }
13795    }
13796
13797    fn test_shape(z_index: usize, blend_mode: BlendMode) -> DrawShape {
13798        DrawShape {
13799            rect: Rect {
13800                x: 0.0,
13801                y: 0.0,
13802                width: 8.0,
13803                height: 8.0,
13804            },
13805            local_rect: Rect {
13806                x: 0.0,
13807                y: 0.0,
13808                width: 8.0,
13809                height: 8.0,
13810            },
13811            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
13812            snap_anchor: None,
13813            brush: Brush::solid(Color::BLACK),
13814            shape: None,
13815            stroke: None,
13816            arc: None,
13817            z_index,
13818            clip: None,
13819            blend_mode,
13820            motion_context_animated: false,
13821        }
13822    }
13823
13824    #[test]
13825    fn shape_shadow_content_hash_ignores_viewport_translation() {
13826        fn translate_shape(shape: &DrawShape, dx: f32, dy: f32) -> DrawShape {
13827            let mut translated = shape.clone();
13828            translated.rect.x += dx;
13829            translated.rect.y += dy;
13830            translated.local_rect.x += dx;
13831            translated.local_rect.y += dy;
13832            for point in &mut translated.quad {
13833                point[0] += dx;
13834                point[1] += dy;
13835            }
13836            translated.snap_anchor = translated.snap_anchor.map(|anchor| {
13837                SnapAnchor::rigid(Point::new(anchor.origin.x + dx, anchor.origin.y + dy))
13838            });
13839            translated.clip = translated.clip.map(|mut clip| {
13840                clip.x += dx;
13841                clip.y += dy;
13842                clip
13843            });
13844            translated
13845        }
13846
13847        let mut first = test_shape(1, BlendMode::SrcOver);
13848        first.rect = Rect {
13849            x: 10.0,
13850            y: 20.0,
13851            width: 80.0,
13852            height: 40.0,
13853        };
13854        first.local_rect = first.rect;
13855        first.quad = [[10.0, 20.0], [90.0, 20.0], [10.0, 60.0], [90.0, 60.0]];
13856        first.snap_anchor = Some(SnapAnchor::rigid(Point::new(7.0, 11.0)));
13857        first.shape = Some(RoundedCornerShape::uniform(8.0));
13858        first.clip = Some(Rect {
13859            x: 8.0,
13860            y: 18.0,
13861            width: 86.0,
13862            height: 44.0,
13863        });
13864        let mut cutout = test_shape(2, BlendMode::DstOut);
13865        cutout.rect = Rect {
13866            x: 18.0,
13867            y: 26.0,
13868            width: 62.0,
13869            height: 22.0,
13870        };
13871        cutout.local_rect = cutout.rect;
13872        cutout.quad = [[18.0, 26.0], [80.0, 26.0], [18.0, 48.0], [80.0, 48.0]];
13873        cutout.shape = Some(RoundedCornerShape::uniform(4.0));
13874
13875        let dx = 37.0;
13876        let dy = -11.5;
13877        let translated = translate_shape(&first, dx, dy);
13878        let translated_cutout = translate_shape(&cutout, dx, dy);
13879
13880        let root_scale = 1.25;
13881        let first_shapes = vec![
13882            (first.clone(), BlendMode::SrcOver),
13883            (cutout, BlendMode::DstOut),
13884        ];
13885        let translated_shapes = vec![
13886            (translated.clone(), BlendMode::SrcOver),
13887            (translated_cutout, BlendMode::DstOut),
13888        ];
13889
13890        let first_hash = shape_shadow_content_hash(&first_shapes, root_scale);
13891        let translated_hash = shape_shadow_content_hash(&translated_shapes, root_scale);
13892
13893        assert_eq!(first_hash, translated_hash);
13894
13895        let mut changed_shapes = translated_shapes;
13896        changed_shapes[0].0.rect.width += 1.0;
13897        let changed_hash = shape_shadow_content_hash(&changed_shapes, root_scale);
13898
13899        assert_ne!(first_hash, changed_hash);
13900    }
13901
13902    #[test]
13903    fn shape_shadow_content_hash_is_stable_under_fractional_scale_scroll() {
13904        // Regression: scrolling a shadowed panel on a fractional-scale display
13905        // (e.g. Xft.dpi 130 → scale ≈ 1.354) must not re-render the shadow blur
13906        // every frame. The production cache key derives its viewport offset from
13907        // FLOORED device-pixel bounds, so the residual subpixel phase used to leak
13908        // into the content hash and miss the cache on every scroll step.
13909        fn shadow_shapes_at(y: f32) -> Vec<(DrawShape, BlendMode)> {
13910            let mut shape = test_shape(1, BlendMode::SrcOver);
13911            shape.rect = Rect {
13912                x: 24.0,
13913                y,
13914                width: 180.0,
13915                height: 90.0,
13916            };
13917            shape.local_rect = shape.rect;
13918            shape.quad = crate::rect_to_quad(shape.rect);
13919            shape.shape = Some(RoundedCornerShape::uniform(14.0));
13920            vec![(shape, BlendMode::SrcOver)]
13921        }
13922
13923        let root_scale = 130.0f32 / 96.0;
13924        let blur_radius = 18.0f32;
13925        let pixel_radius = blur_radius * root_scale;
13926
13927        let key_at = |y: f32| {
13928            let shapes = shadow_shapes_at(y);
13929            let plan =
13930                shape_shadow_surface_plan(&shapes, None, blur_radius, 1600, 1600, root_scale, 8192)
13931                    .expect("surface plan");
13932            shape_shadow_surface_cache_key(
13933                &shapes,
13934                plan.source_device_bounds,
13935                pixel_radius,
13936                root_scale,
13937            )
13938            .expect("cache key")
13939        };
13940
13941        // Wheel scroll translates the panel by whole logical pixels; the device
13942        // subpixel phase changes on every step at fractional scale. The whole
13943        // cache key (content hash AND surface pixel size) must stay stable, or
13944        // every scroll frame re-renders the shadow blur.
13945        let base = key_at(640.0);
13946        for step in 1..=12 {
13947            let scrolled = key_at(640.0 - step as f32 * 4.0);
13948            assert_eq!(
13949                base, scrolled,
13950                "scrolled shadow cache key must stay stable at fractional scale (step {step})"
13951            );
13952        }
13953    }
13954
13955    #[test]
13956    fn shape_shadow_cache_key_uses_unclipped_source_bounds_for_scrolled_clip() {
13957        fn translated_card_shadow(y: f32) -> Vec<(DrawShape, BlendMode)> {
13958            let mut shape = test_shape(1, BlendMode::SrcOver);
13959            shape.rect = Rect {
13960                x: 24.0,
13961                y,
13962                width: 280.0,
13963                height: 120.0,
13964            };
13965            shape.local_rect = shape.rect;
13966            shape.quad = [[24.0, y], [304.0, y], [24.0, y + 120.0], [304.0, y + 120.0]];
13967            shape.shape = Some(RoundedCornerShape::uniform(18.0));
13968            vec![(shape, BlendMode::SrcOver)]
13969        }
13970
13971        let root_scale = 1.0;
13972        let blur_radius = 18.0;
13973        let viewport_clip = Rect {
13974            x: 0.0,
13975            y: 96.0,
13976            width: 360.0,
13977            height: 720.0,
13978        };
13979        let key_for = |y: f32| {
13980            let shapes = translated_card_shadow(y);
13981            let plan = shape_shadow_surface_plan(
13982                &shapes,
13983                Some(viewport_clip),
13984                blur_radius,
13985                360,
13986                900,
13987                root_scale,
13988                4096,
13989            )
13990            .expect("surface plan");
13991            shape_shadow_surface_cache_key(
13992                &shapes,
13993                plan.source_device_bounds,
13994                plan.pixel_radius,
13995                root_scale,
13996            )
13997            .expect("cache key")
13998        };
13999
14000        // The card scrolls under a fixed viewport clip; the visible portion
14001        // changes but the cache key must stay anchored to the unclipped source.
14002        assert_eq!(key_for(740.0), key_for(756.0));
14003    }
14004
14005    #[test]
14006    fn shape_visibility_uses_nonzero_viewport_offset_for_cropped_offscreen() {
14007        let mut shape = test_shape(1, BlendMode::SrcOver);
14008        shape.rect = Rect {
14009            x: 24.0,
14010            y: 740.0,
14011            width: 280.0,
14012            height: 120.0,
14013        };
14014        shape.local_rect = shape.rect;
14015        shape.quad = [[24.0, 740.0], [304.0, 740.0], [24.0, 860.0], [304.0, 860.0]];
14016        let viewport = ViewportUniformParams {
14017            width: 316,
14018            height: 228,
14019            offset: [6.0, 686.0],
14020        };
14021
14022        assert!(shape_draw_is_visible_in_viewport(&shape, viewport, 1.0));
14023    }
14024
14025    #[test]
14026    fn text_prewarm_uses_nonzero_viewport_offset_for_cropped_offscreen() {
14027        let viewport = ViewportUniformParams {
14028            width: 316,
14029            height: 228,
14030            offset: [6.0, 686.0],
14031        };
14032        let text_rect = Rect {
14033            x: 24.0,
14034            y: 740.0,
14035            width: 280.0,
14036            height: 40.0,
14037        };
14038
14039        assert!(text_draw_is_visible_in_viewport(
14040            text_rect, None, viewport, 1.0
14041        ));
14042        assert!(text_draw_should_prewarm_in_viewport(
14043            text_rect, None, viewport, 1.0
14044        ));
14045    }
14046
14047    fn test_shadow_draw(shapes: Vec<(DrawShape, BlendMode)>) -> ShadowDraw {
14048        ShadowDraw {
14049            shapes,
14050            texts: vec![],
14051            blur_radius: 8.0,
14052            clip: None,
14053            z_index: 0,
14054        }
14055    }
14056
14057    fn test_image(z_index: usize, blend_mode: BlendMode) -> ImageDraw {
14058        ImageDraw {
14059            rect: Rect {
14060                x: 0.0,
14061                y: 0.0,
14062                width: 8.0,
14063                height: 8.0,
14064            },
14065            local_rect: Rect {
14066                x: 0.0,
14067                y: 0.0,
14068                width: 8.0,
14069                height: 8.0,
14070            },
14071            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
14072            snap_anchor: None,
14073            image: ImageBitmap::from_rgba8(1, 1, vec![255, 255, 255, 255]).expect("image"),
14074            alpha: 1.0,
14075            color_filter: None,
14076            sampling: ImageSampling::Nearest,
14077            z_index,
14078            clip: None,
14079            blend_mode,
14080            src_rect: None,
14081            motion_context_animated: false,
14082        }
14083    }
14084
14085    #[test]
14086    fn image_sampler_descriptors_match_requested_sampling() {
14087        let nearest = image_sampler_descriptor(ImageSampling::Nearest);
14088        assert_eq!(nearest.mag_filter, wgpu::FilterMode::Nearest);
14089        assert_eq!(nearest.min_filter, wgpu::FilterMode::Nearest);
14090
14091        let linear = image_sampler_descriptor(ImageSampling::Linear);
14092        assert_eq!(linear.mag_filter, wgpu::FilterMode::Linear);
14093        assert_eq!(linear.min_filter, wgpu::FilterMode::Linear);
14094    }
14095
14096    #[test]
14097    fn image_uv_rect_clamps_source_rect_to_texel_centers() {
14098        let image = ImageBitmap::from_rgba8(24, 16, vec![0; 24 * 16 * 4]).expect("image");
14099        let uv = image_uv_rect(
14100            &image,
14101            Some(Rect {
14102                x: 0.0,
14103                y: 0.0,
14104                width: 16.0,
14105                height: 16.0,
14106            }),
14107        )
14108        .expect("uv rect");
14109
14110        assert_eq!(uv.min, [0.0, 0.0]);
14111        assert_eq!(uv.max, [16.0 / 24.0, 1.0]);
14112        assert_eq!(
14113            uv.sample_bounds,
14114            [0.5 / 24.0, 0.5 / 16.0, 15.5 / 24.0, 15.5 / 16.0]
14115        );
14116    }
14117
14118    #[test]
14119    fn image_uv_rect_keeps_full_image_unclamped() {
14120        let image = ImageBitmap::from_rgba8(2, 2, vec![0; 16]).expect("image");
14121        let uv = image_uv_rect(&image, None).expect("uv rect");
14122
14123        assert_eq!(uv.min, [0.0, 0.0]);
14124        assert_eq!(uv.max, [1.0, 1.0]);
14125        assert_eq!(uv.sample_bounds, [0.0, 0.0, 1.0, 1.0]);
14126    }
14127
14128    fn test_text(z_index: usize) -> TextDraw {
14129        TextDraw {
14130            node_id: 0,
14131            rect: Rect {
14132                x: 0.0,
14133                y: 0.0,
14134                width: 8.0,
14135                height: 8.0,
14136            },
14137            snap_anchor: None,
14138            translated_content_context: false,
14139            text: Arc::new(cranpose_ui::text::AnnotatedString::from("t").render_string()),
14140            color: Color::WHITE,
14141            text_style: cranpose_ui::TextStyle::default(),
14142            font_size: 12.0,
14143            scale: 1.0,
14144            layout_options: cranpose_ui::TextLayoutOptions::default(),
14145            z_index,
14146            clip: None,
14147        }
14148    }
14149
14150    #[test]
14151    fn text_draw_visibility_rejects_text_outside_clip_before_rasterization() {
14152        let viewport = ViewportUniformParams {
14153            width: 320,
14154            height: 240,
14155            offset: [0.0, 0.0],
14156        };
14157        let text_rect = Rect {
14158            x: 0.0,
14159            y: 260.0,
14160            width: 200.0,
14161            height: 40.0,
14162        };
14163        let clip = Some(Rect {
14164            x: 0.0,
14165            y: 0.0,
14166            width: 320.0,
14167            height: 200.0,
14168        });
14169
14170        assert!(
14171            !text_draw_is_visible_in_viewport(text_rect, clip, viewport, 1.0),
14172            "lazy-list beyond-bound text outside the clip must not be rasterized"
14173        );
14174    }
14175
14176    #[test]
14177    fn text_draw_prewarm_accepts_clipped_text_near_viewport() {
14178        let viewport = ViewportUniformParams {
14179            width: 320,
14180            height: 240,
14181            offset: [0.0, 0.0],
14182        };
14183        let text_rect = Rect {
14184            x: 0.0,
14185            y: 260.0,
14186            width: 200.0,
14187            height: 40.0,
14188        };
14189        let clip = Some(Rect {
14190            x: 0.0,
14191            y: 0.0,
14192            width: 320.0,
14193            height: 200.0,
14194        });
14195
14196        assert!(!text_draw_is_visible_in_viewport(
14197            text_rect, clip, viewport, 1.0
14198        ));
14199        assert!(text_draw_should_prewarm_in_viewport(
14200            text_rect, clip, viewport, 1.0
14201        ));
14202    }
14203
14204    #[test]
14205    fn text_draw_prewarm_rejects_far_clipped_text() {
14206        let viewport = ViewportUniformParams {
14207            width: 320,
14208            height: 240,
14209            offset: [0.0, 0.0],
14210        };
14211        let text_rect = Rect {
14212            x: 0.0,
14213            y: 1600.0,
14214            width: 200.0,
14215            height: 40.0,
14216        };
14217        let clip = Some(Rect {
14218            x: 0.0,
14219            y: 0.0,
14220            width: 320.0,
14221            height: 200.0,
14222        });
14223
14224        assert!(!text_draw_should_prewarm_in_viewport(
14225            text_rect, clip, viewport, 1.0
14226        ));
14227    }
14228
14229    #[test]
14230    fn text_draw_visibility_rejects_unclipped_text_outside_viewport() {
14231        let viewport = ViewportUniformParams {
14232            width: 320,
14233            height: 240,
14234            offset: [0.0, 0.0],
14235        };
14236        let text_rect = Rect {
14237            x: 0.0,
14238            y: 241.0,
14239            width: 200.0,
14240            height: 40.0,
14241        };
14242
14243        assert!(
14244            !text_draw_is_visible_in_viewport(text_rect, None, viewport, 1.0),
14245            "unclipped text outside the target viewport must not be rasterized"
14246        );
14247    }
14248
14249    #[test]
14250    fn text_draw_visibility_keeps_partially_visible_text() {
14251        let viewport = ViewportUniformParams {
14252            width: 320,
14253            height: 240,
14254            offset: [0.0, 0.0],
14255        };
14256        let text_rect = Rect {
14257            x: 0.0,
14258            y: 220.0,
14259            width: 200.0,
14260            height: 40.0,
14261        };
14262
14263        assert!(text_draw_is_visible_in_viewport(
14264            text_rect, None, viewport, 1.0
14265        ));
14266    }
14267
14268    fn test_draw_ops(
14269        shapes: &[DrawShape],
14270        images: &[ImageDraw],
14271        texts: &[TextDraw],
14272        shadows: &[ShadowDraw],
14273    ) -> Vec<DrawOp> {
14274        let mut ops = Vec::new();
14275        ops.extend(shapes.iter().enumerate().map(|(index, shape)| DrawOp {
14276            z_index: shape.z_index,
14277            kind: DrawOpKind::Shape(index),
14278        }));
14279        ops.extend(images.iter().enumerate().map(|(index, image)| DrawOp {
14280            z_index: image.z_index,
14281            kind: DrawOpKind::Image(index),
14282        }));
14283        ops.extend(texts.iter().enumerate().map(|(index, text)| DrawOp {
14284            z_index: text.z_index,
14285            kind: DrawOpKind::Text(index),
14286        }));
14287        ops.extend(shadows.iter().enumerate().map(|(index, shadow)| DrawOp {
14288            z_index: shadow.z_index,
14289            kind: DrawOpKind::Shadow(index),
14290        }));
14291        ops.sort_by_key(|op| op.z_index);
14292        ops
14293    }
14294
14295    fn test_layer(local_bounds: Rect, children: Vec<RenderNode>) -> LayerNode {
14296        crate::test_support::layer_node(
14297            local_bounds,
14298            ProjectiveTransform::identity(),
14299            GraphicsLayer::default(),
14300            children,
14301        )
14302    }
14303
14304    fn cacheable_layer(
14305        node_id: cranpose_core::NodeId,
14306        local_bounds: Rect,
14307        children: Vec<RenderNode>,
14308    ) -> LayerNode {
14309        let mut layer = test_layer(local_bounds, children);
14310        layer.node_id = Some(node_id);
14311        layer.cache_policy = cranpose_render_common::graph::CachePolicy::Auto;
14312        layer.recompute_raster_cache_hashes();
14313        layer
14314    }
14315
14316    fn text_layer_with_style(text: AnnotatedString, text_style: TextStyle) -> LayerNode {
14317        test_layer(
14318            Rect {
14319                x: 0.0,
14320                y: 0.0,
14321                width: 64.0,
14322                height: 32.0,
14323            },
14324            vec![RenderNode::Primitive(PrimitiveEntry {
14325                phase: PrimitivePhase::BeforeChildren,
14326                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
14327                    node_id: 1,
14328                    rect: Rect {
14329                        x: 2.0,
14330                        y: 3.0,
14331                        width: 48.0,
14332                        height: 18.0,
14333                    },
14334                    text: std::rc::Rc::new(text),
14335                    text_style,
14336                    font_size: 14.0,
14337                    layout_options: TextLayoutOptions::default(),
14338                    clip: None,
14339                })),
14340            })],
14341        )
14342    }
14343
14344    fn snapped_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
14345        LayerNode {
14346            node_id: Some(77),
14347            local_bounds: Rect {
14348                x: 0.0,
14349                y: 0.0,
14350                width: 48.0,
14351                height: 24.0,
14352            },
14353            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
14354            motion_context_animated: animated,
14355            translated_content_context,
14356            translated_content_offset: Point::default(),
14357            content_offset: Point::default(),
14358            scene_children_origin: cranpose_ui_graphics::Point::default(),
14359            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
14360            graphics_layer: GraphicsLayer::default(),
14361            clip_to_bounds: false,
14362            shadow_clip: None,
14363            hit_test: None,
14364            has_hit_targets: false,
14365            isolation: IsolationReasons::default(),
14366            cache_policy: CachePolicy::None,
14367            cache_hashes: LayerRasterCacheHashes::default(),
14368            cache_hashes_valid: false,
14369            children: vec![
14370                RenderNode::Primitive(PrimitiveEntry {
14371                    phase: PrimitivePhase::BeforeChildren,
14372                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
14373                        primitive: DrawPrimitive::RoundRect {
14374                            rect: Rect {
14375                                x: 0.0,
14376                                y: 0.0,
14377                                width: 48.0,
14378                                height: 24.0,
14379                            },
14380                            brush: Brush::solid(Color(0.28, 0.30, 0.46, 0.88)),
14381                            radii: CornerRadii::uniform(6.0),
14382                            stroke: None,
14383                        },
14384                        clip: None,
14385                    }),
14386                }),
14387                RenderNode::Primitive(PrimitiveEntry {
14388                    phase: PrimitivePhase::BeforeChildren,
14389                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
14390                        primitive: DrawPrimitive::Image {
14391                            rect: Rect {
14392                                x: 2.0,
14393                                y: 2.0,
14394                                width: 12.0,
14395                                height: 12.0,
14396                            },
14397                            image: ImageBitmap::from_rgba8(
14398                                2,
14399                                2,
14400                                vec![
14401                                    255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255,
14402                                    255,
14403                                ],
14404                            )
14405                            .expect("image"),
14406                            alpha: 1.0,
14407                            color_filter: None,
14408                            sampling: ImageSampling::Linear,
14409                            src_rect: None,
14410                        },
14411                        clip: None,
14412                    }),
14413                }),
14414                RenderNode::Primitive(PrimitiveEntry {
14415                    phase: PrimitivePhase::BeforeChildren,
14416                    node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
14417                        node_id: 77,
14418                        rect: Rect {
14419                            x: 6.0,
14420                            y: 4.0,
14421                            width: 36.0,
14422                            height: 16.0,
14423                        },
14424                        text: std::rc::Rc::new(AnnotatedString::from("48 px")),
14425                        text_style: TextStyle::default(),
14426                        font_size: 14.0,
14427                        layout_options: TextLayoutOptions::default(),
14428                        clip: None,
14429                    })),
14430                }),
14431            ],
14432        }
14433    }
14434
14435    fn snapped_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
14436        let text_leaf = snapped_text_leaf(animated, translated_content_context);
14437        test_layer(
14438            Rect {
14439                x: 0.0,
14440                y: 0.0,
14441                width: 96.0,
14442                height: 64.0,
14443            },
14444            vec![RenderNode::Layer(Box::new(text_leaf))],
14445        )
14446    }
14447
14448    fn translated_content_local_surface_root() -> LayerNode {
14449        let mut effectful_text = text_layer_with_style(
14450            AnnotatedString::from("shadow"),
14451            TextStyle::from_span_style(SpanStyle {
14452                shadow: Some(Shadow {
14453                    color: Color::BLACK,
14454                    offset: Point::new(1.0, 2.0),
14455                    blur_radius: 3.0,
14456                }),
14457                ..SpanStyle::default()
14458            }),
14459        );
14460        effectful_text.translated_content_context = true;
14461
14462        let translated_content = LayerNode {
14463            node_id: Some(78),
14464            local_bounds: Rect {
14465                x: 0.0,
14466                y: 0.0,
14467                width: 96.0,
14468                height: 64.0,
14469            },
14470            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
14471            motion_context_animated: false,
14472            translated_content_context: true,
14473            translated_content_offset: Point::default(),
14474            content_offset: Point::default(),
14475            scene_children_origin: cranpose_ui_graphics::Point::default(),
14476            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
14477            graphics_layer: GraphicsLayer::default(),
14478            clip_to_bounds: false,
14479            shadow_clip: None,
14480            hit_test: None,
14481            has_hit_targets: false,
14482            isolation: IsolationReasons::default(),
14483            cache_policy: CachePolicy::None,
14484            cache_hashes: LayerRasterCacheHashes::default(),
14485            cache_hashes_valid: false,
14486            children: vec![RenderNode::Layer(Box::new(effectful_text))],
14487        };
14488
14489        test_layer(
14490            Rect {
14491                x: 0.0,
14492                y: 0.0,
14493                width: 160.0,
14494                height: 120.0,
14495            },
14496            vec![RenderNode::Layer(Box::new(translated_content))],
14497        )
14498    }
14499
14500    #[test]
14501    fn scissor_rect_for_layer_intersects_with_clip() {
14502        let rect = Rect {
14503            x: 10.0,
14504            y: 10.0,
14505            width: 30.0,
14506            height: 20.0,
14507        };
14508        let clip = Rect {
14509            x: 20.0,
14510            y: 15.0,
14511            width: 100.0,
14512            height: 100.0,
14513        };
14514
14515        let scissor = scissor_rect_for_layer(rect, Some(clip), 1.0, 200, 200);
14516        assert_eq!(scissor, Some((20, 15, 20, 15)));
14517    }
14518
14519    #[test]
14520    fn visible_draw_rect_no_clip_returns_original() {
14521        let rect = Rect {
14522            x: 100.0,
14523            y: 200.0,
14524            width: 300.0,
14525            height: 400.0,
14526        };
14527        assert_eq!(visible_draw_rect(rect, None), Some(rect));
14528    }
14529
14530    #[test]
14531    fn visible_draw_rect_with_clip_intersects() {
14532        let rect = Rect {
14533            x: 0.0,
14534            y: 0.0,
14535            width: 2000.0,
14536            height: 5000.0,
14537        };
14538        let clip = Rect {
14539            x: 0.0,
14540            y: 0.0,
14541            width: 800.0,
14542            height: 600.0,
14543        };
14544        let visible = visible_draw_rect(rect, Some(clip)).expect("should have visible area");
14545        assert_eq!(visible.width, 800.0);
14546        assert_eq!(visible.height, 600.0);
14547    }
14548
14549    #[test]
14550    fn visible_draw_rect_fully_clipped_returns_none() {
14551        let rect = Rect {
14552            x: 1000.0,
14553            y: 1000.0,
14554            width: 200.0,
14555            height: 200.0,
14556        };
14557        let clip = Rect {
14558            x: 0.0,
14559            y: 0.0,
14560            width: 800.0,
14561            height: 600.0,
14562        };
14563        assert!(visible_draw_rect(rect, Some(clip)).is_none());
14564    }
14565
14566    #[test]
14567    fn scene_bounds_respects_clip_on_shapes() {
14568        let mut scene = CompositorScene::new();
14569        // Shape inside viewport — visible
14570        scene.shapes.push(DrawShape {
14571            rect: Rect {
14572                x: 10.0,
14573                y: 10.0,
14574                width: 100.0,
14575                height: 50.0,
14576            },
14577            clip: Some(Rect {
14578                x: 0.0,
14579                y: 0.0,
14580                width: 800.0,
14581                height: 600.0,
14582            }),
14583            ..test_shape(0, BlendMode::SrcOver)
14584        });
14585        // Shape far outside viewport — clipped away entirely
14586        scene.shapes.push(DrawShape {
14587            rect: Rect {
14588                x: 0.0,
14589                y: 3000.0,
14590                width: 100.0,
14591                height: 50.0,
14592            },
14593            clip: Some(Rect {
14594                x: 0.0,
14595                y: 0.0,
14596                width: 800.0,
14597                height: 600.0,
14598            }),
14599            ..test_shape(1, BlendMode::SrcOver)
14600        });
14601        let bounds = scene_bounds(&scene).expect("should have bounds");
14602        // Bounds should only cover the first shape's visible area,
14603        // NOT extend to y=3050 from the clipped second shape.
14604        assert!(bounds.y + bounds.height <= 600.0);
14605    }
14606
14607    #[test]
14608    fn scene_bounds_scroll_content_clipped_to_viewport() {
14609        // Simulates a scroll container: many items with large y offsets,
14610        // all clipped to a viewport-sized clip rect.
14611        let mut scene = CompositorScene::new();
14612        let viewport_clip = Rect {
14613            x: 0.0,
14614            y: 0.0,
14615            width: 800.0,
14616            height: 600.0,
14617        };
14618        for i in 0..20 {
14619            scene.shapes.push(DrawShape {
14620                rect: Rect {
14621                    x: 0.0,
14622                    y: i as f32 * 300.0,
14623                    width: 800.0,
14624                    height: 200.0,
14625                },
14626                clip: Some(viewport_clip),
14627                ..test_shape(i, BlendMode::SrcOver)
14628            });
14629        }
14630        let bounds = scene_bounds(&scene).expect("should have bounds");
14631        // All shapes are clipped to viewport — bounds should be viewport-sized,
14632        // NOT 20*300 = 6000 dp tall.
14633        assert_eq!(bounds.x, 0.0);
14634        assert_eq!(bounds.y, 0.0);
14635        assert!(bounds.width <= 800.0);
14636        assert!(bounds.height <= 600.0);
14637    }
14638
14639    #[test]
14640    fn scene_bounds_stable_across_scroll_offsets() {
14641        // Simulates horizontal scroll at different offsets —
14642        // bounds should be identical regardless of scroll position.
14643        let viewport_clip = Rect {
14644            x: 0.0,
14645            y: 0.0,
14646            width: 400.0,
14647            height: 50.0,
14648        };
14649        let compute_bounds_at_offset = |scroll_x: f32| {
14650            let mut scene = CompositorScene::new();
14651            for i in 0..10 {
14652                scene.shapes.push(DrawShape {
14653                    rect: Rect {
14654                        x: i as f32 * 100.0 - scroll_x,
14655                        y: 0.0,
14656                        width: 80.0,
14657                        height: 40.0,
14658                    },
14659                    clip: Some(viewport_clip),
14660                    ..test_shape(i, BlendMode::SrcOver)
14661                });
14662            }
14663            scene_bounds(&scene).expect("bounds")
14664        };
14665        let bounds_at_0 = compute_bounds_at_offset(0.0);
14666        let bounds_at_300 = compute_bounds_at_offset(300.0);
14667        let bounds_at_600 = compute_bounds_at_offset(600.0);
14668        // Width should be stable (clipped to viewport) regardless of scroll offset
14669        assert!(
14670            (bounds_at_0.width - bounds_at_300.width).abs() < 1.0,
14671            "bounds width changed with scroll: {} vs {}",
14672            bounds_at_0.width,
14673            bounds_at_300.width
14674        );
14675        assert!(
14676            (bounds_at_0.width - bounds_at_600.width).abs() < 1.0,
14677            "bounds width changed with scroll: {} vs {}",
14678            bounds_at_0.width,
14679            bounds_at_600.width
14680        );
14681    }
14682
14683    #[test]
14684    fn collect_effect_ranges_respects_excluded_effect() {
14685        let layers = vec![effect_layer(10, 40), effect_layer(20, 30)];
14686        let mut ranges = Vec::new();
14687        collect_effect_ranges(&layers, 10, 40, Some(0), &mut ranges);
14688        assert_eq!(ranges.len(), 1);
14689        assert_eq!(ranges[0], 20..30);
14690    }
14691
14692    #[test]
14693    fn collect_layer_events_includes_nested_when_parent_excluded() {
14694        let effects = vec![effect_layer(10, 40), effect_layer(20, 30)];
14695        let backdrops = vec![backdrop_layer(25)];
14696        let mut events = Vec::new();
14697        collect_layer_events(&effects, &backdrops, 10, 40, Some(0), &mut events);
14698        assert_eq!(events.len(), 2);
14699
14700        match events[0].kind {
14701            LayerEventKind::Effect(index) => assert_eq!(index, 1),
14702            LayerEventKind::Backdrop(_) => panic!("expected nested effect as first event"),
14703        }
14704        match events[1].kind {
14705            LayerEventKind::Backdrop(index) => assert_eq!(index, 0),
14706            LayerEventKind::Effect(_) => panic!("expected backdrop as second event"),
14707        }
14708    }
14709
14710    fn pure_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
14711        LayerNode {
14712            node_id: Some(177),
14713            local_bounds: Rect {
14714                x: 0.0,
14715                y: 0.0,
14716                width: 96.0,
14717                height: 32.0,
14718            },
14719            transform_to_parent: ProjectiveTransform::translation(11.4, 23.6),
14720            motion_context_animated: animated,
14721            translated_content_context,
14722            translated_content_offset: Point::default(),
14723            content_offset: Point::default(),
14724            scene_children_origin: cranpose_ui_graphics::Point::default(),
14725            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
14726            graphics_layer: GraphicsLayer::default(),
14727            clip_to_bounds: false,
14728            shadow_clip: None,
14729            hit_test: None,
14730            has_hit_targets: false,
14731            isolation: IsolationReasons::default(),
14732            cache_policy: CachePolicy::None,
14733            cache_hashes: LayerRasterCacheHashes::default(),
14734            cache_hashes_valid: false,
14735            children: vec![RenderNode::Primitive(PrimitiveEntry {
14736                phase: PrimitivePhase::BeforeChildren,
14737                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
14738                    node_id: 177,
14739                    rect: Rect {
14740                        x: 0.0,
14741                        y: 0.0,
14742                        width: 96.0,
14743                        height: 24.0,
14744                    },
14745                    clip: None,
14746                    text: std::rc::Rc::new(AnnotatedString::from("Pure text")),
14747                    text_style: TextStyle::default(),
14748                    font_size: 14.0,
14749                    layout_options: TextLayoutOptions::default(),
14750                })),
14751            })],
14752        }
14753    }
14754
14755    fn pure_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
14756        let text_leaf = pure_text_leaf(animated, translated_content_context);
14757        test_layer(
14758            Rect {
14759                x: 0.0,
14760                y: 0.0,
14761                width: 160.0,
14762                height: 96.0,
14763            },
14764            vec![RenderNode::Layer(Box::new(text_leaf))],
14765        )
14766    }
14767
14768    #[test]
14769    fn collect_layer_events_sorts_backdrop_before_effect_at_same_z() {
14770        let effects = vec![effect_layer(10, 20)];
14771        let backdrops = vec![backdrop_layer(10)];
14772        let mut events = Vec::new();
14773        collect_layer_events(&effects, &backdrops, 0, 30, None, &mut events);
14774        assert_eq!(events.len(), 2);
14775
14776        match events[0].kind {
14777            LayerEventKind::Backdrop(_) => {}
14778            LayerEventKind::Effect(_) => panic!("expected backdrop to run before effect"),
14779        }
14780        match events[1].kind {
14781            LayerEventKind::Effect(_) => {}
14782            LayerEventKind::Backdrop(_) => panic!("expected effect as second event"),
14783        }
14784    }
14785
14786    #[test]
14787    fn collect_layer_events_prefers_outer_effect_when_same_start_z() {
14788        // Child emitted before parent (matching scene collection order where a
14789        // parent effect is recorded after recursively processing children).
14790        let effects = vec![effect_layer(10, 20), effect_layer(10, 40)];
14791        let mut events = Vec::new();
14792        collect_layer_events(&effects, &[], 0, 50, None, &mut events);
14793
14794        assert_eq!(events.len(), 2);
14795        match events[0].kind {
14796            LayerEventKind::Effect(index) => assert_eq!(index, 1),
14797            LayerEventKind::Backdrop(_) => panic!("expected outer effect first"),
14798        }
14799        match events[1].kind {
14800            LayerEventKind::Effect(index) => assert_eq!(index, 0),
14801            LayerEventKind::Backdrop(_) => panic!("expected child effect second"),
14802        }
14803    }
14804
14805    #[test]
14806    fn collect_layer_events_prefers_later_effect_when_ranges_match() {
14807        let effects = vec![effect_layer(10, 20), effect_layer(10, 20)];
14808        let mut events = Vec::new();
14809        collect_layer_events(&effects, &[], 0, 30, None, &mut events);
14810
14811        assert_eq!(events.len(), 2);
14812        match events[0].kind {
14813            LayerEventKind::Effect(index) => assert_eq!(index, 1),
14814            LayerEventKind::Backdrop(_) => panic!("expected later effect first"),
14815        }
14816        match events[1].kind {
14817            LayerEventKind::Effect(index) => assert_eq!(index, 0),
14818            LayerEventKind::Backdrop(_) => panic!("expected earlier effect second"),
14819        }
14820    }
14821
14822    #[test]
14823    fn has_backdrop_layer_in_range_detects_nested_layers() {
14824        let backdrops = vec![backdrop_layer(5), backdrop_layer(15), backdrop_layer(25)];
14825        assert!(has_backdrop_layer_in_range(&backdrops, 10, 20));
14826        assert!(has_backdrop_layer_in_range(&backdrops, 0, 6));
14827        assert!(!has_backdrop_layer_in_range(&backdrops, 20, 25));
14828    }
14829
14830    #[test]
14831    fn layer_contains_descendant_backdrop_ignores_self_backdrop() {
14832        let mut self_backdrop = test_layer(
14833            Rect {
14834                x: 0.0,
14835                y: 0.0,
14836                width: 10.0,
14837                height: 10.0,
14838            },
14839            vec![],
14840        );
14841        self_backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
14842        assert!(!layer_contains_descendant_backdrop(&self_backdrop));
14843
14844        let mut child = test_layer(
14845            Rect {
14846                x: 0.0,
14847                y: 0.0,
14848                width: 8.0,
14849                height: 8.0,
14850            },
14851            vec![],
14852        );
14853        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
14854
14855        let parent = test_layer(
14856            Rect {
14857                x: 0.0,
14858                y: 0.0,
14859                width: 20.0,
14860                height: 20.0,
14861            },
14862            vec![RenderNode::Layer(Box::new(child))],
14863        );
14864        assert!(layer_contains_descendant_backdrop(&parent));
14865    }
14866
14867    fn child_layer_composite(
14868        layer: &LayerNode,
14869        z_index: usize,
14870        rect: Rect,
14871        needs_nested_underlay: bool,
14872    ) -> crate::normalized_scene::ChildLayerComposite {
14873        let mut requirements_cache = cranpose_core::collections::map::HashMap::new();
14874        let surface_requirements =
14875            crate::surface_plan::layer_surface_requirements_cached(layer, &mut requirements_cache);
14876        crate::normalized_scene::ChildLayerComposite {
14877            z_index,
14878            logical_rect: Rect {
14879                x: 0.0,
14880                y: 0.0,
14881                width: rect.width,
14882                height: rect.height,
14883            },
14884            dest_quad: rect_to_quad(rect),
14885            snap_anchor: None,
14886            composite_snap_origin: None,
14887            backdrop_rect: rect,
14888            visual_clip: None,
14889            surface_clip: None,
14890            shadow_draws: Vec::new(),
14891            needs_nested_underlay,
14892            node_id: layer.node_id,
14893            backdrop: layer.backdrop().cloned(),
14894            has_effect: layer.effect().is_some(),
14895            effect_contains_runtime_shader: layer
14896                .effect()
14897                .is_some_and(|effect| effect.contains_runtime_shader()),
14898            target_content_hash: layer.target_content_hash(),
14899            effect_hash: layer.effect_hash(),
14900            motion_source_content_hash: Some(layer.motion_source_content_hash()),
14901            contains_descendant_backdrop: layer_contains_descendant_backdrop(layer),
14902            cache_policy: layer.cache_policy,
14903            surface_requirements,
14904            rounded_clip: crate::surface_executor::backend::LayerSurfaceRoundedClip::from_layer(
14905                layer,
14906            ),
14907            isolation: cranpose_render_common::layer_composition::effective_layer_isolation(
14908                &layer.graphics_layer,
14909            ),
14910            translated_content_context: layer.translated_content_context,
14911            own_translated_content_axes: crate::surface_plan::translated_content_axes_for_layer(
14912                layer,
14913            ),
14914            clip_rect: layer.clip_rect(),
14915            local_bounds: layer.local_bounds,
14916            surface_scale: crate::surface_plan::layer_surface_scale(layer),
14917            source: crate::normalized_scene::LoweredChildSource::default(),
14918        }
14919    }
14920
14921    #[test]
14922    fn root_direct_preflight_allows_first_translated_child_underlay() {
14923        let child = test_layer(
14924            Rect {
14925                x: 0.0,
14926                y: 0.0,
14927                width: 400.0,
14928                height: 280.0,
14929            },
14930            vec![],
14931        );
14932        let collected = CollectedLayer {
14933            scene: CompositorScene::new(),
14934            child_layers: vec![child_layer_composite(
14935                &child,
14936                3,
14937                Rect {
14938                    x: 48.0,
14939                    y: 96.0,
14940                    width: 400.0,
14941                    height: 280.0,
14942                },
14943                true,
14944            )],
14945        };
14946
14947        assert!(direct_root_child_underlays_are_supported(&collected));
14948    }
14949
14950    #[test]
14951    fn root_direct_preflight_allows_axis_aligned_prior_child_underlay() {
14952        let first = test_layer(
14953            Rect {
14954                x: 0.0,
14955                y: 0.0,
14956                width: 80.0,
14957                height: 40.0,
14958            },
14959            vec![],
14960        );
14961        let backdrop_child = test_layer(
14962            Rect {
14963                x: 0.0,
14964                y: 0.0,
14965                width: 400.0,
14966                height: 280.0,
14967            },
14968            vec![],
14969        );
14970        let collected = CollectedLayer {
14971            scene: CompositorScene::new(),
14972            child_layers: vec![
14973                child_layer_composite(
14974                    &first,
14975                    1,
14976                    Rect {
14977                        x: 8.0,
14978                        y: 16.0,
14979                        width: 80.0,
14980                        height: 40.0,
14981                    },
14982                    false,
14983                ),
14984                child_layer_composite(
14985                    &backdrop_child,
14986                    4,
14987                    Rect {
14988                        x: 48.0,
14989                        y: 96.0,
14990                        width: 400.0,
14991                        height: 280.0,
14992                    },
14993                    true,
14994                ),
14995            ],
14996        };
14997
14998        assert!(direct_root_child_underlays_are_supported(&collected));
14999    }
15000
15001    #[test]
15002    fn root_direct_preflight_rejects_effectful_prior_child_underlay() {
15003        let mut first = test_layer(
15004            Rect {
15005                x: 0.0,
15006                y: 0.0,
15007                width: 80.0,
15008                height: 40.0,
15009            },
15010            vec![],
15011        );
15012        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
15013        let backdrop_child = test_layer(
15014            Rect {
15015                x: 0.0,
15016                y: 0.0,
15017                width: 400.0,
15018                height: 280.0,
15019            },
15020            vec![],
15021        );
15022        let collected = CollectedLayer {
15023            scene: CompositorScene::new(),
15024            child_layers: vec![
15025                child_layer_composite(
15026                    &first,
15027                    1,
15028                    Rect {
15029                        x: 64.0,
15030                        y: 112.0,
15031                        width: 80.0,
15032                        height: 40.0,
15033                    },
15034                    false,
15035                ),
15036                child_layer_composite(
15037                    &backdrop_child,
15038                    4,
15039                    Rect {
15040                        x: 48.0,
15041                        y: 96.0,
15042                        width: 400.0,
15043                        height: 280.0,
15044                    },
15045                    true,
15046                ),
15047            ],
15048        };
15049
15050        assert!(!direct_root_child_underlays_are_supported(&collected));
15051    }
15052
15053    #[test]
15054    fn root_direct_preflight_ignores_non_overlapping_effectful_prior_child_underlay() {
15055        let mut first = test_layer(
15056            Rect {
15057                x: 0.0,
15058                y: 0.0,
15059                width: 80.0,
15060                height: 40.0,
15061            },
15062            vec![],
15063        );
15064        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
15065        let backdrop_child = test_layer(
15066            Rect {
15067                x: 0.0,
15068                y: 0.0,
15069                width: 400.0,
15070                height: 280.0,
15071            },
15072            vec![],
15073        );
15074        let collected = CollectedLayer {
15075            scene: CompositorScene::new(),
15076            child_layers: vec![
15077                child_layer_composite(
15078                    &first,
15079                    1,
15080                    Rect {
15081                        x: 8.0,
15082                        y: 16.0,
15083                        width: 80.0,
15084                        height: 40.0,
15085                    },
15086                    false,
15087                ),
15088                child_layer_composite(
15089                    &backdrop_child,
15090                    4,
15091                    Rect {
15092                        x: 48.0,
15093                        y: 96.0,
15094                        width: 400.0,
15095                        height: 280.0,
15096                    },
15097                    true,
15098                ),
15099            ],
15100        };
15101
15102        assert!(direct_root_child_underlays_are_supported(&collected));
15103    }
15104
15105    #[test]
15106    fn root_direct_preflight_rejects_underlay_that_would_replay_prior_scene_effects() {
15107        let backdrop_child = test_layer(
15108            Rect {
15109                x: 0.0,
15110                y: 0.0,
15111                width: 400.0,
15112                height: 280.0,
15113            },
15114            vec![],
15115        );
15116        let mut scene = CompositorScene::new();
15117        scene.next_z = 1;
15118        scene.push_effect_layer(
15119            Rect {
15120                x: 0.0,
15121                y: 0.0,
15122                width: 120.0,
15123                height: 120.0,
15124            },
15125            None,
15126            Some(RenderEffect::blur(2.0)),
15127            BlendMode::SrcOver,
15128            1.0,
15129            0,
15130            1,
15131        );
15132        let collected = CollectedLayer {
15133            scene,
15134            child_layers: vec![child_layer_composite(
15135                &backdrop_child,
15136                4,
15137                Rect {
15138                    x: 48.0,
15139                    y: 96.0,
15140                    width: 400.0,
15141                    height: 280.0,
15142                },
15143                true,
15144            )],
15145        };
15146
15147        assert!(!direct_root_child_underlays_are_supported(&collected));
15148    }
15149
15150    #[test]
15151    fn root_direct_eligibility_does_not_reject_descendant_backdrop() {
15152        let mut backdrop = test_layer(
15153            Rect {
15154                x: 0.0,
15155                y: 0.0,
15156                width: 40.0,
15157                height: 40.0,
15158            },
15159            vec![],
15160        );
15161        backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(4.0));
15162        let child = test_layer(
15163            Rect {
15164                x: 0.0,
15165                y: 0.0,
15166                width: 120.0,
15167                height: 96.0,
15168            },
15169            vec![RenderNode::Layer(Box::new(backdrop))],
15170        );
15171        let root = test_layer(
15172            Rect {
15173                x: 0.0,
15174                y: 0.0,
15175                width: 240.0,
15176                height: 160.0,
15177            },
15178            vec![RenderNode::Layer(Box::new(child))],
15179        );
15180        let mut cache = HashMap::new();
15181
15182        assert!(root_can_render_directly_cached(&root, &mut cache));
15183    }
15184
15185    #[test]
15186    fn root_direct_scene_events_allow_root_local_effects() {
15187        let mut scene = CompositorScene::new();
15188        scene.effect_layers.push(EffectLayer {
15189            rect: Rect {
15190                x: 20.0,
15191                y: 30.0,
15192                width: 120.0,
15193                height: 80.0,
15194            },
15195            clip: None,
15196            snap_anchor: None,
15197            effect: Some(RenderEffect::blur(6.0)),
15198            blend_mode: BlendMode::SrcOver,
15199            composite_alpha: 1.0,
15200            z_start: 0,
15201            z_end: 1,
15202            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
15203        });
15204
15205        assert!(root_direct_scene_events_are_supported(&scene));
15206    }
15207
15208    #[test]
15209    fn root_direct_scene_events_reject_root_local_backdrops() {
15210        let mut scene = CompositorScene::new();
15211        scene.backdrop_layers.push(BackdropLayer {
15212            node_id: Some(99),
15213            rect: Rect {
15214                x: 20.0,
15215                y: 30.0,
15216                width: 120.0,
15217                height: 80.0,
15218            },
15219            clip: None,
15220            snap_anchor: None,
15221            effect: RenderEffect::blur(6.0),
15222            z_index: 1,
15223        });
15224
15225        assert!(!root_direct_scene_events_are_supported(&scene));
15226    }
15227
15228    #[test]
15229    fn estimate_layer_surface_rect_includes_transformed_child_bounds() {
15230        let mut child = test_layer(
15231            Rect {
15232                x: 0.0,
15233                y: 0.0,
15234                width: 10.0,
15235                height: 6.0,
15236            },
15237            vec![RenderNode::Primitive(PrimitiveEntry {
15238                phase: PrimitivePhase::BeforeChildren,
15239                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15240                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15241                        rect: Rect {
15242                            x: 0.0,
15243                            y: 0.0,
15244                            width: 10.0,
15245                            height: 6.0,
15246                        },
15247                        brush: Brush::solid(Color::WHITE),
15248                        stroke: None,
15249                    },
15250                    clip: None,
15251                }),
15252            })],
15253        );
15254        child.transform_to_parent = ProjectiveTransform::translation(18.0, 7.0);
15255
15256        let parent = test_layer(
15257            Rect {
15258                x: 0.0,
15259                y: 0.0,
15260                width: 4.0,
15261                height: 4.0,
15262            },
15263            vec![RenderNode::Layer(Box::new(child))],
15264        );
15265
15266        assert_eq!(
15267            estimate_layer_surface_rect(&parent),
15268            Rect {
15269                x: 18.0,
15270                y: 7.0,
15271                width: 10.0,
15272                height: 6.0,
15273            }
15274        );
15275    }
15276
15277    #[test]
15278    fn estimate_layer_surface_rect_clips_translated_clip_layers_without_hidden_leading_content() {
15279        let mut layer = test_layer(
15280            Rect {
15281                x: 0.0,
15282                y: 0.0,
15283                width: 120.0,
15284                height: 72.0,
15285            },
15286            vec![RenderNode::Primitive(PrimitiveEntry {
15287                phase: PrimitivePhase::BeforeChildren,
15288                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15289                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15290                        rect: Rect {
15291                            x: 24.0,
15292                            y: 0.0,
15293                            width: 200.0,
15294                            height: 480.0,
15295                        },
15296                        brush: Brush::solid(Color::WHITE),
15297                        stroke: None,
15298                    },
15299                    clip: None,
15300                }),
15301            })],
15302        );
15303        layer.translated_content_context = true;
15304        layer.motion_context_animated = true;
15305        layer.clip_to_bounds = true;
15306
15307        assert_eq!(
15308            estimate_layer_surface_rect(&layer),
15309            Rect {
15310                x: 24.0,
15311                y: 0.0,
15312                width: 96.0,
15313                height: 72.0,
15314            }
15315        );
15316    }
15317
15318    #[test]
15319    fn estimate_layer_surface_rect_clips_active_horizontal_scroll_content() {
15320        let mut layer = test_layer(
15321            Rect {
15322                x: 0.0,
15323                y: 0.0,
15324                width: 120.0,
15325                height: 72.0,
15326            },
15327            vec![RenderNode::Primitive(PrimitiveEntry {
15328                phase: PrimitivePhase::BeforeChildren,
15329                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15330                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15331                        rect: Rect {
15332                            x: -24.0,
15333                            y: 0.0,
15334                            width: 200.0,
15335                            height: 480.0,
15336                        },
15337                        brush: Brush::solid(Color::WHITE),
15338                        stroke: None,
15339                    },
15340                    clip: None,
15341                }),
15342            })],
15343        );
15344        layer.translated_content_context = true;
15345        layer.motion_context_animated = true;
15346        layer.clip_to_bounds = true;
15347
15348        assert_eq!(
15349            estimate_layer_surface_rect(&layer),
15350            Rect {
15351                x: 0.0,
15352                y: 0.0,
15353                width: 120.0,
15354                height: 72.0,
15355            }
15356        );
15357    }
15358
15359    #[test]
15360    fn estimate_layer_surface_rect_clips_active_vertical_scroll_content() {
15361        let mut layer = test_layer(
15362            Rect {
15363                x: 0.0,
15364                y: 0.0,
15365                width: 120.0,
15366                height: 72.0,
15367            },
15368            vec![RenderNode::Primitive(PrimitiveEntry {
15369                phase: PrimitivePhase::BeforeChildren,
15370                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15371                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15372                        rect: Rect {
15373                            x: 0.0,
15374                            y: -24.0,
15375                            width: 120.0,
15376                            height: 200.0,
15377                        },
15378                        brush: Brush::solid(Color::WHITE),
15379                        stroke: None,
15380                    },
15381                    clip: None,
15382                }),
15383            })],
15384        );
15385        layer.translated_content_context = true;
15386        layer.motion_context_animated = true;
15387        layer.clip_to_bounds = true;
15388
15389        assert_eq!(
15390            estimate_layer_surface_rect(&layer),
15391            Rect {
15392                x: 0.0,
15393                y: 0.0,
15394                width: 120.0,
15395                height: 72.0,
15396            }
15397        );
15398    }
15399
15400    #[test]
15401    fn estimate_layer_surface_rect_keeps_shallow_scroll_capture_origin_stable() {
15402        fn shallow_scroll_surface_rect(content_y: f32) -> Rect {
15403            let mut layer = test_layer(
15404                Rect {
15405                    x: 0.0,
15406                    y: 0.0,
15407                    width: 120.0,
15408                    height: 72.0,
15409                },
15410                vec![RenderNode::Primitive(PrimitiveEntry {
15411                    phase: PrimitivePhase::BeforeChildren,
15412                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
15413                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15414                            rect: Rect {
15415                                x: 0.0,
15416                                y: content_y,
15417                                width: 120.0,
15418                                height: 200.0,
15419                            },
15420                            brush: Brush::solid(Color::WHITE),
15421                            stroke: None,
15422                        },
15423                        clip: None,
15424                    }),
15425                })],
15426            );
15427            layer.translated_content_context = true;
15428            layer.motion_context_animated = true;
15429            layer.clip_to_bounds = true;
15430            estimate_layer_surface_rect(&layer)
15431        }
15432
15433        assert_eq!(
15434            shallow_scroll_surface_rect(-24.0),
15435            shallow_scroll_surface_rect(-25.0),
15436            "shallow scroll capture bounds must not move the offscreen surface origin on adjacent scroll positions"
15437        );
15438    }
15439
15440    #[test]
15441    fn estimate_layer_surface_rect_clips_active_xy_scroll_content() {
15442        let mut layer = test_layer(
15443            Rect {
15444                x: 0.0,
15445                y: 0.0,
15446                width: 120.0,
15447                height: 72.0,
15448            },
15449            vec![RenderNode::Primitive(PrimitiveEntry {
15450                phase: PrimitivePhase::BeforeChildren,
15451                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15452                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15453                        rect: Rect {
15454                            x: -16.0,
15455                            y: -24.0,
15456                            width: 180.0,
15457                            height: 240.0,
15458                        },
15459                        brush: Brush::solid(Color::WHITE),
15460                        stroke: None,
15461                    },
15462                    clip: None,
15463                }),
15464            })],
15465        );
15466        layer.translated_content_context = true;
15467        layer.motion_context_animated = true;
15468        layer.clip_to_bounds = true;
15469
15470        assert_eq!(
15471            estimate_layer_surface_rect(&layer),
15472            Rect {
15473                x: 0.0,
15474                y: 0.0,
15475                width: 120.0,
15476                height: 72.0,
15477            }
15478        );
15479    }
15480
15481    #[test]
15482    fn estimate_layer_surface_rect_clips_deep_hidden_active_scroll_content() {
15483        let mut layer = test_layer(
15484            Rect {
15485                x: 0.0,
15486                y: 0.0,
15487                width: 120.0,
15488                height: 72.0,
15489            },
15490            vec![RenderNode::Primitive(PrimitiveEntry {
15491                phase: PrimitivePhase::BeforeChildren,
15492                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15493                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15494                        rect: Rect {
15495                            x: 0.0,
15496                            y: -1200.0,
15497                            width: 120.0,
15498                            height: 1400.0,
15499                        },
15500                        brush: Brush::solid(Color::WHITE),
15501                        stroke: None,
15502                    },
15503                    clip: None,
15504                }),
15505            })],
15506        );
15507        layer.translated_content_context = true;
15508        layer.motion_context_animated = true;
15509        layer.clip_to_bounds = true;
15510
15511        assert_eq!(
15512            estimate_layer_surface_rect(&layer),
15513            Rect {
15514                x: 0.0,
15515                y: 0.0,
15516                width: 120.0,
15517                height: 72.0,
15518            }
15519        );
15520    }
15521
15522    #[test]
15523    fn estimate_layer_surface_rect_keeps_deep_scroll_capture_origin_stable() {
15524        fn deep_scroll_surface_rect(content_y: f32) -> Rect {
15525            let mut layer = test_layer(
15526                Rect {
15527                    x: 0.0,
15528                    y: 0.0,
15529                    width: 120.0,
15530                    height: 72.0,
15531                },
15532                vec![RenderNode::Primitive(PrimitiveEntry {
15533                    phase: PrimitivePhase::BeforeChildren,
15534                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
15535                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15536                            rect: Rect {
15537                                x: 0.0,
15538                                y: content_y,
15539                                width: 120.0,
15540                                height: 1400.0,
15541                            },
15542                            brush: Brush::solid(Color::WHITE),
15543                            stroke: None,
15544                        },
15545                        clip: None,
15546                    }),
15547                })],
15548            );
15549            layer.translated_content_context = true;
15550            layer.motion_context_animated = true;
15551            layer.clip_to_bounds = true;
15552            estimate_layer_surface_rect(&layer)
15553        }
15554
15555        assert_eq!(
15556            deep_scroll_surface_rect(-1200.0),
15557            deep_scroll_surface_rect(-1201.0),
15558            "deep scroll capture bounds must not re-phase the offscreen surface origin on adjacent scroll positions"
15559        );
15560    }
15561
15562    #[test]
15563    fn motion_stable_capture_bounds_bounds_shadows_for_clipped_effect_layer() {
15564        let mut layer = test_layer(
15565            Rect {
15566                x: 0.0,
15567                y: 0.0,
15568                width: 120.0,
15569                height: 72.0,
15570            },
15571            vec![],
15572        );
15573        layer.clip_to_bounds = true;
15574        layer.graphics_layer.clip = true;
15575        layer.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
15576
15577        let mut shadow_shape = test_shape(0, BlendMode::SrcOver);
15578        shadow_shape.rect = Rect {
15579            x: -24.0,
15580            y: -1200.0,
15581            width: 180.0,
15582            height: 1400.0,
15583        };
15584        let mut scene = CompositorScene::new();
15585        scene
15586            .shadow_draws
15587            .push(test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]));
15588
15589        let requirements = SurfaceRequirementSet::default()
15590            .with(SurfaceRequirement::RenderEffect)
15591            .with(SurfaceRequirement::MotionStableCapture);
15592
15593        assert_eq!(
15594            motion_stable_capture_bounds(
15595                &layer,
15596                &scene,
15597                &[],
15598                requirements,
15599                TranslatedContentAxes::default(),
15600                None,
15601            ),
15602            Some(Rect {
15603                x: -360.0,
15604                y: -216.0,
15605                width: 480.0,
15606                height: 288.0,
15607            })
15608        );
15609    }
15610
15611    #[test]
15612    fn vertical_motion_stable_capture_uses_viewport_cross_axis_bounds() {
15613        let mut layer = test_layer(
15614            Rect {
15615                x: 0.0,
15616                y: 0.0,
15617                width: 200.0,
15618                height: 100.0,
15619            },
15620            vec![],
15621        );
15622        layer.clip_to_bounds = true;
15623        layer.graphics_layer.clip = true;
15624
15625        let mut shape = test_shape(0, BlendMode::SrcOver);
15626        shape.rect = Rect {
15627            x: 60.0,
15628            y: -80.0,
15629            width: 80.0,
15630            height: 220.0,
15631        };
15632        let mut scene = CompositorScene::new();
15633        scene.shapes.push(shape);
15634
15635        let requirements =
15636            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
15637
15638        assert_eq!(
15639            motion_stable_capture_bounds(
15640                &layer,
15641                &scene,
15642                &[],
15643                requirements,
15644                TranslatedContentAxes { x: false, y: true },
15645                None,
15646            ),
15647            Some(Rect {
15648                x: -96.0,
15649                y: -64.0,
15650                width: 296.0,
15651                height: 164.0,
15652            })
15653        );
15654    }
15655
15656    #[test]
15657    fn vertical_motion_stable_capture_uses_external_surface_clip() {
15658        let layer = test_layer(
15659            Rect {
15660                x: 0.0,
15661                y: 0.0,
15662                width: 200.0,
15663                height: 100.0,
15664            },
15665            vec![],
15666        );
15667
15668        let mut shape = test_shape(0, BlendMode::SrcOver);
15669        shape.rect = Rect {
15670            x: 60.0,
15671            y: -80.0,
15672            width: 80.0,
15673            height: 220.0,
15674        };
15675        let mut scene = CompositorScene::new();
15676        scene.shapes.push(shape);
15677
15678        let requirements =
15679            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
15680
15681        assert_eq!(
15682            motion_stable_capture_bounds(
15683                &layer,
15684                &scene,
15685                &[],
15686                requirements,
15687                TranslatedContentAxes { x: false, y: true },
15688                Some(Rect {
15689                    x: 0.0,
15690                    y: 0.0,
15691                    width: 200.0,
15692                    height: 100.0,
15693                }),
15694            ),
15695            Some(Rect {
15696                x: -96.0,
15697                y: -64.0,
15698                width: 296.0,
15699                height: 164.0,
15700            })
15701        );
15702    }
15703
15704    #[test]
15705    fn estimate_layer_surface_rect_expands_for_child_layer_shadow() {
15706        let mut child = test_layer(
15707            Rect {
15708                x: 0.0,
15709                y: 0.0,
15710                width: 12.0,
15711                height: 8.0,
15712            },
15713            vec![],
15714        );
15715        child.transform_to_parent = ProjectiveTransform::translation(20.0, 9.0);
15716        child.graphics_layer.shadow_elevation = 6.0;
15717
15718        let parent = test_layer(
15719            Rect {
15720                x: 0.0,
15721                y: 0.0,
15722                width: 4.0,
15723                height: 4.0,
15724            },
15725            vec![RenderNode::Layer(Box::new(child))],
15726        );
15727
15728        let rect = estimate_layer_surface_rect(&parent);
15729        assert!(rect.x < 20.0);
15730        assert!(rect.y < 9.0);
15731        assert!(rect.width > 12.0);
15732        assert!(rect.height > 8.0);
15733    }
15734
15735    #[test]
15736    fn estimate_layer_surface_rect_respects_local_bounds_for_effect_layers() {
15737        let mut layer = test_layer(
15738            Rect {
15739                x: 0.0,
15740                y: 0.0,
15741                width: 28.0,
15742                height: 28.0,
15743            },
15744            vec![RenderNode::Primitive(PrimitiveEntry {
15745                phase: PrimitivePhase::BeforeChildren,
15746                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15747                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15748                        rect: Rect {
15749                            x: 10.0,
15750                            y: 10.0,
15751                            width: 10.0,
15752                            height: 10.0,
15753                        },
15754                        brush: Brush::solid(Color::WHITE),
15755                        stroke: None,
15756                    },
15757                    clip: None,
15758                }),
15759            })],
15760        );
15761        layer.graphics_layer.render_effect = Some(RenderEffect::blur(12.0));
15762
15763        assert_eq!(
15764            estimate_layer_surface_rect(&layer),
15765            Rect {
15766                x: 0.0,
15767                y: 0.0,
15768                width: 28.0,
15769                height: 28.0,
15770            }
15771        );
15772    }
15773
15774    #[test]
15775    fn layer_raster_cache_candidate_ignores_parent_transform() {
15776        let primitive = PrimitiveEntry {
15777            phase: PrimitivePhase::BeforeChildren,
15778            node: PrimitiveNode::Draw(DrawPrimitiveNode {
15779                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15780                    rect: Rect {
15781                        x: 2.0,
15782                        y: 3.0,
15783                        width: 6.0,
15784                        height: 4.0,
15785                    },
15786                    brush: Brush::solid(Color::BLACK),
15787                    stroke: None,
15788                },
15789                clip: None,
15790            }),
15791        };
15792        let base = cacheable_layer(
15793            41,
15794            Rect {
15795                x: 0.0,
15796                y: 0.0,
15797                width: 20.0,
15798                height: 20.0,
15799            },
15800            vec![RenderNode::Primitive(primitive.clone())],
15801        );
15802        let mut moved = base.clone();
15803        moved.transform_to_parent = ProjectiveTransform::translation(32.0, 18.0);
15804
15805        assert_eq!(
15806            layer_raster_cache_candidate(&base, 1.25, false, false),
15807            layer_raster_cache_candidate(&moved, 1.25, false, false)
15808        );
15809    }
15810
15811    #[test]
15812    fn layer_raster_cache_candidate_changes_for_translated_content_offset() {
15813        let primitive = PrimitiveEntry {
15814            phase: PrimitivePhase::BeforeChildren,
15815            node: PrimitiveNode::Draw(DrawPrimitiveNode {
15816                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15817                    rect: Rect {
15818                        x: 2.0,
15819                        y: 3.0,
15820                        width: 6.0,
15821                        height: 4.0,
15822                    },
15823                    brush: Brush::solid(Color::BLACK),
15824                    stroke: None,
15825                },
15826                clip: None,
15827            }),
15828        };
15829        let mut base = cacheable_layer(
15830            42,
15831            Rect {
15832                x: 0.0,
15833                y: 0.0,
15834                width: 20.0,
15835                height: 20.0,
15836            },
15837            vec![RenderNode::Primitive(primitive)],
15838        );
15839        base.translated_content_context = true;
15840        base.translated_content_offset = Point::new(0.0, -8.0);
15841        base.recompute_raster_cache_hashes();
15842
15843        let mut moved = base.clone();
15844        moved.translated_content_offset = Point::new(0.0, -16.0);
15845        moved.recompute_raster_cache_hashes();
15846
15847        assert_ne!(
15848            layer_raster_cache_candidate(&base, 1.25, false, false),
15849            layer_raster_cache_candidate(&moved, 1.25, false, false),
15850            "full-surface layer cache candidates must not alias different scroll offsets"
15851        );
15852    }
15853
15854    #[test]
15855    fn layer_raster_cache_candidate_changes_for_child_transform() {
15856        let mut child = cacheable_layer(
15857            8,
15858            Rect {
15859                x: 0.0,
15860                y: 0.0,
15861                width: 12.0,
15862                height: 10.0,
15863            },
15864            vec![],
15865        );
15866        child.transform_to_parent = ProjectiveTransform::translation(4.0, 6.0);
15867        let base = cacheable_layer(
15868            7,
15869            Rect {
15870                x: 0.0,
15871                y: 0.0,
15872                width: 20.0,
15873                height: 20.0,
15874            },
15875            vec![RenderNode::Layer(Box::new(child.clone()))],
15876        );
15877        let mut moved_child = child;
15878        moved_child.transform_to_parent = ProjectiveTransform::translation(9.0, 6.0);
15879        let moved = cacheable_layer(
15880            7,
15881            Rect {
15882                x: 0.0,
15883                y: 0.0,
15884                width: 20.0,
15885                height: 20.0,
15886            },
15887            vec![RenderNode::Layer(Box::new(moved_child))],
15888        );
15889
15890        assert_ne!(
15891            layer_raster_cache_candidate(&base, 1.0, false, false),
15892            layer_raster_cache_candidate(&moved, 1.0, false, false)
15893        );
15894    }
15895
15896    #[test]
15897    fn layer_raster_cache_candidate_rejects_external_backdrop_dependency() {
15898        let mut child = cacheable_layer(
15899            12,
15900            Rect {
15901                x: 0.0,
15902                y: 0.0,
15903                width: 8.0,
15904                height: 8.0,
15905            },
15906            vec![],
15907        );
15908        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
15909        let parent = cacheable_layer(
15910            11,
15911            Rect {
15912                x: 0.0,
15913                y: 0.0,
15914                width: 16.0,
15915                height: 16.0,
15916            },
15917            vec![RenderNode::Layer(Box::new(child))],
15918        );
15919
15920        assert!(layer_raster_cache_candidate(&parent, 1.0, false, false).is_some());
15921        assert!(layer_raster_cache_candidate(&parent, 1.0, true, false).is_none());
15922    }
15923
15924    #[test]
15925    fn layer_raster_cache_candidate_does_not_force_translation_only_text_surfaces() {
15926        let text = RenderNode::Primitive(PrimitiveEntry {
15927            phase: PrimitivePhase::BeforeChildren,
15928            node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
15929                node_id: 77,
15930                rect: Rect {
15931                    x: 2.0,
15932                    y: 3.0,
15933                    width: 48.0,
15934                    height: 18.0,
15935                },
15936                text: std::rc::Rc::new(AnnotatedString::from("runtime cache")),
15937                text_style: TextStyle::default(),
15938                font_size: 14.0,
15939                layout_options: TextLayoutOptions::default(),
15940                clip: None,
15941            })),
15942        });
15943        let mut layer = test_layer(
15944            Rect {
15945                x: 0.0,
15946                y: 0.0,
15947                width: 64.0,
15948                height: 32.0,
15949            },
15950            vec![text],
15951        );
15952        layer.node_id = Some(77);
15953        layer.recompute_raster_cache_hashes();
15954
15955        assert!(
15956            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
15957            "root path should not isolate plain translation-only text layers"
15958        );
15959        assert!(
15960            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
15961            "child path should also render plain translation-only text layers directly"
15962        );
15963    }
15964
15965    #[test]
15966    fn layer_raster_cache_candidate_allows_stable_runtime_child_effect_surfaces() {
15967        let mut layer = test_layer(
15968            Rect {
15969                x: 0.0,
15970                y: 0.0,
15971                width: 64.0,
15972                height: 32.0,
15973            },
15974            vec![RenderNode::Primitive(PrimitiveEntry {
15975                phase: PrimitivePhase::BeforeChildren,
15976                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15977                    primitive: DrawPrimitive::Rect {
15978                        rect: Rect {
15979                            x: 0.0,
15980                            y: 0.0,
15981                            width: 64.0,
15982                            height: 32.0,
15983                        },
15984                        brush: Brush::solid(Color::WHITE),
15985                        stroke: None,
15986                    },
15987                    clip: None,
15988                }),
15989            })],
15990        );
15991        layer.node_id = Some(78);
15992        layer.graphics_layer.render_effect = Some(RenderEffect::blur(4.0));
15993        layer.recompute_raster_cache_hashes();
15994
15995        assert!(
15996            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
15997            "root direct path should not force-cache ordinary stable effects"
15998        );
15999        assert!(
16000            layer_raster_cache_candidate(&layer, 1.0, false, true).is_some(),
16001            "child surface rendering should retain stable non-runtime effects"
16002        );
16003    }
16004
16005    #[test]
16006    fn layer_raster_cache_candidate_rejects_runtime_shader_child_effect_surfaces() {
16007        let mut layer = test_layer(
16008            Rect {
16009                x: 0.0,
16010                y: 0.0,
16011                width: 64.0,
16012                height: 32.0,
16013            },
16014            vec![],
16015        );
16016        layer.node_id = Some(79);
16017        layer.graphics_layer.render_effect = Some(RenderEffect::runtime_shader(
16018            RuntimeShader::new("runtime shader"),
16019        ));
16020        layer.recompute_raster_cache_hashes();
16021
16022        assert!(
16023            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
16024            "runtime shaders must not fill the retained layer cache with per-frame uniform variants"
16025        );
16026    }
16027
16028    #[test]
16029    fn layer_surface_requirements_keep_plain_text_on_direct_path() {
16030        let layer = text_layer_with_style(AnnotatedString::from("plain"), TextStyle::default());
16031
16032        let requirements = layer_surface_requirements(&layer);
16033
16034        assert_eq!(requirements.direct_translation, Some(Point::default()));
16035        assert!(requirements
16036            .surface_requirements
16037            .contains(SurfaceRequirement::PixelStableComposite));
16038        assert!(!requirements
16039            .surface_requirements
16040            .has_isolating_requirement());
16041    }
16042
16043    #[test]
16044    fn layer_surface_requirements_keep_translated_plain_text_leaf_on_direct_path() {
16045        let layer = pure_text_leaf(false, true);
16046
16047        let requirements = layer_surface_requirements(&layer);
16048
16049        assert_eq!(
16050            requirements.direct_translation,
16051            Some(Point::new(11.4, 23.6))
16052        );
16053        assert!(
16054            requirements
16055                .surface_requirements
16056                .contains(SurfaceRequirement::PixelStableComposite)
16057                && !requirements
16058                    .surface_requirements
16059                    .has_isolating_requirement(),
16060            "translated plain text should stay on the direct path and isolate only the glyph draw"
16061        );
16062    }
16063
16064    #[test]
16065    fn layer_surface_requirements_keep_translated_text_leaf_with_background_on_direct_path() {
16066        let layer = snapped_text_leaf(false, true);
16067
16068        let requirements = layer_surface_requirements(&layer);
16069
16070        assert_eq!(
16071            requirements.direct_translation,
16072            Some(Point::new(14.25, 16.5))
16073        );
16074        assert!(
16075            requirements
16076                .surface_requirements
16077                .contains(SurfaceRequirement::PixelStableComposite)
16078                && !requirements
16079                    .surface_requirements
16080                    .has_isolating_requirement(),
16081            "translated text with direct sibling decoration/background should keep the layer direct"
16082        );
16083    }
16084
16085    #[test]
16086    fn translated_plain_text_uses_bounded_snap_surface() {
16087        let root = pure_text_leaf_root(true, true);
16088        let mut rect_cache = HashMap::new();
16089        let mut requirements_cache = HashMap::new();
16090        let collected =
16091            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16092
16093        assert_eq!(collected.child_layers.len(), 1);
16094        assert!(collected.scene.texts.is_empty());
16095        assert!(collected.scene.effect_layers.is_empty());
16096        assert_snap_anchor_close(
16097            collected.child_layers[0].snap_anchor,
16098            Point::new(11.4, 23.6),
16099            "translated plain text's bounded local surface should composite at the content-origin snap phase",
16100        );
16101    }
16102
16103    /// Not a correctness test: a local timing harness for the shape-run
16104    /// collect path. Run manually with
16105    /// `cargo test --release -p cranpose-render-wgpu -- --ignored collect_timing --nocapture`.
16106    #[test]
16107    #[ignore]
16108    fn shape_run_collect_timing_harness() {
16109        use cranpose_render_common::graph::DrawPrimitiveNode;
16110        use cranpose_render_common::layer_composition::local_content_layer_for;
16111        use cranpose_ui_graphics::Stroke;
16112
16113        let bounds = Rect {
16114            x: 0.0,
16115            y: 0.0,
16116            width: 1080.0,
16117            height: 2244.0,
16118        };
16119        let graphics_layer = GraphicsLayer::default();
16120
16121        // A MEGA-BOSS-shaped workload: thousands of consecutive arcs, most
16122        // solid, some gradient, one text-free layer.
16123        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
16124        for i in 0..3000u32 {
16125            let f = i as f32;
16126            let brush = if i % 8 == 0 {
16127                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
16128            } else {
16129                Brush::Solid(Color(0.5, 0.2, 0.8, 1.0))
16130            };
16131            let center = Point::new(540.0 + (f % 400.0), 1122.0 + (f % 350.0));
16132            let radius = 8.0 + (i % 23) as f32;
16133            let half = radius + 4.0;
16134            nodes.push(DrawPrimitiveNode {
16135                primitive: DrawPrimitive::Arc {
16136                    rect: Rect {
16137                        x: center.x - half,
16138                        y: center.y - half,
16139                        width: half * 2.0,
16140                        height: half * 2.0,
16141                    },
16142                    brush,
16143                    center,
16144                    radius,
16145                    start_angle: f * 0.07,
16146                    sweep_angle: 0.5 + (i % 5) as f32,
16147                    stroke: (i % 3 != 0).then(|| Stroke::new(4.0)),
16148                    inner_radius: if i % 3 == 0 { radius * 0.6 } else { 0.0 },
16149                },
16150                clip: None,
16151            });
16152        }
16153
16154        let children: Vec<RenderNode> = nodes
16155            .iter()
16156            .map(|node| {
16157                RenderNode::Primitive(PrimitiveEntry {
16158                    phase: PrimitivePhase::BeforeChildren,
16159                    node: PrimitiveNode::Draw(node.clone()),
16160                })
16161            })
16162            .collect();
16163        let layer = crate::test_support::layer_node(
16164            bounds,
16165            ProjectiveTransform::identity(),
16166            graphics_layer,
16167            children,
16168        );
16169
16170        const ITERS: usize = 300;
16171
16172        // Reference: the pre-run per-primitive path.
16173        let local_layer = local_content_layer_for(&layer.graphics_layer);
16174        let start = Instant::now();
16175        let mut sink_shapes = 0usize;
16176        for _ in 0..ITERS {
16177            let mut scene = CompositorScene::new();
16178            for node in &nodes {
16179                crate::pipeline::push_draw_primitive(
16180                    &node.primitive,
16181                    bounds,
16182                    &local_layer,
16183                    None,
16184                    &mut scene,
16185                    None,
16186                    false,
16187                );
16188            }
16189            sink_shapes = scene.shapes.len();
16190        }
16191        let serial = start.elapsed();
16192
16193        let mut rect_cache = HashMap::new();
16194        let mut requirements_cache = HashMap::new();
16195        let start = Instant::now();
16196        let mut run_shapes = 0usize;
16197        for _ in 0..ITERS {
16198            let collected = collect_layer_contents(
16199                &layer,
16200                None,
16201                None,
16202                &mut rect_cache,
16203                &mut requirements_cache,
16204            );
16205            run_shapes = collected.scene.shapes.len();
16206        }
16207        let run = start.elapsed();
16208
16209        println!(
16210            "per-primitive: {:?}/iter ({sink_shapes} shapes)  shape-run: {:?}/iter ({run_shapes} shapes)",
16211            serial / ITERS as u32,
16212            run / ITERS as u32,
16213        );
16214    }
16215
16216    /// Shared body for the serial and forced-parallel equivalence tests:
16217    fn assert_shape_run_collect_matches_per_primitive_emission() {
16218        use cranpose_render_common::graph::DrawPrimitiveNode;
16219        use cranpose_render_common::layer_composition::local_content_layer_for;
16220        use cranpose_render_common::primitive_emit::{resolve_primitive_clip, PrimitiveClipSpace};
16221        use cranpose_ui_graphics::{CornerRadii, Stroke};
16222
16223        let bounds = Rect {
16224            x: 0.0,
16225            y: 0.0,
16226            width: 800.0,
16227            height: 800.0,
16228        };
16229        // Rotation keeps rigid snapping off, so both paths agree on
16230        // `snap_anchor: None` without replicating the anchor computation here.
16231        let graphics_layer = GraphicsLayer {
16232            scale: 1.25,
16233            translation_x: 3.5,
16234            translation_y: -2.0,
16235            alpha: 0.9,
16236            rotation_z: 0.35,
16237            ..GraphicsLayer::default()
16238        };
16239
16240        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
16241        for i in 0..600u32 {
16242            let f = i as f32;
16243            let brush = if i % 11 == 0 {
16244                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
16245            } else {
16246                Brush::Solid(Color(0.1 + (i % 7) as f32 * 0.1, 0.5, 0.9, 1.0))
16247            };
16248            let stroke = (i % 5 == 0).then(|| Stroke::new(1.0 + (i % 3) as f32));
16249            let primitive = match i % 3 {
16250                0 => DrawPrimitive::Rect {
16251                    rect: Rect {
16252                        x: f % 37.0,
16253                        y: f % 53.0,
16254                        width: 8.0 + f % 9.0,
16255                        height: 6.0 + f % 5.0,
16256                    },
16257                    brush,
16258                    stroke,
16259                },
16260                1 => DrawPrimitive::RoundRect {
16261                    rect: Rect {
16262                        x: f % 41.0,
16263                        y: f % 43.0,
16264                        width: 12.0,
16265                        height: 10.0,
16266                    },
16267                    brush,
16268                    radii: CornerRadii::uniform(2.0 + (i % 4) as f32),
16269                    stroke,
16270                },
16271                _ => {
16272                    let center = Point::new(60.0 + f % 71.0, 60.0 + f % 67.0);
16273                    let radius = 5.0 + (i % 13) as f32;
16274                    // One degenerate sweep proves dropped draws stay dropped.
16275                    let sweep_angle = if i == 302 { 0.0 } else { 0.4 + (i % 6) as f32 };
16276                    let half = radius + 4.0;
16277                    DrawPrimitive::Arc {
16278                        rect: Rect {
16279                            x: center.x - half,
16280                            y: center.y - half,
16281                            width: half * 2.0,
16282                            height: half * 2.0,
16283                        },
16284                        brush,
16285                        center,
16286                        radius,
16287                        start_angle: f * 0.11,
16288                        sweep_angle,
16289                        stroke: (i % 2 == 0).then(|| Stroke::new(3.0)),
16290                        inner_radius: if i % 4 == 2 { radius * 0.5 } else { 0.0 },
16291                    }
16292                }
16293            };
16294            let primitive = if i == 300 {
16295                // A nested blend disqualifies the run view and forces a
16296                // mid-run flush through the serial path, splitting 600 draws
16297                // into two runs that are both long enough to fan out.
16298                DrawPrimitive::Blend {
16299                    primitive: Box::new(DrawPrimitive::Blend {
16300                        primitive: Box::new(primitive),
16301                        blend_mode: BlendMode::SrcOver,
16302                    }),
16303                    blend_mode: BlendMode::DstOut,
16304                }
16305            } else if i % 7 == 3 {
16306                DrawPrimitive::Blend {
16307                    primitive: Box::new(primitive),
16308                    blend_mode: BlendMode::DstOut,
16309                }
16310            } else {
16311                primitive
16312            };
16313            let clip = (i % 31 == 7).then_some(Rect {
16314                x: 0.0,
16315                y: 0.0,
16316                width: 30.0,
16317                height: 30.0,
16318            });
16319            nodes.push(DrawPrimitiveNode { primitive, clip });
16320        }
16321
16322        let children: Vec<RenderNode> = nodes
16323            .iter()
16324            .map(|node| {
16325                RenderNode::Primitive(PrimitiveEntry {
16326                    phase: PrimitivePhase::BeforeChildren,
16327                    node: PrimitiveNode::Draw(node.clone()),
16328                })
16329            })
16330            .collect();
16331        let layer = crate::test_support::layer_node(
16332            bounds,
16333            ProjectiveTransform::identity(),
16334            graphics_layer,
16335            children,
16336        );
16337
16338        let mut rect_cache = HashMap::new();
16339        let mut requirements_cache = HashMap::new();
16340        let collected =
16341            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
16342
16343        // The reference scene: every primitive through the per-primitive
16344        // emission path, exactly as the pre-run collect loop ran it.
16345        let local_layer = local_content_layer_for(&layer.graphics_layer);
16346        let mut expected = CompositorScene::new();
16347        for node in &nodes {
16348            let clip = resolve_primitive_clip(
16349                node.clip,
16350                bounds,
16351                &local_layer,
16352                None,
16353                PrimitiveClipSpace::Local,
16354            );
16355            if node.clip.is_some() && clip.is_none() {
16356                continue;
16357            }
16358            crate::pipeline::push_draw_primitive(
16359                &node.primitive,
16360                bounds,
16361                &local_layer,
16362                clip,
16363                &mut expected,
16364                None,
16365                false,
16366            );
16367        }
16368
16369        assert!(
16370            collected.scene.shapes.len() >= 590,
16371            "the runs should engage the parallel branch: got {} shapes",
16372            collected.scene.shapes.len()
16373        );
16374        assert_eq!(collected.scene.shapes.len(), expected.shapes.len());
16375        assert_eq!(collected.scene.draw_ops, expected.draw_ops);
16376        assert_eq!(collected.scene.next_z, expected.next_z);
16377        assert!(
16378            collected
16379                .scene
16380                .shapes
16381                .iter()
16382                .all(|s| s.snap_anchor.is_none()),
16383            "a rotated layer must not rigid-snap; the reference scene assumes it"
16384        );
16385        for (index, (got, want)) in collected
16386            .scene
16387            .shapes
16388            .iter()
16389            .zip(&expected.shapes)
16390            .enumerate()
16391        {
16392            assert_eq!(got.rect, want.rect, "shape {index} rect");
16393            assert_eq!(got.local_rect, want.local_rect, "shape {index} local_rect");
16394            assert_eq!(got.quad, want.quad, "shape {index} quad");
16395            assert_eq!(got.snap_anchor, want.snap_anchor, "shape {index} snap");
16396            assert_eq!(got.brush, want.brush, "shape {index} brush");
16397            assert_eq!(got.shape, want.shape, "shape {index} shape");
16398            assert_eq!(got.stroke, want.stroke, "shape {index} stroke");
16399            assert_eq!(got.arc, want.arc, "shape {index} arc");
16400            assert_eq!(got.z_index, want.z_index, "shape {index} z");
16401            assert_eq!(got.clip, want.clip, "shape {index} clip");
16402            assert_eq!(got.blend_mode, want.blend_mode, "shape {index} blend");
16403            assert_eq!(
16404                got.motion_context_animated, want.motion_context_animated,
16405                "shape {index} motion flag"
16406            );
16407        }
16408    }
16409
16410    /// The run collector must emit exactly what per-primitive emission does,
16411    /// on BOTH flush paths: the serial drain and the scoped-thread fan-out
16412    /// (forced via the tuning override, since a test-sized scene would never
16413    /// cross the size gate on its own).
16414    #[test]
16415    fn shape_run_collect_matches_per_primitive_emission_exactly() {
16416        assert_shape_run_collect_matches_per_primitive_emission();
16417        crate::normalized_scene::force_shape_run_parallel_for_tests(true);
16418        let outcome =
16419            std::panic::catch_unwind(assert_shape_run_collect_matches_per_primitive_emission);
16420        crate::normalized_scene::force_shape_run_parallel_for_tests(false);
16421        if let Err(payload) = outcome {
16422            std::panic::resume_unwind(payload);
16423        }
16424    }
16425
16426    #[test]
16427    fn non_translated_text_local_surface_keeps_linear_composite_resolve() {
16428        let layer = text_layer_with_style(
16429            AnnotatedString::from("gradient"),
16430            TextStyle::from_span_style(SpanStyle {
16431                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
16432                ..SpanStyle::default()
16433            }),
16434        );
16435        let requirements = layer_surface_requirements(&layer);
16436
16437        assert!(requirements
16438            .surface_requirements
16439            .contains(SurfaceRequirement::TextMaterialMask));
16440        assert_eq!(
16441            composite_sample_mode_for_requirements(false, false, requirements),
16442            CompositeSampleMode::Linear
16443        );
16444    }
16445
16446    #[test]
16447    fn inherited_translated_text_local_surface_uses_box4_layer_surface() {
16448        let layer = text_layer_with_style(
16449            AnnotatedString::from("shadow"),
16450            TextStyle::from_span_style(SpanStyle {
16451                shadow: Some(Shadow {
16452                    color: Color::BLACK,
16453                    offset: Point::new(1.0, 2.0),
16454                    blur_radius: 3.0,
16455                }),
16456                ..SpanStyle::default()
16457            }),
16458        );
16459        let requirements = layer_surface_requirements(&layer);
16460
16461        assert!(requirements
16462            .surface_requirements
16463            .contains(SurfaceRequirement::TextMaterialMask));
16464        assert_eq!(
16465            composite_sample_mode_for_requirements(true, false, requirements),
16466            CompositeSampleMode::Box4
16467        );
16468        assert_eq!(
16469            layer_surface_target_scale(
16470                true,
16471                false,
16472                requirements,
16473                1.25,
16474                layer_surface_scale(&layer)
16475            ),
16476            SurfaceRequirementSet::default()
16477                .with(SurfaceRequirement::TextMaterialMask)
16478                .with(SurfaceRequirement::MotionStableCapture)
16479                .target_scale(1.25, 1.0)
16480        );
16481    }
16482
16483    #[test]
16484    fn translated_text_local_surface_inside_capture_keeps_parent_scale() {
16485        let layer = text_layer_with_style(
16486            AnnotatedString::from("shadow"),
16487            TextStyle::from_span_style(SpanStyle {
16488                shadow: Some(Shadow {
16489                    color: Color::BLACK,
16490                    offset: Point::new(1.0, 2.0),
16491                    blur_radius: 3.0,
16492                }),
16493                ..SpanStyle::default()
16494            }),
16495        );
16496        let requirements = layer_surface_requirements(&layer);
16497
16498        assert_eq!(
16499            composite_sample_mode_for_requirements(true, true, requirements),
16500            CompositeSampleMode::Linear
16501        );
16502        assert_eq!(
16503            layer_surface_target_scale(true, true, requirements, 10.0, layer_surface_scale(&layer)),
16504            SurfaceRequirementSet::default()
16505                .with(SurfaceRequirement::TextMaterialMask)
16506                .target_scale(10.0, 1.0)
16507        );
16508    }
16509
16510    #[test]
16511    fn layer_surface_requirements_use_local_surface_for_gradient_and_stroke_text() {
16512        let cases = [
16513            (
16514                "draw_style",
16515                AnnotatedString::from("draw_style"),
16516                TextStyle::from_span_style(SpanStyle {
16517                    draw_style: Some(TextDrawStyle::Stroke { width: 2.0 }),
16518                    ..SpanStyle::default()
16519                }),
16520            ),
16521            (
16522                "gradient_brush",
16523                AnnotatedString::from("gradient"),
16524                TextStyle::from_span_style(SpanStyle {
16525                    brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
16526                    ..SpanStyle::default()
16527                }),
16528            ),
16529        ];
16530
16531        for (label, text, text_style) in cases {
16532            let layer = text_layer_with_style(text, text_style);
16533            let requirements = layer_surface_requirements(&layer);
16534            assert!(
16535                requirements
16536                    .surface_requirements
16537                    .contains(SurfaceRequirement::TextMaterialMask),
16538                "{label} text should use a bounded local surface: {requirements:?}"
16539            );
16540        }
16541    }
16542
16543    #[test]
16544    fn layer_surface_requirements_use_local_surface_for_complex_text_effects() {
16545        let cases = [
16546            (
16547                "shadow",
16548                AnnotatedString::from("shadow"),
16549                TextStyle::from_span_style(SpanStyle {
16550                    shadow: Some(Shadow {
16551                        color: Color::BLACK,
16552                        offset: Point::new(1.0, 2.0),
16553                        blur_radius: 3.0,
16554                    }),
16555                    ..SpanStyle::default()
16556                }),
16557            ),
16558            (
16559                "background",
16560                AnnotatedString::from("background"),
16561                TextStyle::from_span_style(SpanStyle {
16562                    background: Some(Color::BLACK),
16563                    ..SpanStyle::default()
16564                }),
16565            ),
16566            (
16567                "baseline_shift",
16568                AnnotatedString::from("baseline_shift"),
16569                TextStyle::from_span_style(SpanStyle {
16570                    baseline_shift: Some(BaselineShift::SUPERSCRIPT),
16571                    ..SpanStyle::default()
16572                }),
16573            ),
16574            (
16575                "geometric_transform",
16576                AnnotatedString::from("geometric_transform"),
16577                TextStyle::from_span_style(SpanStyle {
16578                    text_geometric_transform: Some(TextGeometricTransform {
16579                        scale_x: 1.2,
16580                        skew_x: 0.15,
16581                    }),
16582                    ..SpanStyle::default()
16583                }),
16584            ),
16585            (
16586                "letter_spacing",
16587                AnnotatedString::from("letter_spacing"),
16588                TextStyle::from_span_style(SpanStyle {
16589                    letter_spacing: TextUnit::Em(0.2),
16590                    ..SpanStyle::default()
16591                }),
16592            ),
16593        ];
16594
16595        for (label, text, text_style) in cases {
16596            let layer = text_layer_with_style(text, text_style);
16597            let requirements = layer_surface_requirements(&layer);
16598            assert!(
16599                requirements
16600                    .surface_requirements
16601                    .contains(SurfaceRequirement::TextMaterialMask),
16602                "{label} text should use a bounded local surface: {requirements:?}"
16603            );
16604            assert_eq!(
16605                requirements.direct_translation,
16606                Some(Point::default()),
16607                "{label} text should still classify as a direct translation"
16608            );
16609        }
16610    }
16611
16612    #[test]
16613    fn layer_surface_requirements_color_only_span_styles_use_direct_path() {
16614        let layer = text_layer_with_style(
16615            AnnotatedString {
16616                text: "styled".to_string(),
16617                span_styles: vec![RangeStyle {
16618                    item: SpanStyle {
16619                        color: Some(Color::BLACK),
16620                        ..SpanStyle::default()
16621                    },
16622                    range: 0..3,
16623                }],
16624                ..AnnotatedString::default()
16625            },
16626            TextStyle::default(),
16627        );
16628        let requirements = layer_surface_requirements(&layer);
16629        assert!(
16630            !requirements
16631                .surface_requirements
16632                .contains(SurfaceRequirement::TextMaterialMask),
16633            "color-only span styles should render directly via software text raster colors"
16634        );
16635    }
16636
16637    #[test]
16638    fn layer_surface_requirements_keep_decoration_only_text_on_direct_path() {
16639        let layer = text_layer_with_style(
16640            AnnotatedString::from("decoration"),
16641            TextStyle::from_span_style(SpanStyle {
16642                text_decoration: Some(TextDecoration::UNDERLINE),
16643                ..SpanStyle::default()
16644            }),
16645        );
16646
16647        let requirements = layer_surface_requirements(&layer);
16648
16649        assert_eq!(requirements.direct_translation, Some(Point::default()));
16650        assert!(
16651            requirements
16652                .surface_requirements
16653                .contains(SurfaceRequirement::PixelStableComposite)
16654                && !requirements
16655                    .surface_requirements
16656                    .has_isolating_requirement(),
16657            "decoration-only text should not force an isolating layer surface: {requirements:?}"
16658        );
16659    }
16660
16661    #[test]
16662    fn direct_text_leaf_snaps_modifier_background_and_text_with_one_anchor() {
16663        let root = snapped_text_leaf_root(false, false);
16664        let mut rect_cache = HashMap::new();
16665        let mut requirements_cache = HashMap::new();
16666
16667        let collected =
16668            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16669
16670        assert_eq!(collected.scene.shapes.len(), 1);
16671        assert_eq!(collected.scene.images.len(), 1);
16672        assert_eq!(collected.scene.texts.len(), 1);
16673        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
16674        assert_eq!(collected.scene.shapes[0].snap_anchor, expected_anchor);
16675        assert_eq!(collected.scene.images[0].snap_anchor, expected_anchor);
16676        assert_eq!(collected.scene.texts[0].snap_anchor, expected_anchor);
16677    }
16678
16679    #[test]
16680    fn animated_translated_content_text_leaf_uses_bounded_content_snap() {
16681        let root = snapped_text_leaf_root(true, true);
16682        let mut rect_cache = HashMap::new();
16683        let mut requirements_cache = HashMap::new();
16684
16685        let collected =
16686            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16687
16688        assert_eq!(collected.child_layers.len(), 1);
16689        assert!(collected.scene.shapes.is_empty());
16690        assert!(collected.scene.images.is_empty());
16691        assert!(collected.scene.texts.is_empty());
16692        assert!(collected.scene.effect_layers.is_empty());
16693        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
16694        assert_eq!(
16695            collected.child_layers[0].snap_anchor, expected_anchor,
16696            "active translated leaf surface should keep the content-origin snap phase"
16697        );
16698    }
16699
16700    #[test]
16701    fn translated_content_assigns_motion_anchor_to_rotated_child_surface() {
16702        let mut child = snapped_text_leaf(false, false);
16703        child.graphics_layer.rotation_z = 5.0;
16704        child.transform_to_parent =
16705            cranpose_render_common::layer_transform::layer_transform_to_parent(
16706                child.local_bounds,
16707                Point::new(108.0, 3.0),
16708                &child.graphics_layer,
16709            );
16710        child.recompute_raster_cache_hashes();
16711        let mut root = test_layer(
16712            Rect {
16713                x: 0.0,
16714                y: 0.0,
16715                width: 320.0,
16716                height: 180.0,
16717            },
16718            vec![RenderNode::Layer(Box::new(child))],
16719        );
16720        root.translated_content_context = true;
16721        root.translated_content_offset = Point::new(0.0, -80.8);
16722        root.recompute_raster_cache_hashes();
16723        let mut rect_cache = HashMap::new();
16724        let mut requirements_cache = HashMap::new();
16725
16726        let collected =
16727            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16728
16729        assert_eq!(collected.child_layers.len(), 1);
16730        assert!(
16731            collected.child_layers[0].snap_anchor.is_some(),
16732            "a projective child still translates rigidly with its scrolling parent"
16733        );
16734    }
16735
16736    #[test]
16737    fn rested_translated_content_context_text_leaf_snaps_for_crisp_scroll_rest() {
16738        let root = snapped_text_leaf_root(false, true);
16739        let mut rect_cache = HashMap::new();
16740        let mut requirements_cache = HashMap::new();
16741
16742        let collected =
16743            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16744
16745        assert_eq!(collected.child_layers.len(), 0);
16746        assert_eq!(collected.scene.shapes.len(), 1);
16747        assert_eq!(collected.scene.images.len(), 1);
16748        assert_eq!(collected.scene.texts.len(), 1);
16749        assert_eq!(collected.scene.effect_layers.len(), 0);
16750        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
16751        assert_eq!(
16752            collected.scene.shapes[0].snap_anchor, expected_anchor,
16753            "rested scroll content should snap back to device pixels"
16754        );
16755        assert_eq!(
16756            collected.scene.images[0].snap_anchor, expected_anchor,
16757            "rested scroll images should snap back to device pixels"
16758        );
16759        assert_eq!(
16760            collected.scene.texts[0].snap_anchor, expected_anchor,
16761            "rested scroll text should snap back to device pixels"
16762        );
16763    }
16764
16765    #[test]
16766    fn complex_text_uses_local_surface() {
16767        let root = translated_content_local_surface_root();
16768        let mut rect_cache = HashMap::new();
16769        let mut requirements_cache = HashMap::new();
16770
16771        let collected =
16772            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16773
16774        assert!(
16775            !collected.child_layers.is_empty(),
16776            "translated-content effectful text should render through a bounded local surface"
16777        );
16778        assert!(collected.scene.texts.is_empty());
16779        assert!(collected.scene.shadow_draws.is_empty());
16780    }
16781
16782    #[test]
16783    fn translated_content_surface_composite_uses_scroll_content_snap_anchor() {
16784        let mut root = translated_content_local_surface_root();
16785        let scroll_offset = Point::new(0.0, -18.5);
16786        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
16787            panic!("expected translated content layer");
16788        };
16789        translated_content.translated_content_offset = scroll_offset;
16790        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
16791            panic!("expected effectful text layer");
16792        };
16793        effectful_text.transform_to_parent =
16794            effectful_text
16795                .transform_to_parent
16796                .then(ProjectiveTransform::translation(
16797                    scroll_offset.x,
16798                    scroll_offset.y,
16799                ));
16800
16801        let mut rect_cache = HashMap::new();
16802        let mut requirements_cache = HashMap::new();
16803        let collected =
16804            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16805
16806        assert_eq!(collected.child_layers.len(), 1);
16807        assert_eq!(
16808            collected.child_layers[0].snap_anchor,
16809            Some(SnapAnchor::rigid(Point::new(14.25, -2.0))),
16810            "isolated scrolled descendants must composite with the same content-origin snap phase"
16811        );
16812    }
16813
16814    #[test]
16815    fn animated_translated_content_surface_composite_uses_scroll_content_snap_anchor() {
16816        let mut root = translated_content_local_surface_root();
16817        let scroll_offset = Point::new(0.0, -18.5);
16818        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
16819            panic!("expected translated content layer");
16820        };
16821        translated_content.motion_context_animated = true;
16822        translated_content.translated_content_offset = scroll_offset;
16823        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
16824            panic!("expected effectful text layer");
16825        };
16826        effectful_text.transform_to_parent =
16827            effectful_text
16828                .transform_to_parent
16829                .then(ProjectiveTransform::translation(
16830                    scroll_offset.x,
16831                    scroll_offset.y,
16832                ));
16833
16834        let mut rect_cache = HashMap::new();
16835        let mut requirements_cache = HashMap::new();
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_eq!(
16841            collected.child_layers[0].snap_anchor,
16842            Some(SnapAnchor::rigid(Point::new(14.25, 16.5))),
16843            "animated translated content should composite the stable local surface at the viewport-origin snap phase"
16844        );
16845    }
16846
16847    #[test]
16848    fn translated_text_material_effect_layer_uses_scroll_content_snap_anchor() {
16849        let mut layer = text_layer_with_style(
16850            AnnotatedString::from("gradient"),
16851            TextStyle::from_span_style(SpanStyle {
16852                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
16853                ..SpanStyle::default()
16854            }),
16855        );
16856        layer.translated_content_context = true;
16857        layer.translated_content_offset = Point::new(0.0, -18.5);
16858        let mut rect_cache = HashMap::new();
16859        let mut requirements_cache = HashMap::new();
16860
16861        let collected =
16862            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
16863
16864        assert_eq!(collected.scene.effect_layers.len(), 1);
16865        assert_eq!(
16866            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
16867            CompositeSampleMode::Box4
16868        );
16869        assert_eq!(
16870            collected.scene.effect_layers[0].snap_anchor,
16871            Some(SnapAnchor::rigid(Point::new(0.0, -18.5))),
16872            "text material surfaces must composite with the scroll content-origin snap phase"
16873        );
16874    }
16875
16876    #[test]
16877    fn translated_layer_surface_capture_does_not_restart_local_picture_for_shadow_text() {
16878        let mut layer = text_layer_with_style(
16879            AnnotatedString::from("shadow"),
16880            TextStyle::from_span_style(SpanStyle {
16881                shadow: Some(Shadow {
16882                    color: Color::BLACK,
16883                    offset: Point::new(1.0, 2.0),
16884                    blur_radius: 3.0,
16885                }),
16886                ..SpanStyle::default()
16887            }),
16888        );
16889        layer.translated_content_context = true;
16890        let mut rect_cache = HashMap::new();
16891        let mut requirements_cache = HashMap::new();
16892
16893        let collected = collect_layer_contents_with_translation_context(
16894            &layer,
16895            None,
16896            None,
16897            TranslationRenderContext {
16898                inherited_content_translation: false,
16899                surface_capture_active: true,
16900                local_picture_capture_active: true,
16901                ..TranslationRenderContext::default()
16902            },
16903            &mut rect_cache,
16904            &mut requirements_cache,
16905        );
16906
16907        assert!(
16908            collected.scene.effect_layers.is_empty(),
16909            "a translated layer surface already provides the stable local capture"
16910        );
16911        assert_eq!(collected.scene.shadow_draws.len(), 1);
16912        assert_eq!(collected.scene.texts.len(), 1);
16913        assert!(
16914            !collected.scene.texts[0].translated_content_context,
16915            "text inside an active motion-stable capture must raster in capture-local coordinates"
16916        );
16917    }
16918
16919    #[test]
16920    fn translated_layer_surface_capture_keeps_only_material_effect_layers() {
16921        let mut layer = text_layer_with_style(
16922            AnnotatedString::from("gradient"),
16923            TextStyle::from_span_style(SpanStyle {
16924                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
16925                ..SpanStyle::default()
16926            }),
16927        );
16928        layer.translated_content_context = true;
16929        let mut rect_cache = HashMap::new();
16930        let mut requirements_cache = HashMap::new();
16931
16932        let collected = collect_layer_contents_with_translation_context(
16933            &layer,
16934            None,
16935            None,
16936            TranslationRenderContext {
16937                inherited_content_translation: false,
16938                surface_capture_active: true,
16939                local_picture_capture_active: true,
16940                ..TranslationRenderContext::default()
16941            },
16942            &mut rect_cache,
16943            &mut requirements_cache,
16944        );
16945
16946        assert_eq!(collected.scene.effect_layers.len(), 1);
16947        assert!(
16948            collected.scene.effect_layers[0]
16949                .requirements
16950                .contains(SurfaceRequirement::MotionStableCapture),
16951            "translated text materials still need motion-stable resolve semantics inside a stable capture"
16952        );
16953        assert_eq!(
16954            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
16955            CompositeSampleMode::Box4
16956        );
16957        assert_eq!(
16958            effect_layer_target_scale(&collected.scene.effect_layers[0], 10.0),
16959            10.0
16960        );
16961        assert!(collected.scene.effect_layers[0].effect.is_some());
16962    }
16963
16964    #[test]
16965    fn translated_viewport_surface_does_not_add_plain_local_picture_capture() {
16966        let mut layer = text_layer_with_style(
16967            AnnotatedString::from("shadow"),
16968            TextStyle::from_span_style(SpanStyle {
16969                shadow: Some(Shadow {
16970                    color: Color::BLACK,
16971                    offset: Point::new(1.0, 2.0),
16972                    blur_radius: 3.0,
16973                }),
16974                ..SpanStyle::default()
16975            }),
16976        );
16977        layer.translated_content_context = true;
16978        layer.motion_context_animated = true;
16979        let mut rect_cache = HashMap::new();
16980        let mut requirements_cache = HashMap::new();
16981
16982        let collected = collect_layer_contents_with_translation_context(
16983            &layer,
16984            None,
16985            None,
16986            TranslationRenderContext {
16987                surface_capture_active: true,
16988                ..TranslationRenderContext::default()
16989            },
16990            &mut rect_cache,
16991            &mut requirements_cache,
16992        );
16993
16994        assert_eq!(
16995            collected.scene.effect_layers.len(),
16996            0,
16997            "plain translated content inside a viewport surface should not be captured again"
16998        );
16999        assert_eq!(collected.scene.shadow_draws.len(), 1);
17000        assert_eq!(collected.scene.texts.len(), 1);
17001    }
17002
17003    #[test]
17004    fn static_pure_text_leaf_snaps_without_sibling_draw_primitives() {
17005        let root = pure_text_leaf_root(false, false);
17006        let mut rect_cache = HashMap::new();
17007        let mut requirements_cache = HashMap::new();
17008
17009        let collected =
17010            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17011
17012        assert_eq!(collected.scene.texts.len(), 1);
17013        assert!(
17014            collected.scene.texts[0].snap_anchor.is_some(),
17015            "idle pure text leaves should participate in rigid snap anchoring"
17016        );
17017    }
17018
17019    #[test]
17020    fn animated_pure_text_leaf_stays_unsnapped() {
17021        let root = pure_text_leaf_root(true, false);
17022        let mut rect_cache = HashMap::new();
17023        let mut requirements_cache = HashMap::new();
17024
17025        let collected =
17026            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17027
17028        assert_eq!(collected.scene.texts.len(), 1);
17029        assert_eq!(collected.scene.texts[0].snap_anchor, None);
17030    }
17031
17032    #[test]
17033    fn animated_translated_pure_text_uses_bounded_content_snap() {
17034        let root = pure_text_leaf_root(true, true);
17035        let mut rect_cache = HashMap::new();
17036        let mut requirements_cache = HashMap::new();
17037
17038        let collected =
17039            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17040
17041        assert_eq!(collected.child_layers.len(), 1);
17042        assert!(collected.scene.texts.is_empty());
17043        assert!(collected.scene.effect_layers.is_empty());
17044        assert_snap_anchor_close(
17045            collected.child_layers[0].snap_anchor,
17046            Point::new(11.4, 23.6),
17047            "animated translated pure text should use the bounded content snap phase",
17048        );
17049    }
17050
17051    #[test]
17052    fn rested_translated_pure_text_leaf_snaps_for_crisp_scroll_rest() {
17053        let root = pure_text_leaf_root(false, true);
17054        let mut rect_cache = HashMap::new();
17055        let mut requirements_cache = HashMap::new();
17056
17057        let collected =
17058            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17059
17060        assert_eq!(collected.child_layers.len(), 0);
17061        assert_eq!(collected.scene.texts.len(), 1);
17062        assert_eq!(collected.scene.effect_layers.len(), 0);
17063        assert_snap_anchor_close(
17064            collected.scene.texts[0].snap_anchor,
17065            Point::new(11.4, 23.6),
17066            "rested translated text should snap to device pixels",
17067        );
17068    }
17069
17070    #[test]
17071    fn static_gpu_effect_text_leaf_stays_unsnapped() {
17072        let root = text_layer_with_style(
17073            AnnotatedString::from("Gradient"),
17074            TextStyle::from_span_style(SpanStyle {
17075                brush: Some(Brush::linear_gradient(vec![
17076                    Color(0.2, 0.8, 1.0, 1.0),
17077                    Color(1.0, 0.7, 0.4, 1.0),
17078                ])),
17079                draw_style: Some(TextDrawStyle::Stroke { width: 2.5 }),
17080                ..SpanStyle::default()
17081            }),
17082        );
17083        let mut rect_cache = HashMap::new();
17084        let mut requirements_cache = HashMap::new();
17085
17086        let collected =
17087            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17088
17089        assert_eq!(collected.scene.texts.len(), 1);
17090        assert_eq!(
17091            collected.scene.texts[0].snap_anchor, None,
17092            "gpu text-effect leaves must not take the rigid text snap path"
17093        );
17094        assert_eq!(
17095            collected.scene.effect_layers.len(),
17096            1,
17097            "gradient stroke text should still emit a runtime shader effect layer"
17098        );
17099    }
17100
17101    #[test]
17102    fn layer_surface_requirements_keep_shape_plus_direct_child_on_direct_path() {
17103        let mut child = test_layer(
17104            Rect {
17105                x: 0.0,
17106                y: 0.0,
17107                width: 40.0,
17108                height: 20.0,
17109            },
17110            vec![RenderNode::Primitive(PrimitiveEntry {
17111                phase: PrimitivePhase::BeforeChildren,
17112                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17113                    primitive: DrawPrimitive::Rect {
17114                        rect: Rect {
17115                            x: 0.0,
17116                            y: 0.0,
17117                            width: 40.0,
17118                            height: 20.0,
17119                        },
17120                        brush: Brush::solid(Color::WHITE),
17121                        stroke: None,
17122                    },
17123                    clip: None,
17124                }),
17125            })],
17126        );
17127        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
17128
17129        let layer = test_layer(
17130            Rect {
17131                x: 0.0,
17132                y: 0.0,
17133                width: 64.0,
17134                height: 32.0,
17135            },
17136            vec![
17137                RenderNode::Primitive(PrimitiveEntry {
17138                    phase: PrimitivePhase::BeforeChildren,
17139                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
17140                        primitive: DrawPrimitive::Rect {
17141                            rect: Rect {
17142                                x: 0.0,
17143                                y: 0.0,
17144                                width: 64.0,
17145                                height: 32.0,
17146                            },
17147                            brush: Brush::solid(Color::BLACK),
17148                            stroke: None,
17149                        },
17150                        clip: None,
17151                    }),
17152                }),
17153                RenderNode::Layer(Box::new(child)),
17154            ],
17155        );
17156
17157        let requirements = layer_surface_requirements(&layer);
17158
17159        assert_eq!(requirements.direct_translation, Some(Point::default()));
17160        assert!(!requirements
17161            .surface_requirements
17162            .contains(SurfaceRequirement::MixedDirectContent));
17163        assert!(!requirements
17164            .surface_requirements
17165            .has_isolating_requirement());
17166    }
17167
17168    #[test]
17169    fn collect_layer_contents_translates_direct_text_rects_into_parent_space() {
17170        let mut child = text_layer_with_style(
17171            AnnotatedString::from("direct"),
17172            TextStyle::from_span_style(SpanStyle {
17173                text_decoration: Some(TextDecoration::UNDERLINE),
17174                ..SpanStyle::default()
17175            }),
17176        );
17177        child.transform_to_parent = ProjectiveTransform::translation(9.0, 7.0);
17178
17179        let parent = test_layer(
17180            Rect {
17181                x: 0.0,
17182                y: 0.0,
17183                width: 64.0,
17184                height: 32.0,
17185            },
17186            vec![RenderNode::Layer(Box::new(child))],
17187        );
17188
17189        let mut rect_cache = HashMap::new();
17190        let mut requirements_cache = HashMap::new();
17191        let collected = with_test_app_context(|| {
17192            collect_layer_contents(
17193                &parent,
17194                None,
17195                None,
17196                &mut rect_cache,
17197                &mut requirements_cache,
17198            )
17199        });
17200
17201        assert!(
17202            collected.child_layers.is_empty(),
17203            "decoration-only text child should collapse directly into the parent scene"
17204        );
17205        assert_eq!(collected.scene.texts.len(), 1, "expected one text draw");
17206        let text = &collected.scene.texts[0];
17207        assert!(
17208            text.rect.x >= 9.0 && text.rect.y >= 7.0,
17209            "collapsed text rect should be translated into parent space, got {:?}",
17210            text.rect
17211        );
17212        assert!(
17213            collected
17214                .scene
17215                .shapes
17216                .iter()
17217                .any(|shape| shape.rect.y >= 7.0),
17218            "collapsed underline geometry should also be translated into parent space"
17219        );
17220    }
17221
17222    #[test]
17223    fn normalized_scene_keeps_lazy_after_bound_text_for_prewarm() {
17224        use std::cell::RefCell;
17225
17226        fn collect_graph_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
17227            for child in &layer.children {
17228                match child {
17229                    RenderNode::Primitive(PrimitiveEntry {
17230                        node: PrimitiveNode::Text(text),
17231                        ..
17232                    }) => labels.push(text.text.text.clone()),
17233                    RenderNode::Layer(child_layer) => {
17234                        collect_graph_text_labels(child_layer, labels)
17235                    }
17236                    RenderNode::Primitive(_) | RenderNode::DrawRun(_) => {}
17237                }
17238            }
17239        }
17240
17241        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
17242        let state_holder_for_comp = state_holder.clone();
17243        let mut composition = cranpose_ui::run_test_composition(move || {
17244            let list_state = remember_lazy_list_state();
17245            *state_holder_for_comp.borrow_mut() = Some(list_state);
17246            let mut spec = LazyColumnSpec::new()
17247                .vertical_arrangement(cranpose_ui::LinearArrangement::SpacedBy(6.0));
17248            spec.beyond_bounds_item_count = 0;
17249            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
17250                scope.items(
17251                    12,
17252                    None::<fn(usize) -> u64>,
17253                    None::<fn(usize) -> u64>,
17254                    |index| {
17255                        Text(
17256                            format!("WarmRow {index}"),
17257                            Modifier::empty().height(32.0),
17258                            TextStyle::default(),
17259                        );
17260                    },
17261                );
17262            });
17263        });
17264
17265        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
17266        list_state.scroll_to_item(4, 0.0);
17267
17268        let root = composition.root().expect("lazy column root");
17269        let handle = composition.runtime_handle();
17270        let mut applier = composition.applier_mut();
17271        applier.set_runtime_handle(handle);
17272        let _ = applier
17273            .compute_layout(
17274                root,
17275                Size {
17276                    width: 240.0,
17277                    height: 240.0,
17278                },
17279            )
17280            .expect("lazy column layout");
17281        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
17282        applier.clear_runtime_handle();
17283        let mut graph_labels = Vec::new();
17284        collect_graph_text_labels(&graph.root, &mut graph_labels);
17285
17286        let visible_indices: Vec<_> = list_state
17287            .layout_info()
17288            .visible_items_info
17289            .iter()
17290            .map(|item| item.index)
17291            .collect();
17292        assert_eq!(
17293            visible_indices,
17294            vec![4, 5, 6],
17295            "test setup expects exactly three viewport-visible rows"
17296        );
17297
17298        let mut rect_cache = HashMap::new();
17299        let mut requirements_cache = HashMap::new();
17300        let collected = with_test_app_context(|| {
17301            collect_layer_contents(
17302                &graph.root,
17303                None,
17304                None,
17305                &mut rect_cache,
17306                &mut requirements_cache,
17307            )
17308        });
17309        let root_text_labels: Vec<_> = collected
17310            .scene
17311            .texts
17312            .iter()
17313            .map(|text| text.text.text.clone())
17314            .collect();
17315        let child_layer_count = collected.child_layers.len();
17316        let warm_text = collected
17317            .scene
17318            .texts
17319            .iter()
17320            .find(|text| text.text.text == "WarmRow 7")
17321            .unwrap_or_else(|| {
17322                panic!(
17323                    "after-bound lazy text should reach WGPU scene collection; graph_texts={graph_labels:?} root_texts={root_text_labels:?} child_layers={child_layer_count}"
17324                )
17325            });
17326
17327        assert!(
17328            warm_text.rect.y >= 96.0,
17329            "after-bound text should be below the viewport, got {:?}",
17330            warm_text.rect
17331        );
17332        assert_eq!(
17333            visible_draw_rect(warm_text.rect, warm_text.clip),
17334            None,
17335            "after-bound text should remain clipped away for drawing while staying available for glyph prewarm"
17336        );
17337        assert!(
17338            text_draw_should_prewarm_in_viewport(
17339                warm_text.rect,
17340                warm_text.clip,
17341                ViewportUniformParams {
17342                    width: 240,
17343                    height: 96,
17344                    offset: [0.0, 0.0],
17345                },
17346                1.0,
17347            ),
17348            "after-bound text inside the warm window must be selected by WGPU prewarm"
17349        );
17350    }
17351
17352    #[test]
17353    fn direct_translation_accepts_nearly_identity_axis_scale_noise() {
17354        let local_bounds = Rect {
17355            x: 0.0,
17356            y: 0.0,
17357            width: 393.3,
17358            height: 16.8,
17359        };
17360        let quad = [
17361            [10.0, 78.399_994],
17362            [403.3, 78.399_994],
17363            [10.0, 95.2],
17364            [403.3, 95.2],
17365        ];
17366        let transform = ProjectiveTransform::from_rect_to_quad(local_bounds, quad);
17367
17368        assert_eq!(
17369            direct_translation(transform),
17370            Some(Point::new(10.0, 78.399_994)),
17371        );
17372    }
17373
17374    #[test]
17375    fn layer_surface_requirements_keep_shape_plus_isolating_child_as_mixed_content() {
17376        let mut child = test_layer(
17377            Rect {
17378                x: 0.0,
17379                y: 0.0,
17380                width: 24.0,
17381                height: 18.0,
17382            },
17383            vec![RenderNode::Primitive(PrimitiveEntry {
17384                phase: PrimitivePhase::BeforeChildren,
17385                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17386                    primitive: DrawPrimitive::Rect {
17387                        rect: Rect {
17388                            x: 0.0,
17389                            y: 0.0,
17390                            width: 24.0,
17391                            height: 18.0,
17392                        },
17393                        brush: Brush::solid(Color::WHITE),
17394                        stroke: None,
17395                    },
17396                    clip: None,
17397                }),
17398            })],
17399        );
17400        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
17401        child.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
17402
17403        let layer = test_layer(
17404            Rect {
17405                x: 0.0,
17406                y: 0.0,
17407                width: 64.0,
17408                height: 32.0,
17409            },
17410            vec![
17411                RenderNode::Primitive(PrimitiveEntry {
17412                    phase: PrimitivePhase::BeforeChildren,
17413                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
17414                        primitive: DrawPrimitive::Rect {
17415                            rect: Rect {
17416                                x: 0.0,
17417                                y: 0.0,
17418                                width: 64.0,
17419                                height: 32.0,
17420                            },
17421                            brush: Brush::solid(Color::BLACK),
17422                            stroke: None,
17423                        },
17424                        clip: None,
17425                    }),
17426                }),
17427                RenderNode::Layer(Box::new(child)),
17428            ],
17429        );
17430
17431        let requirements = layer_surface_requirements(&layer);
17432
17433        assert!(requirements
17434            .surface_requirements
17435            .contains(SurfaceRequirement::MixedDirectContent));
17436        assert!(!requirements
17437            .surface_requirements
17438            .has_isolating_requirement());
17439    }
17440
17441    #[test]
17442    fn build_scene_window_filters_and_translates_items() {
17443        let mut shape = test_shape(6, BlendMode::SrcOver);
17444        shape.rect.x = 12.0;
17445        shape.rect.y = 25.0;
17446        shape.local_rect.x = 12.0;
17447        shape.local_rect.y = 25.0;
17448        shape.quad = [[12.0, 25.0], [20.0, 25.0], [12.0, 33.0], [20.0, 33.0]];
17449        shape.clip = Some(Rect {
17450            x: 11.0,
17451            y: 24.0,
17452            width: 10.0,
17453            height: 10.0,
17454        });
17455
17456        let mut image = test_image(8, BlendMode::SrcOver);
17457        image.rect.x = 18.0;
17458        image.rect.y = 27.0;
17459        image.local_rect.x = 18.0;
17460        image.local_rect.y = 27.0;
17461        image.quad = [[18.0, 27.0], [26.0, 27.0], [18.0, 35.0], [26.0, 35.0]];
17462
17463        let mut text = test_text(9);
17464        text.rect.x = 16.0;
17465        text.rect.y = 29.0;
17466        text.clip = Some(Rect {
17467            x: 15.0,
17468            y: 28.0,
17469            width: 9.0,
17470            height: 6.0,
17471        });
17472
17473        let mut shadow_shape = test_shape(7, BlendMode::SrcOver);
17474        shadow_shape.rect.x = 14.0;
17475        shadow_shape.rect.y = 26.0;
17476        shadow_shape.local_rect.x = 14.0;
17477        shadow_shape.local_rect.y = 26.0;
17478        shadow_shape.quad = [[14.0, 26.0], [22.0, 26.0], [14.0, 34.0], [22.0, 34.0]];
17479        let mut shadow = test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]);
17480        shadow.z_index = 7;
17481
17482        let mut nested_effect = effect_layer(6, 10);
17483        nested_effect.rect.x = 13.0;
17484        nested_effect.rect.y = 24.0;
17485        nested_effect.clip = Some(Rect {
17486            x: 15.0,
17487            y: 25.0,
17488            width: 4.0,
17489            height: 5.0,
17490        });
17491
17492        let mut nested_backdrop = backdrop_layer(8);
17493        nested_backdrop.rect.x = 17.0;
17494        nested_backdrop.rect.y = 26.0;
17495        nested_backdrop.clip = Some(Rect {
17496            x: 18.0,
17497            y: 27.0,
17498            width: 3.0,
17499            height: 4.0,
17500        });
17501
17502        let window = build_scene_window(
17503            SceneWindowSource {
17504                shapes: &[test_shape(4, BlendMode::SrcOver), shape],
17505                images: &[image],
17506                texts: &[text],
17507                shadow_draws: &[shadow],
17508                draw_ops: &[],
17509                effect_layers: &[effect_layer(2, 4), nested_effect.clone()],
17510                backdrop_layers: &[backdrop_layer(4), nested_backdrop.clone()],
17511            },
17512            5,
17513            10,
17514            Rect {
17515                x: 10.0,
17516                y: 20.0,
17517                width: 20.0,
17518                height: 20.0,
17519            },
17520        );
17521
17522        assert_eq!(window.shapes.len(), 1);
17523        assert_eq!(
17524            window.shapes[0].rect,
17525            Rect {
17526                x: 2.0,
17527                y: 5.0,
17528                width: 8.0,
17529                height: 8.0,
17530            }
17531        );
17532        assert_eq!(
17533            window.shapes[0].clip,
17534            Some(Rect {
17535                x: 1.0,
17536                y: 4.0,
17537                width: 10.0,
17538                height: 10.0,
17539            })
17540        );
17541        assert_eq!(window.images.len(), 1);
17542        assert_eq!(window.images[0].rect.x, 8.0);
17543        assert_eq!(window.images[0].rect.y, 7.0);
17544        assert_eq!(window.texts.len(), 1);
17545        assert_eq!(window.texts[0].rect.x, 6.0);
17546        assert_eq!(window.texts[0].rect.y, 9.0);
17547        assert_eq!(
17548            window.texts[0].clip,
17549            Some(Rect {
17550                x: 5.0,
17551                y: 8.0,
17552                width: 9.0,
17553                height: 6.0,
17554            })
17555        );
17556        assert_eq!(window.shadow_draws.len(), 1);
17557        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.x, 4.0);
17558        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.y, 6.0);
17559        assert_eq!(window.effect_layers.len(), 1);
17560        assert_eq!(
17561            window.effect_layers[0].rect,
17562            Rect {
17563                x: 3.0,
17564                y: 4.0,
17565                width: 10.0,
17566                height: 10.0,
17567            }
17568        );
17569        assert_eq!(
17570            window.effect_layers[0].clip,
17571            Some(Rect {
17572                x: 5.0,
17573                y: 5.0,
17574                width: 4.0,
17575                height: 5.0,
17576            })
17577        );
17578        assert_eq!(window.backdrop_layers.len(), 1);
17579        assert_eq!(
17580            window.backdrop_layers[0].rect,
17581            Rect {
17582                x: 7.0,
17583                y: 6.0,
17584                width: 10.0,
17585                height: 10.0,
17586            }
17587        );
17588        assert_eq!(
17589            window.backdrop_layers[0].clip,
17590            Some(Rect {
17591                x: 8.0,
17592                y: 7.0,
17593                width: 3.0,
17594                height: 4.0,
17595            })
17596        );
17597    }
17598
17599    #[test]
17600    fn filtered_effect_layer_index_counts_only_window_members() {
17601        let effects = vec![
17602            effect_layer(0, 2),
17603            effect_layer(5, 12),
17604            effect_layer(6, 10),
17605            effect_layer(14, 20),
17606        ];
17607
17608        assert_eq!(filtered_effect_layer_index(&effects, 1, 5, 12), Some(0));
17609        assert_eq!(filtered_effect_layer_index(&effects, 2, 5, 12), Some(1));
17610        assert_eq!(filtered_effect_layer_index(&effects, 3, 5, 12), None);
17611    }
17612
17613    #[test]
17614    fn blend_mode_support_matrix_is_explicit() {
17615        assert!(is_blend_mode_supported(BlendMode::SrcOver));
17616        assert!(is_blend_mode_supported(BlendMode::DstOut));
17617        assert!(!is_blend_mode_supported(BlendMode::Clear));
17618        assert!(!is_blend_mode_supported(BlendMode::Multiply));
17619    }
17620
17621    #[test]
17622    fn collect_non_effect_segment_items_preserves_global_z_order() {
17623        let shapes = vec![
17624            test_shape(3, BlendMode::SrcOver),
17625            test_shape(1, BlendMode::DstOut),
17626        ];
17627        let images = vec![test_image(2, BlendMode::SrcOver)];
17628        let texts = vec![test_text(0)];
17629        let shadows: Vec<ShadowDraw> = Vec::new();
17630        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
17631
17632        let mut scratch = Vec::new();
17633        collect_non_effect_segment_items(
17634            &shapes,
17635            &images,
17636            &texts,
17637            &shadows,
17638            &draw_ops,
17639            0,
17640            4,
17641            &[],
17642            100,
17643            100,
17644            1.0,
17645            &mut scratch,
17646        );
17647        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
17648        assert_eq!(
17649            items,
17650            vec![
17651                SegmentDrawItem::Text(0),
17652                SegmentDrawItem::Shape(1),
17653                SegmentDrawItem::Image(0),
17654                SegmentDrawItem::Shape(0),
17655            ]
17656        );
17657    }
17658
17659    #[test]
17660    fn collect_non_effect_segment_items_filters_effect_ranges() {
17661        let shapes = vec![
17662            test_shape(1, BlendMode::SrcOver),
17663            test_shape(3, BlendMode::DstOut),
17664        ];
17665        let images = vec![test_image(2, BlendMode::SrcOver)];
17666        let texts = vec![test_text(4)];
17667        let shadows: Vec<ShadowDraw> = Vec::new();
17668        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
17669        let effect_ranges = [std::ops::Range { start: 2, end: 4 }];
17670
17671        let mut scratch = Vec::new();
17672        collect_non_effect_segment_items(
17673            &shapes,
17674            &images,
17675            &texts,
17676            &shadows,
17677            &draw_ops,
17678            0,
17679            5,
17680            &effect_ranges,
17681            100,
17682            100,
17683            1.0,
17684            &mut scratch,
17685        );
17686        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
17687        assert_eq!(
17688            items,
17689            vec![SegmentDrawItem::Shape(0), SegmentDrawItem::Text(0)]
17690        );
17691    }
17692
17693    #[test]
17694    fn collect_non_effect_segment_items_culls_offscreen_shapes_but_keeps_text_prewarm() {
17695        let mut shape = test_shape(0, BlendMode::SrcOver);
17696        shape.rect.y = 160.0;
17697        shape.local_rect.y = 160.0;
17698        shape.quad = [[0.0, 160.0], [8.0, 160.0], [0.0, 168.0], [8.0, 168.0]];
17699
17700        let shapes = vec![shape];
17701        let images = Vec::new();
17702        let mut text = test_text(1);
17703        text.rect.y = 160.0;
17704        let texts = vec![text];
17705        let shadows: Vec<ShadowDraw> = Vec::new();
17706        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
17707
17708        let mut scratch = Vec::new();
17709        collect_non_effect_segment_items(
17710            &shapes,
17711            &images,
17712            &texts,
17713            &shadows,
17714            &draw_ops,
17715            0,
17716            2,
17717            &[],
17718            100,
17719            100,
17720            1.0,
17721            &mut scratch,
17722        );
17723
17724        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
17725        assert_eq!(items, vec![SegmentDrawItem::Text(0)]);
17726    }
17727
17728    #[test]
17729    fn segment_command_iter_merges_non_conflicting_batches_into_one_chunk() {
17730        let ordered_items = vec![
17731            (0, SegmentDrawItem::Shape(0)),
17732            (1, SegmentDrawItem::Image(0)),
17733            (2, SegmentDrawItem::Text(0)),
17734        ];
17735        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
17736        let images = vec![test_image(1, BlendMode::DstOut)];
17737
17738        let commands: Vec<_> = SegmentCommandIter::new(
17739            &ordered_items,
17740            &shapes,
17741            &images,
17742            ShapeBatchLimits::desktop(),
17743        )
17744        .collect();
17745
17746        assert_eq!(
17747            commands,
17748            vec![SegmentRenderCommand::DrawChunk(chunk(&[
17749                SegmentBatchPlan::Shape {
17750                    start: 0,
17751                    end: 1,
17752                    blend_mode: BlendMode::SrcOver,
17753                },
17754                SegmentBatchPlan::Image {
17755                    start: 1,
17756                    end: 2,
17757                    blend_mode: BlendMode::DstOut,
17758                },
17759                SegmentBatchPlan::Text { start: 2, end: 3 },
17760            ]))]
17761        );
17762    }
17763
17764    #[test]
17765    fn segment_command_iter_keeps_layer_composites_in_ordered_draw_chunk() {
17766        let ordered_items = vec![
17767            (0, SegmentDrawItem::Shape(0)),
17768            (1, SegmentDrawItem::Composite(0)),
17769            (2, SegmentDrawItem::Image(0)),
17770            (3, SegmentDrawItem::Composite(1)),
17771            (4, SegmentDrawItem::Text(0)),
17772        ];
17773        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
17774        let images = vec![test_image(2, BlendMode::SrcOver)];
17775
17776        let commands: Vec<_> = SegmentCommandIter::new(
17777            &ordered_items,
17778            &shapes,
17779            &images,
17780            ShapeBatchLimits::desktop(),
17781        )
17782        .collect();
17783
17784        assert_eq!(
17785            commands,
17786            vec![SegmentRenderCommand::DrawChunk(chunk(&[
17787                SegmentBatchPlan::Shape {
17788                    start: 0,
17789                    end: 1,
17790                    blend_mode: BlendMode::SrcOver,
17791                },
17792                SegmentBatchPlan::Composite { start: 1, end: 2 },
17793                SegmentBatchPlan::Image {
17794                    start: 2,
17795                    end: 3,
17796                    blend_mode: BlendMode::SrcOver,
17797                },
17798                SegmentBatchPlan::Composite { start: 3, end: 4 },
17799                SegmentBatchPlan::Text { start: 4, end: 5 },
17800            ]))]
17801        );
17802    }
17803
17804    #[test]
17805    fn retain_renderable_shadow_items_culls_invisible_shadow_boundaries() {
17806        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
17807        let images = vec![test_image(2, BlendMode::SrcOver)];
17808        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
17809        shadow_shape.rect = Rect {
17810            x: 500.0,
17811            y: 500.0,
17812            width: 12.0,
17813            height: 12.0,
17814        };
17815        let shadow_draws = vec![ShadowDraw {
17816            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
17817            texts: Vec::new(),
17818            blur_radius: 8.0,
17819            clip: None,
17820            z_index: 1,
17821        }];
17822        let mut ordered_items = vec![
17823            (0, SegmentDrawItem::Shape(0)),
17824            (1, SegmentDrawItem::Shadow(0)),
17825            (2, SegmentDrawItem::Image(0)),
17826        ];
17827
17828        let culled =
17829            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
17830        let commands: Vec<_> = SegmentCommandIter::new(
17831            &ordered_items,
17832            &shapes,
17833            &images,
17834            ShapeBatchLimits::desktop(),
17835        )
17836        .collect();
17837
17838        assert_eq!(culled, 1);
17839        assert_eq!(
17840            commands,
17841            vec![SegmentRenderCommand::DrawChunk(chunk(&[
17842                SegmentBatchPlan::Shape {
17843                    start: 0,
17844                    end: 1,
17845                    blend_mode: BlendMode::SrcOver,
17846                },
17847                SegmentBatchPlan::Image {
17848                    start: 1,
17849                    end: 2,
17850                    blend_mode: BlendMode::SrcOver,
17851                },
17852            ]))]
17853        );
17854    }
17855
17856    #[test]
17857    fn retain_renderable_shadow_items_keeps_visible_shadow_boundaries() {
17858        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
17859        shadow_shape.rect = Rect {
17860            x: 20.0,
17861            y: 20.0,
17862            width: 12.0,
17863            height: 12.0,
17864        };
17865        let shadow_draws = vec![ShadowDraw {
17866            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
17867            texts: Vec::new(),
17868            blur_radius: 8.0,
17869            clip: None,
17870            z_index: 1,
17871        }];
17872        let mut ordered_items = vec![(1, SegmentDrawItem::Shadow(0))];
17873
17874        let culled =
17875            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
17876
17877        assert_eq!(culled, 0);
17878        assert_eq!(ordered_items, vec![(1, SegmentDrawItem::Shadow(0))]);
17879    }
17880
17881    #[test]
17882    fn shape_data_layout_matches_the_wgsl_mirror() {
17883        // 10 x vec4-sized slots. The uniform address space requires a 16-byte
17884        // multiple, and `shape.wgsl`'s array length literal is derived from
17885        // this size — if it drifts, batches silently overrun the binding.
17886        assert_eq!(std::mem::size_of::<ShapeData>(), 160);
17887        assert_eq!(std::mem::size_of::<ShapeData>() % 16, 0);
17888        assert_eq!(std::mem::size_of::<GradientStop>(), 32);
17889    }
17890
17891    #[test]
17892    fn shape_flags_pack_kind_cap_and_join_without_collision() {
17893        assert_eq!(
17894            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
17895            0.0
17896        );
17897        assert_eq!(
17898            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Miter),
17899            1.0
17900        );
17901        assert_eq!(
17902            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Butt, StrokeJoin::Miter),
17903            2.0
17904        );
17905        // cap in bits 2-3, join in bits 4-5
17906        assert_eq!(
17907            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Round, StrokeJoin::Miter),
17908            2.0 + 4.0
17909        );
17910        assert_eq!(
17911            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Square, StrokeJoin::Miter),
17912            2.0 + 8.0
17913        );
17914        assert_eq!(
17915            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Round),
17916            1.0 + 16.0
17917        );
17918        assert_eq!(
17919            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Bevel),
17920            1.0 + 32.0
17921        );
17922        // Every combination must round-trip through f32 exactly.
17923        for kind in [SHAPE_KIND_FILL, SHAPE_KIND_STROKE, SHAPE_KIND_ARC] {
17924            for cap in [StrokeCap::Butt, StrokeCap::Round, StrokeCap::Square] {
17925                for join in [StrokeJoin::Miter, StrokeJoin::Round, StrokeJoin::Bevel] {
17926                    let packed = pack_shape_flags(kind, cap, join);
17927                    let bits = packed as u32;
17928                    assert_eq!(bits & 3, kind);
17929                    assert_eq!((bits >> 2) & 3, stroke_cap_code(cap));
17930                    assert_eq!((bits >> 4) & 3, stroke_join_code(join));
17931                    assert_eq!(packed, bits as f32, "flags must be exact in f32");
17932                }
17933            }
17934        }
17935    }
17936
17937    #[cfg(not(target_arch = "wasm32"))]
17938    #[test]
17939    fn mesh_vertex_layout_matches_the_wgsl_input() {
17940        // {pos: vec2<f32>, uv: vec2<f32>, shape_idx: u32} = 20 bytes, no
17941        // padding — the vertex buffer layout stride relies on it.
17942        assert_eq!(std::mem::size_of::<MeshVertex>(), 20);
17943    }
17944
17945    /// f32 port of `sdf_arc_band` (shape.wgsl), operation for operation: the
17946    /// same ra/rb derivation and clamp, the same mirror trick (`abs` on the
17947    /// rotated x), the same cap branches.
17948    #[cfg(not(target_arch = "wasm32"))]
17949    #[allow(clippy::too_many_arguments)]
17950    fn sdf_arc_band_reference(
17951        p: [f32; 2],
17952        center: [f32; 2],
17953        inner: f32,
17954        outer: f32,
17955        mid_sin_cos: [f32; 2],
17956        half_sin_cos: [f32; 2],
17957        cap: u32,
17958    ) -> f32 {
17959        let ra = (outer + inner) * 0.5;
17960        let rb = ((outer - inner) * 0.5).max(0.0);
17961        let sm = mid_sin_cos[0];
17962        let cm = mid_sin_cos[1];
17963        let d = [p[0] - center[0], p[1] - center[1]];
17964        let mut q = [-sm * d[0] + cm * d[1], cm * d[0] + sm * d[1]];
17965        q[0] = q[0].abs();
17966        let sc = half_sin_cos;
17967        let mut dist = if sc[1] * q[0] > sc[0] * q[1] {
17968            let dx = q[0] - sc[0] * ra;
17969            let dy = q[1] - sc[1] * ra;
17970            (dx * dx + dy * dy).sqrt() - rb
17971        } else {
17972            ((q[0] * q[0] + q[1] * q[1]).sqrt() - ra).abs() - rb
17973        };
17974        let plane = sc[1] * q[0] - sc[0] * q[1];
17975        // STROKE_CAP_BUTT = 0, STROKE_CAP_SQUARE = 2, as in the shader.
17976        if cap == 0 {
17977            dist = dist.max(plane);
17978        } else if cap == 2 {
17979            dist = dist.max(plane - rb);
17980        }
17981        dist
17982    }
17983
17984    #[cfg(not(target_arch = "wasm32"))]
17985    fn point_in_triangle(p: [f64; 2], tri: &[[f64; 2]; 3]) -> bool {
17986        let side = |a: [f64; 2], b: [f64; 2]| {
17987            (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
17988        };
17989        let d0 = side(tri[0], tri[1]);
17990        let d1 = side(tri[1], tri[2]);
17991        let d2 = side(tri[2], tri[0]);
17992        let has_neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0;
17993        let has_pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0;
17994        !(has_neg && has_pos)
17995    }
17996
17997    #[cfg(not(target_arch = "wasm32"))]
17998    fn converted_arc_shape(arc: cranpose_ui_graphics::ArcGeometry, root_scale: f32) -> ShapeData {
17999        let bounds = arc.bounds();
18000        let mut shape = test_shape(0, BlendMode::SrcOver);
18001        shape.rect = bounds;
18002        shape.local_rect = bounds;
18003        shape.quad = [
18004            [bounds.x, bounds.y],
18005            [bounds.x + bounds.width, bounds.y],
18006            [bounds.x, bounds.y + bounds.height],
18007            [bounds.x + bounds.width, bounds.y + bounds.height],
18008        ];
18009        shape.arc = Some(arc);
18010        let mut converted = ShapeData::zeroed();
18011        convert_shape_into_slots(&shape, root_scale, 0, &mut converted, &mut []);
18012        converted
18013    }
18014
18015    /// The containment invariant, checked directly: every point of the
18016    /// capture box whose (exactly ported) SDF keeps it must lie inside the
18017    /// emitted triangle set. Thin/thick, tiny/huge, full rings, near-zero
18018    /// and near-TAU sweeps, all caps, `Ri == 0` discs and pie wedges.
18019    #[cfg(not(target_arch = "wasm32"))]
18020    #[test]
18021    fn arc_mesh_contains_every_band_pixel() {
18022        use cranpose_ui_graphics::ArcGeometry;
18023        let tau = cranpose_ui_graphics::TAU;
18024        let center = Point::new(250.0, 250.0);
18025        let cases: &[(f32, f32, f32, f32, StrokeCap)] = &[
18026            // full ring, thin band
18027            (90.0, 100.0, 0.0, tau, StrokeCap::Round),
18028            // sweep > TAU normalizes to a closed ring
18029            (80.0, 100.0, 1.0, 10.0, StrokeCap::Butt),
18030            // full disc: Ri == 0
18031            (0.0, 40.0, 0.0, tau, StrokeCap::Round),
18032            // thick partial arc, every cap
18033            (30.0, 80.0, 0.7, 2.5, StrokeCap::Butt),
18034            (30.0, 80.0, 0.7, 2.5, StrokeCap::Round),
18035            (30.0, 80.0, 0.7, 2.5, StrokeCap::Square),
18036            // thin, axis-crossing sweep
18037            (99.0, 101.0, 3.0, 4.0, StrokeCap::Round),
18038            // tiny
18039            (0.6, 2.0, 0.3, 1.2, StrokeCap::Butt),
18040            // huge radius, thin band
18041            (1900.0, 1904.0, 0.1, 0.35, StrokeCap::Square),
18042            // near-zero sweep
18043            (40.0, 60.0, 5.0, 1e-3, StrokeCap::Round),
18044            // sweep near TAU: the cap pads wrap the range closed
18045            (40.0, 60.0, 0.2, tau - 1e-3, StrokeCap::Butt),
18046            // rb_m >= ra: the cap disc wraps the center (pie wedge)
18047            (0.0, 3.0, 1.0, 2.0, StrokeCap::Round),
18048            // filled annular sector (butt radial ends)
18049            (20.0, 60.0, 4.5, 1.9, StrokeCap::Butt),
18050        ];
18051        for (case, &(inner, outer, start, sweep, cap)) in cases.iter().enumerate() {
18052            // 2.75 is deliberately non-dyadic: quad corners and rect then
18053            // disagree by an ulp, which the axis-aligned gate must tolerate
18054            // (an equality-with-rect gate silently failed every arc on the
18055            // Huawei at scale 2.75).
18056            for root_scale in [1.0f32, 2.0, 2.75] {
18057                let arc = ArcGeometry::new(center, inner, outer, start, sweep, cap);
18058                assert!(!arc.is_degenerate(), "case {case} must be drawable");
18059                let converted = converted_arc_shape(arc, root_scale);
18060                let band = arc_mesh_band(&converted)
18061                    .unwrap_or_else(|| panic!("case {case} must qualify for meshing"));
18062                let mut vertices = Vec::new();
18063                let mut indices = Vec::new();
18064                let segments =
18065                    emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
18066                        .unwrap_or_else(|| panic!("case {case} must produce a mesh"));
18067                assert!(segments >= ARC_MESH_MIN_SEGMENTS);
18068                // The rasterized set is the indexed walk: triangles are index
18069                // triples into the shared vertex list.
18070                let position = |index: u32| {
18071                    let p = vertices[index as usize].position;
18072                    [p[0] as f64, p[1] as f64]
18073                };
18074                let triangles: Vec<[[f64; 2]; 3]> = indices
18075                    .chunks_exact(3)
18076                    .map(|tri| [position(tri[0]), position(tri[1]), position(tri[2])])
18077                    .collect();
18078
18079                // Sample the QUAD box, not `rect`: quad expansion rasterizes the
18080                // quad, the mesh clips to the quad, and at non-dyadic root
18081                // scales the two boxes differ by an ulp.
18082                let [qx, qy, ..] = converted.quad01;
18083                let [_, _, qr, qb] = converted.quad23;
18084                let (rw, rh) = (qr - qx, qb - qy);
18085                let cap_bits = (converted.stroke_params[1].max(0.0) as u32 >> 2) & 3;
18086                let step = (rw.max(rh) / 400.0).clamp(0.25, 2.0);
18087                let mut band_points = 0usize;
18088                let mut y = qy;
18089                while y <= qb {
18090                    let mut x = qx;
18091                    while x <= qr {
18092                        let dist = sdf_arc_band_reference(
18093                            [x, y],
18094                            [converted.arc_params[0], converted.arc_params[1]],
18095                            converted.stroke_params[3],
18096                            converted.stroke_params[2],
18097                            [converted.radii[0], converted.radii[1]],
18098                            [converted.radii[2], converted.radii[3]],
18099                            cap_bits,
18100                        );
18101                        if dist <= 0.5 {
18102                            band_points += 1;
18103                            let p = [x as f64, y as f64];
18104                            assert!(
18105                                triangles.iter().any(|tri| point_in_triangle(p, tri)),
18106                                "case {case} scale {root_scale}: band point ({x}, {y}) \
18107                                 dist {dist} escapes the mesh"
18108                            );
18109                        }
18110                        x += step;
18111                    }
18112                    y += step;
18113                }
18114                assert!(
18115                    band_points > 0,
18116                    "case {case} scale {root_scale}: the sampling grid never hit the band"
18117                );
18118            }
18119        }
18120    }
18121
18122    #[cfg(not(target_arch = "wasm32"))]
18123    #[test]
18124    fn arc_mesh_passthrough_replicates_the_quad_expansion() {
18125        let shape = test_shape(0, BlendMode::SrcOver);
18126        let mut converted = ShapeData::zeroed();
18127        convert_shape_into_slots(&shape, 1.0, 0, &mut converted, &mut []);
18128        let build =
18129            build_arc_mesh_vertices(std::slice::from_ref(&converted)).expect("within budget");
18130        assert_eq!(build.meshed_arcs, 0);
18131        assert_eq!(build.passthrough, 1);
18132        // Four shared corner vertices, six indices — amplification-free.
18133        assert_eq!(build.vertices.len(), 4);
18134        assert_eq!(build.index_prefix, vec![0, 6]);
18135        assert_eq!(build.indices, vec![0, 1, 2, 2, 1, 3]);
18136        let corners = [
18137            ([converted.quad01[0], converted.quad01[1]], [0.0f32, 0.0]),
18138            ([converted.quad01[2], converted.quad01[3]], [1.0, 0.0]),
18139            ([converted.quad23[0], converted.quad23[1]], [0.0, 1.0]),
18140            ([converted.quad23[2], converted.quad23[3]], [1.0, 1.0]),
18141        ];
18142        for (vertex, corner) in build.vertices.iter().zip(corners) {
18143            assert_eq!(vertex.position, corner.0);
18144            assert_eq!(vertex.uv, corner.1);
18145            assert_eq!(vertex.shape_idx, 0);
18146        }
18147        // The indexed walk expands to vs_main's slot order: triangles
18148        // (0, 1, 2) and (2, 1, 3).
18149        for (index, corner) in build.indices.iter().zip([0usize, 1, 2, 2, 1, 3]) {
18150            assert_eq!(build.vertices[*index as usize].position, corners[corner].0);
18151            assert_eq!(build.vertices[*index as usize].uv, corners[corner].1);
18152        }
18153    }
18154
18155    /// The indexed-topology contract for arcs whose trapezoids survive
18156    /// clipping whole: every band boundary contributes exactly one (inner,
18157    /// outer) vertex pair, both adjacent trapezoids reference it through the
18158    /// index list, and a closed ring's last segment wraps around to boundary
18159    /// zero's pair — one seam vertex pair instead of bitwise-equal copies.
18160    #[cfg(not(target_arch = "wasm32"))]
18161    #[test]
18162    fn arc_mesh_indices_share_boundary_vertices_and_wrap_closed_rings() {
18163        use cranpose_ui_graphics::ArcGeometry;
18164        let tau = cranpose_ui_graphics::TAU;
18165        // (sweep, expected boundary count relation): a closed ring wraps
18166        // (boundaries == segments), an open arc does not (segments + 1).
18167        for (sweep, closed) in [(tau, true), (1.9f32, false)] {
18168            let arc = ArcGeometry::new(
18169                Point::new(250.0, 250.0),
18170                80.0,
18171                100.0,
18172                0.7,
18173                sweep,
18174                StrokeCap::Round,
18175            );
18176            let mut converted = converted_arc_shape(arc, 1.0);
18177            // Inflate the quad box (and rect, for uv) far beyond the dilated
18178            // band so NO trapezoid is clipped: every segment must take the
18179            // shared-boundary path.
18180            converted.rect = [0.0, 0.0, 500.0, 500.0];
18181            converted.quad01 = [0.0, 0.0, 500.0, 0.0];
18182            converted.quad23 = [0.0, 500.0, 500.0, 500.0];
18183            let band = arc_mesh_band(&converted).expect("arc must qualify");
18184            let mut vertices = Vec::new();
18185            let mut indices = Vec::new();
18186            let segments = emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
18187                .expect("arc must mesh");
18188            let boundary_count = if closed { segments } else { segments + 1 };
18189            assert_eq!(
18190                vertices.len(),
18191                2 * boundary_count,
18192                "closed={closed}: every boundary owns exactly one (inner, outer) pair"
18193            );
18194            assert_eq!(indices.len(), 6 * segments);
18195            // Emission order is boundary order: boundary j's pair is
18196            // (2j, 2j + 1). Each segment must reference its own boundary and
18197            // its successor's — modulo the count exactly when closed.
18198            for j in 0..segments {
18199                let jb = (j + 1) % boundary_count;
18200                let (in_a, out_a) = (2 * j as u32, 2 * j as u32 + 1);
18201                let (in_b, out_b) = (2 * jb as u32, 2 * jb as u32 + 1);
18202                assert_eq!(
18203                    indices[6 * j..6 * j + 6],
18204                    [in_a, out_a, out_b, in_a, out_b, in_b],
18205                    "closed={closed}: segment {j} must share its boundary pairs"
18206                );
18207            }
18208            if closed {
18209                // The wrap made concrete: the final segment indexes boundary
18210                // zero's vertices.
18211                assert_eq!(indices[6 * segments - 1], 0);
18212            }
18213            // Inner vertices ride the dilated inner radius, outer vertices
18214            // the pushed-out chord radius — sanity that pairs are ordered
18215            // (inner, outer).
18216            for pair in vertices.chunks_exact(2) {
18217                let radius = |v: &MeshVertex| {
18218                    let dx = v.position[0] - 250.0;
18219                    let dy = v.position[1] - 250.0;
18220                    (dx * dx + dy * dy).sqrt()
18221                };
18222                assert!(radius(&pair[0]) < radius(&pair[1]));
18223            }
18224        }
18225    }
18226
18227    /// The private-vertex arm of the indexed topology: under the real
18228    /// tight-AABB quad the pushed-out chord vertices near the box edges get
18229    /// clipped, and those trapezoids must fan over vertices of their own —
18230    /// appended after the shared block, carrying clip-plane coordinates —
18231    /// while untouched diagonal trapezoids still share boundary pairs.
18232    #[cfg(not(target_arch = "wasm32"))]
18233    #[test]
18234    fn arc_mesh_clipped_segments_fan_over_private_vertices() {
18235        use cranpose_ui_graphics::ArcGeometry;
18236        let arc = ArcGeometry::new(
18237            Point::new(250.0, 250.0),
18238            80.0,
18239            100.0,
18240            0.0,
18241            cranpose_ui_graphics::TAU,
18242            StrokeCap::Round,
18243        );
18244        let converted = converted_arc_shape(arc, 1.0);
18245        let band = arc_mesh_band(&converted).expect("ring must qualify");
18246        let mut vertices = Vec::new();
18247        let mut indices = Vec::new();
18248        emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
18249            .expect("ring must mesh");
18250        // Sharing must actually happen: a shared boundary vertex is used by
18251        // both of its trapezoids' fans (at least three triangle references).
18252        let mut uses = vec![0usize; vertices.len()];
18253        for &index in &indices {
18254            uses[index as usize] += 1;
18255        }
18256        assert!(
18257            uses.iter().any(|&count| count >= 3),
18258            "some boundary vertices must be shared across trapezoids"
18259        );
18260        // Clipping must actually happen, and clipped polygons index private
18261        // vertices lying bitwise ON the quad box (the clipper writes the
18262        // bound coordinate exactly; boundary vertices never touch the box —
18263        // inner ones sit strictly inside, pushed-out outer ones strictly
18264        // outside near the extremes, where they are clipped).
18265        let [left, top, ..] = converted.quad01;
18266        let [.., right, bottom] = converted.quad23;
18267        let clipped: Vec<&MeshVertex> = vertices
18268            .iter()
18269            .filter(|vertex| {
18270                let [x, y] = vertex.position;
18271                x == left || x == right || y == top || y == bottom
18272            })
18273            .collect();
18274        assert!(
18275            !clipped.is_empty(),
18276            "the tight box must clip the pushed-out chord vertices"
18277        );
18278        // Fewer unique vertices than the non-indexed emitter's
18279        // three-per-triangle — the amplification this change removes.
18280        assert!(
18281            vertices.len() < indices.len(),
18282            "{} unique vertices should undercut {} triangle corners",
18283            vertices.len(),
18284            indices.len()
18285        );
18286    }
18287
18288    #[cfg(not(target_arch = "wasm32"))]
18289    #[test]
18290    fn arc_mesh_budget_overflow_falls_back_to_whole_slot_passthrough() {
18291        use cranpose_ui_graphics::ArcGeometry;
18292        // 100 large full rings mesh at the 64-segment ceiling (well over
18293        // 4 KB of vertices + indices each), far past the byte budget
18294        // max(100 * ~960 B, ~80 KB) — the builder must refuse the whole
18295        // slot rather than truncate.
18296        let arc = ArcGeometry::new(
18297            Point::new(2000.0, 2000.0),
18298            1690.0,
18299            1710.0,
18300            0.0,
18301            cranpose_ui_graphics::TAU,
18302            StrokeCap::Round,
18303        );
18304        let converted = converted_arc_shape(arc, 1.0);
18305        let shapes = vec![converted; 100];
18306        assert!(build_arc_mesh_vertices(&shapes).is_none());
18307    }
18308
18309    #[cfg(not(target_arch = "wasm32"))]
18310    #[test]
18311    fn shape_batch_limits_follow_uniform_binding_size() {
18312        // With a 160-byte ShapeData, even a desktop-class 64 KiB binding can no
18313        // longer hold the full compile-time cap: 65536 / 160 = 409 < 768.
18314        let desktop_shapes = 65536 / std::mem::size_of::<ShapeData>();
18315        assert_eq!(desktop_shapes, 409);
18316        assert_eq!(
18317            ShapeBatchLimits::desktop(),
18318            ShapeBatchLimits {
18319                max_shapes_per_batch: desktop_shapes.min(MAX_SHAPES_PER_BATCH),
18320                max_gradient_stops: MAX_GRADIENT_STOPS,
18321                storage: false,
18322            }
18323        );
18324
18325        // The 16 KiB downlevel/GLES minimum must shrink batches to fit:
18326        // 16384 / 160-byte ShapeData = 102 shapes, 16384 / 32-byte stop = 512.
18327        let downlevel = ShapeBatchLimits::for_uniform_binding_size(16384);
18328        assert_eq!(downlevel.max_shapes_per_batch, 16384 / 160);
18329        assert_eq!(downlevel.max_shapes_per_batch, 102);
18330        assert_eq!(downlevel.max_gradient_stops, 512.min(MAX_GRADIENT_STOPS));
18331        assert!(downlevel.max_shapes_per_batch * std::mem::size_of::<ShapeData>() <= 16384);
18332        assert!(downlevel.max_gradient_stops * std::mem::size_of::<GradientStop>() <= 16384);
18333
18334        // Degenerate limits must not produce zero-sized buffers.
18335        let tiny = ShapeBatchLimits::for_uniform_binding_size(1);
18336        assert_eq!(tiny.max_shapes_per_batch, 1);
18337        assert_eq!(tiny.max_gradient_stops, 1);
18338    }
18339
18340    #[test]
18341    fn storage_shape_batch_limits_uncap_the_batch_and_start_small() {
18342        // A typical 128 MiB storage binding hits the compile-time ceilings,
18343        // not the device limit: one batch holds the whole scene.
18344        let storage = ShapeBatchLimits::for_storage_binding_size(128 << 20);
18345        assert!(storage.storage);
18346        assert_eq!(storage.max_shapes_per_batch, MAX_SHAPES_PER_STORAGE_BATCH);
18347        assert_eq!(
18348            storage.max_gradient_stops,
18349            MAX_GRADIENT_STOPS_PER_STORAGE_BATCH
18350        );
18351
18352        // The buffers must not be allocated at the multi-megabyte ceiling up
18353        // front; they start small and grow on demand.
18354        assert_eq!(
18355            storage.initial_shape_capacity(),
18356            INITIAL_STORAGE_BATCH_CAPACITY
18357        );
18358        assert_eq!(
18359            storage.initial_gradient_capacity(),
18360            INITIAL_STORAGE_BATCH_CAPACITY
18361        );
18362        assert_eq!(
18363            storage.data_binding_type(),
18364            wgpu::BufferBindingType::Storage { read_only: true }
18365        );
18366        assert!(storage
18367            .data_buffer_usage()
18368            .contains(wgpu::BufferUsages::STORAGE));
18369
18370        // Uniform mode keeps its start-at-the-cap invariant: a uniform
18371        // binding smaller than the shader's fixed array fails validation.
18372        let uniform = ShapeBatchLimits::desktop();
18373        assert_eq!(
18374            uniform.initial_shape_capacity(),
18375            uniform.max_shapes_per_batch
18376        );
18377        assert_eq!(
18378            uniform.initial_gradient_capacity(),
18379            uniform.max_gradient_stops
18380        );
18381        assert_eq!(
18382            uniform.data_binding_type(),
18383            wgpu::BufferBindingType::Uniform
18384        );
18385        assert!(uniform
18386            .data_buffer_usage()
18387            .contains(wgpu::BufferUsages::UNIFORM));
18388    }
18389
18390    #[test]
18391    fn storage_shape_shader_swaps_the_arrays_to_runtime_sized_storage() {
18392        let source = shape_shader_source(ShapeBatchLimits::for_storage_binding_size(128 << 20));
18393        assert!(
18394            source.contains("var<storage, read> shape_data: array<ShapeData>;"),
18395            "storage-mode shader must declare a runtime-sized shape array"
18396        );
18397        assert!(
18398            source.contains("var<storage, read> gradient_stops: array<GradientStop>;"),
18399            "storage-mode shader must declare a runtime-sized gradient array"
18400        );
18401        assert!(
18402            !source.contains("var<uniform> shape_data"),
18403            "the uniform shape declaration must be fully replaced"
18404        );
18405        assert!(
18406            !source.contains("var<uniform> gradient_stops"),
18407            "the uniform gradient declaration must be fully replaced"
18408        );
18409        assert!(
18410            source.contains("var<storage, read> paint: array<vec4<f32>>;"),
18411            "storage-mode shader must declare the retained paint array"
18412        );
18413        assert!(
18414            source.contains("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)"),
18415            "storage-mode shader must read paint under the paint_select flag"
18416        );
18417        assert!(
18418            source.contains("fn vs_mesh("),
18419            "the storage rewrite must leave the retained-mesh vertex entry intact"
18420        );
18421        assert!(
18422            source.contains("fn vs_shape_instanced("),
18423            "the storage rewrite must leave the instanced-quad vertex entry intact"
18424        );
18425        assert_eq!(
18426            source
18427                .matches("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)")
18428                .count(),
18429            3,
18430            "vs_main, vs_shape_instanced and vs_mesh must all read paint under \
18431             the paint_select flag (meshless retained draws ride the instanced \
18432             entry when the selection is latched on)"
18433        );
18434
18435        // The storage variant is what native devices actually compile; it
18436        // must be valid WGSL, not just textually plausible.
18437        let module = naga::front::wgsl::parse_str(&source)
18438            .expect("storage-mode shape shader must parse as WGSL");
18439        naga::valid::Validator::new(
18440            naga::valid::ValidationFlags::all(),
18441            naga::valid::Capabilities::all(),
18442        )
18443        .validate(&module)
18444        .expect("storage-mode shape shader must validate for WebGPU");
18445    }
18446
18447    #[test]
18448    fn uniform_shape_shader_keeps_the_in_record_color_and_no_paint_binding() {
18449        // The base text serves WebGL-class uniform devices, which can bind
18450        // no storage buffers: the paint array and its select must exist only
18451        // in the storage-mode rewrite.
18452        for source in [
18453            Cow::Borrowed(shaders::SHADER),
18454            shape_shader_source(ShapeBatchLimits::desktop()),
18455        ] {
18456            assert!(
18457                !source.contains("paint: array"),
18458                "the uniform variant must not declare a paint array"
18459            );
18460            assert!(
18461                source.contains("output.color = shape.color;"),
18462                "the uniform variant must read the color from ShapeData \
18463                 (this literal is also what `shape_shader_source` rewrites)"
18464            );
18465            assert!(
18466                source.contains("paint_select: f32"),
18467                "SimilarityTransform must name the flag field in both \
18468                 variants; the Rust mirror is Pod and uploads raw bytes"
18469            );
18470        }
18471    }
18472
18473    #[test]
18474    fn shipped_shape_shader_array_length_fits_the_downlevel_uniform_floor() {
18475        // The wasm build uses `shaders::SHADER` verbatim, so its declared array
18476        // length is simultaneously the wasm batch cap and the WebGL binding
18477        // size. It must fit the 16 KiB floor exactly.
18478        assert!(
18479            shaders::SHADER.contains("array<ShapeData, 102>"),
18480            "shape.wgsl array length must stay in sync with \
18481             `shape_shader_source`'s replace string and MAX_SHAPES_PER_BATCH"
18482        );
18483        assert!(102 * std::mem::size_of::<ShapeData>() <= 16384);
18484        assert!(103 * std::mem::size_of::<ShapeData>() > 16384);
18485    }
18486
18487    #[test]
18488    fn glyph_atlas_doubles_on_overflow_and_stops_at_the_device_ceiling() {
18489        // Every overflow buys one doubling, so an app that needs the old fixed
18490        // 4096 atlas reaches it in three resets and then stays there.
18491        assert_eq!(
18492            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MIN_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
18493            1024
18494        );
18495        assert_eq!(
18496            next_glyph_atlas_size(2048, TEXT_GLYPH_ATLAS_MAX_SIZE),
18497            TEXT_GLYPH_ATLAS_MAX_SIZE
18498        );
18499        assert_eq!(
18500            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MAX_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
18501            TEXT_GLYPH_ATLAS_MAX_SIZE
18502        );
18503
18504        // A device that only grants `downlevel_defaults()`'s 2048 caps the
18505        // growth there rather than failing to create the texture.
18506        assert_eq!(next_glyph_atlas_size(1024, 2048), 2048);
18507        assert_eq!(next_glyph_atlas_size(2048, 2048), 2048);
18508
18509        // Never zero and never wrapping, whatever the ceiling turns out to be.
18510        assert_eq!(next_glyph_atlas_size(u32::MAX, 4096), 4096);
18511        assert_eq!(next_glyph_atlas_size(0, 0), 1);
18512    }
18513
18514    #[test]
18515    fn glyph_atlas_uv_rect_normalizes_against_the_atlas_it_was_placed_in() {
18516        // The atlas grows, so a UV is only meaningful together with the size of
18517        // the texture the entry came from. Reading the size off a constant is
18518        // what would make a grown atlas sample the wrong glyph.
18519        let entry = GlyphAtlasEntry {
18520            x: 128,
18521            y: 256,
18522            width: 16,
18523            height: 32,
18524        };
18525
18526        let small = glyph_atlas_uv_rect(entry, 512);
18527        let large = glyph_atlas_uv_rect(entry, 4096);
18528
18529        assert_eq!(small.min, [128.0 / 512.0, 256.0 / 512.0]);
18530        assert_eq!(large.min, [128.0 / 4096.0, 256.0 / 4096.0]);
18531        assert_eq!(small.max, [144.0 / 512.0, 288.0 / 512.0]);
18532        assert_eq!(large.max, [144.0 / 4096.0, 288.0 / 4096.0]);
18533    }
18534
18535    #[test]
18536    fn native_shape_shader_source_uses_native_batch_limits() {
18537        let limits = ShapeBatchLimits::desktop();
18538        let source = shape_shader_source(limits);
18539
18540        assert!(source.contains(&format!(
18541            "array<ShapeData, {}>",
18542            limits.max_shapes_per_batch
18543        )));
18544        assert!(source.contains(&format!(
18545            "array<GradientStop, {}>",
18546            limits.max_gradient_stops
18547        )));
18548        // Sanity: the substitution actually fired rather than silently leaving
18549        // the downlevel literal in place.
18550        assert!(!source.contains("array<ShapeData, 146>"));
18551    }
18552
18553    #[test]
18554    fn stroked_and_arc_shapes_batch_together_with_fills() {
18555        // Strokes and arcs ride the same pipeline, the same ShapeData array and
18556        // the same blend state as fills, so a run of mixed shapes must stay a
18557        // single batch. If they ever split the batch, a polar UI built from
18558        // hundreds of arcs would pay a draw call per arc — precisely the cost
18559        // this primitive exists to remove.
18560        let fill = test_shape(0, BlendMode::SrcOver);
18561        let mut stroked = test_shape(1, BlendMode::SrcOver);
18562        stroked.stroke = Some(
18563            cranpose_ui_graphics::Stroke::new(3.0)
18564                .with_cap(StrokeCap::Round)
18565                .with_join(StrokeJoin::Bevel),
18566        );
18567        let mut arc = test_shape(2, BlendMode::SrcOver);
18568        arc.arc = Some(cranpose_ui_graphics::ArcGeometry::new(
18569            Point::new(4.0, 4.0),
18570            2.0,
18571            4.0,
18572            0.0,
18573            1.0,
18574            StrokeCap::Round,
18575        ));
18576        let trailing_fill = test_shape(3, BlendMode::SrcOver);
18577
18578        assert!(!fill.has_stroke_or_arc());
18579        assert!(stroked.has_stroke_or_arc());
18580        assert!(arc.has_stroke_or_arc());
18581        assert!(!trailing_fill.has_stroke_or_arc());
18582
18583        let shapes = vec![fill, stroked, arc, trailing_fill];
18584        let ordered_items: Vec<_> = (0..shapes.len())
18585            .map(|index| (index, SegmentDrawItem::Shape(index)))
18586            .collect();
18587        let images = Vec::new();
18588
18589        let commands: Vec<_> = SegmentCommandIter::new(
18590            &ordered_items,
18591            &shapes,
18592            &images,
18593            ShapeBatchLimits::desktop(),
18594        )
18595        .collect();
18596
18597        assert_eq!(
18598            commands,
18599            vec![SegmentRenderCommand::DrawChunk(chunk(&[
18600                SegmentBatchPlan::Shape {
18601                    start: 0,
18602                    end: 4,
18603                    blend_mode: BlendMode::SrcOver,
18604                }
18605            ]))],
18606            "mixed fill/stroke/arc runs must stay one batch"
18607        );
18608    }
18609
18610    #[cfg(not(target_arch = "wasm32"))]
18611    #[test]
18612    fn native_segment_fusion_budget_allows_small_interleaved_chunks() {
18613        let ordered_items = vec![
18614            (0, SegmentDrawItem::Shape(0)),
18615            (1, SegmentDrawItem::Image(0)),
18616            (2, SegmentDrawItem::Text(0)),
18617            (3, SegmentDrawItem::Shape(1)),
18618        ];
18619        let shapes = vec![
18620            test_shape(0, BlendMode::SrcOver),
18621            test_shape(3, BlendMode::DstOut),
18622        ];
18623        let segment = chunk(&[
18624            SegmentBatchPlan::Shape {
18625                start: 0,
18626                end: 1,
18627                blend_mode: BlendMode::SrcOver,
18628            },
18629            SegmentBatchPlan::Image {
18630                start: 1,
18631                end: 2,
18632                blend_mode: BlendMode::SrcOver,
18633            },
18634            SegmentBatchPlan::Text { start: 2, end: 3 },
18635            SegmentBatchPlan::Shape {
18636                start: 3,
18637                end: 4,
18638                blend_mode: BlendMode::DstOut,
18639            },
18640        ]);
18641
18642        let budget = native_segment_fusion_budget(
18643            &ordered_items,
18644            &shapes,
18645            &segment,
18646            ShapeBatchLimits::desktop(),
18647        )
18648        .expect("budget should be valid")
18649        .expect("chunk should fit native fusion budget");
18650
18651        assert_eq!(
18652            budget,
18653            NativeSegmentFusionBudget {
18654                shape_count: 2,
18655                gradient_stop_count: 0,
18656            }
18657        );
18658    }
18659
18660    #[cfg(not(target_arch = "wasm32"))]
18661    #[test]
18662    fn native_segment_fusion_budget_rejects_shape_uniform_overflow() {
18663        let ordered_items: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
18664            .map(|index| (index, SegmentDrawItem::Shape(index)))
18665            .collect();
18666        let shapes: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
18667            .map(|index| test_shape(index, BlendMode::SrcOver))
18668            .collect();
18669        let segment = chunk(&[
18670            SegmentBatchPlan::Shape {
18671                start: 0,
18672                end: MAX_SHAPES_PER_BATCH,
18673                blend_mode: BlendMode::SrcOver,
18674            },
18675            SegmentBatchPlan::Shape {
18676                start: MAX_SHAPES_PER_BATCH,
18677                end: MAX_SHAPES_PER_BATCH + 1,
18678                blend_mode: BlendMode::SrcOver,
18679            },
18680        ]);
18681
18682        let budget = native_segment_fusion_budget(
18683            &ordered_items,
18684            &shapes,
18685            &segment,
18686            ShapeBatchLimits::desktop(),
18687        )
18688        .expect("valid plan");
18689
18690        assert_eq!(budget, None);
18691    }
18692
18693    #[cfg(not(target_arch = "wasm32"))]
18694    #[test]
18695    fn native_segment_fusion_budget_rejects_gradient_uniform_overflow() {
18696        let ordered_items = vec![(0, SegmentDrawItem::Shape(0))];
18697        let mut shape = test_shape(0, BlendMode::SrcOver);
18698        shape.brush = Brush::linear_gradient(vec![Color::BLACK; MAX_GRADIENT_STOPS + 1]);
18699        let shapes = vec![shape];
18700        let segment = chunk(&[SegmentBatchPlan::Shape {
18701            start: 0,
18702            end: 1,
18703            blend_mode: BlendMode::SrcOver,
18704        }]);
18705
18706        let budget = native_segment_fusion_budget(
18707            &ordered_items,
18708            &shapes,
18709            &segment,
18710            ShapeBatchLimits::desktop(),
18711        )
18712        .expect("valid plan");
18713
18714        assert_eq!(budget, None);
18715    }
18716
18717    #[cfg(not(target_arch = "wasm32"))]
18718    #[test]
18719    fn native_segment_fusion_partitions_shape_uniform_overflow() {
18720        // The uniform batch cap is derived from the device binding size and
18721        // the 112-byte ShapeData, not from the compile-time ceiling.
18722        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
18723        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
18724            .map(|index| (index, SegmentDrawItem::Shape(index)))
18725            .collect();
18726        let shapes: Vec<_> = (0..=desktop_batch_cap)
18727            .map(|index| test_shape(index, BlendMode::SrcOver))
18728            .collect();
18729        let segment = chunk(&[
18730            SegmentBatchPlan::Shape {
18731                start: 0,
18732                end: desktop_batch_cap,
18733                blend_mode: BlendMode::SrcOver,
18734            },
18735            SegmentBatchPlan::Shape {
18736                start: desktop_batch_cap,
18737                end: desktop_batch_cap + 1,
18738                blend_mode: BlendMode::SrcOver,
18739            },
18740        ]);
18741
18742        let partitions = native_segment_fusion_partitions(
18743            &ordered_items,
18744            &shapes,
18745            &segment,
18746            ShapeBatchLimits::desktop(),
18747        )
18748        .expect("valid plan")
18749        .expect("overflowing segment should be partitionable");
18750
18751        assert_eq!(partitions.len(), 2);
18752        assert_eq!(
18753            partitions[0],
18754            NativeSegmentFusionPartition {
18755                chunk: chunk(&[SegmentBatchPlan::Shape {
18756                    start: 0,
18757                    end: desktop_batch_cap,
18758                    blend_mode: BlendMode::SrcOver,
18759                }]),
18760                budget: NativeSegmentFusionBudget {
18761                    shape_count: desktop_batch_cap,
18762                    gradient_stop_count: 0,
18763                },
18764            }
18765        );
18766        assert_eq!(
18767            partitions[1],
18768            NativeSegmentFusionPartition {
18769                chunk: chunk(&[SegmentBatchPlan::Shape {
18770                    start: desktop_batch_cap,
18771                    end: desktop_batch_cap + 1,
18772                    blend_mode: BlendMode::SrcOver,
18773                }]),
18774                budget: NativeSegmentFusionBudget {
18775                    shape_count: 1,
18776                    gradient_stop_count: 0,
18777                },
18778            }
18779        );
18780    }
18781
18782    #[cfg(not(target_arch = "wasm32"))]
18783    #[test]
18784    fn native_segment_fusion_partitions_gradient_uniform_overflow() {
18785        const STOPS_PER_SHAPE: usize = MAX_GRADIENT_STOPS / 2;
18786        let ordered_items = vec![
18787            (0, SegmentDrawItem::Shape(0)),
18788            (1, SegmentDrawItem::Shape(1)),
18789            (2, SegmentDrawItem::Shape(2)),
18790        ];
18791        let mut shapes = Vec::new();
18792        for index in 0..3 {
18793            let mut shape = test_shape(index, BlendMode::SrcOver);
18794            shape.brush = Brush::linear_gradient(vec![Color::BLACK; STOPS_PER_SHAPE]);
18795            shapes.push(shape);
18796        }
18797        let segment = chunk(&[SegmentBatchPlan::Shape {
18798            start: 0,
18799            end: 3,
18800            blend_mode: BlendMode::SrcOver,
18801        }]);
18802
18803        let partitions = native_segment_fusion_partitions(
18804            &ordered_items,
18805            &shapes,
18806            &segment,
18807            ShapeBatchLimits::desktop(),
18808        )
18809        .expect("valid plan")
18810        .expect("overflowing gradient segment should be partitionable");
18811
18812        assert_eq!(partitions.len(), 2);
18813        assert_eq!(
18814            partitions[0],
18815            NativeSegmentFusionPartition {
18816                chunk: chunk(&[SegmentBatchPlan::Shape {
18817                    start: 0,
18818                    end: 2,
18819                    blend_mode: BlendMode::SrcOver,
18820                }]),
18821                budget: NativeSegmentFusionBudget {
18822                    shape_count: 2,
18823                    gradient_stop_count: MAX_GRADIENT_STOPS,
18824                },
18825            }
18826        );
18827        assert_eq!(
18828            partitions[1],
18829            NativeSegmentFusionPartition {
18830                chunk: chunk(&[SegmentBatchPlan::Shape {
18831                    start: 2,
18832                    end: 3,
18833                    blend_mode: BlendMode::SrcOver,
18834                }]),
18835                budget: NativeSegmentFusionBudget {
18836                    shape_count: 1,
18837                    gradient_stop_count: STOPS_PER_SHAPE,
18838                },
18839            }
18840        );
18841    }
18842
18843    #[cfg(not(target_arch = "wasm32"))]
18844    #[test]
18845    fn native_segment_fusion_accepts_layer_composite_chunks() {
18846        let ordered_items = vec![
18847            (0, SegmentDrawItem::Shape(0)),
18848            (1, SegmentDrawItem::Composite(0)),
18849            (2, SegmentDrawItem::ShaderComposite(0)),
18850            (3, SegmentDrawItem::Shape(1)),
18851        ];
18852        let shapes = vec![
18853            test_shape(0, BlendMode::SrcOver),
18854            test_shape(1, BlendMode::SrcOver),
18855        ];
18856        let segment = chunk(&[
18857            SegmentBatchPlan::Shape {
18858                start: 0,
18859                end: 1,
18860                blend_mode: BlendMode::SrcOver,
18861            },
18862            SegmentBatchPlan::Composite { start: 1, end: 2 },
18863            SegmentBatchPlan::ShaderComposite { start: 2, end: 3 },
18864            SegmentBatchPlan::Shape {
18865                start: 3,
18866                end: 4,
18867                blend_mode: BlendMode::SrcOver,
18868            },
18869        ]);
18870
18871        let partitions = native_segment_fusion_partitions(
18872            &ordered_items,
18873            &shapes,
18874            &segment,
18875            ShapeBatchLimits::desktop(),
18876        )
18877        .expect("valid plan")
18878        .expect("composites are drawable inside the native fused pass");
18879
18880        assert_eq!(
18881            partitions,
18882            vec![NativeSegmentFusionPartition {
18883                chunk: segment,
18884                budget: NativeSegmentFusionBudget {
18885                    shape_count: 2,
18886                    gradient_stop_count: 0,
18887                },
18888            }],
18889            "layer composites and shader composites must preserve order without forcing separate render passes"
18890        );
18891    }
18892
18893    #[cfg(not(target_arch = "wasm32"))]
18894    #[test]
18895    fn native_segment_fusion_partitions_preserve_non_shape_order_at_budget_boundary() {
18896        // The uniform batch cap is derived from the device binding size and
18897        // the 112-byte ShapeData, not from the compile-time ceiling.
18898        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
18899        let ordered_items: Vec<_> = (0..desktop_batch_cap)
18900            .map(|index| (index, SegmentDrawItem::Shape(index)))
18901            .chain([
18902                (desktop_batch_cap, SegmentDrawItem::Image(0)),
18903                (
18904                    desktop_batch_cap + 1,
18905                    SegmentDrawItem::Shape(desktop_batch_cap),
18906                ),
18907            ])
18908            .collect();
18909        let shapes: Vec<_> = (0..=desktop_batch_cap)
18910            .map(|index| test_shape(index, BlendMode::SrcOver))
18911            .collect();
18912        let segment = chunk(&[
18913            SegmentBatchPlan::Shape {
18914                start: 0,
18915                end: desktop_batch_cap,
18916                blend_mode: BlendMode::SrcOver,
18917            },
18918            SegmentBatchPlan::Image {
18919                start: desktop_batch_cap,
18920                end: desktop_batch_cap + 1,
18921                blend_mode: BlendMode::SrcOver,
18922            },
18923            SegmentBatchPlan::Shape {
18924                start: desktop_batch_cap + 1,
18925                end: desktop_batch_cap + 2,
18926                blend_mode: BlendMode::SrcOver,
18927            },
18928        ]);
18929
18930        let partitions = native_segment_fusion_partitions(
18931            &ordered_items,
18932            &shapes,
18933            &segment,
18934            ShapeBatchLimits::desktop(),
18935        )
18936        .expect("valid plan")
18937        .expect("overflowing segment should be partitionable");
18938
18939        assert_eq!(partitions.len(), 2);
18940        assert_eq!(
18941            partitions[0].chunk,
18942            chunk(&[
18943                SegmentBatchPlan::Shape {
18944                    start: 0,
18945                    end: desktop_batch_cap,
18946                    blend_mode: BlendMode::SrcOver,
18947                },
18948                SegmentBatchPlan::Image {
18949                    start: desktop_batch_cap,
18950                    end: desktop_batch_cap + 1,
18951                    blend_mode: BlendMode::SrcOver,
18952                },
18953            ])
18954        );
18955        assert_eq!(
18956            partitions[1].chunk,
18957            chunk(&[SegmentBatchPlan::Shape {
18958                start: desktop_batch_cap + 1,
18959                end: desktop_batch_cap + 2,
18960                blend_mode: BlendMode::SrcOver,
18961            }])
18962        );
18963    }
18964
18965    #[test]
18966    fn segment_command_iter_keeps_repeated_batch_kinds_in_one_chunk() {
18967        let ordered_items = vec![
18968            (0, SegmentDrawItem::Shape(0)),
18969            (1, SegmentDrawItem::Image(0)),
18970            (2, SegmentDrawItem::Shape(1)),
18971        ];
18972        let shapes = vec![
18973            test_shape(0, BlendMode::SrcOver),
18974            test_shape(2, BlendMode::DstOut),
18975        ];
18976        let images = vec![test_image(1, BlendMode::SrcOver)];
18977
18978        let commands: Vec<_> = SegmentCommandIter::new(
18979            &ordered_items,
18980            &shapes,
18981            &images,
18982            ShapeBatchLimits::desktop(),
18983        )
18984        .collect();
18985
18986        assert_eq!(
18987            commands,
18988            vec![SegmentRenderCommand::DrawChunk(chunk(&[
18989                SegmentBatchPlan::Shape {
18990                    start: 0,
18991                    end: 1,
18992                    blend_mode: BlendMode::SrcOver,
18993                },
18994                SegmentBatchPlan::Image {
18995                    start: 1,
18996                    end: 2,
18997                    blend_mode: BlendMode::SrcOver,
18998                },
18999                SegmentBatchPlan::Shape {
19000                    start: 2,
19001                    end: 3,
19002                    blend_mode: BlendMode::DstOut,
19003                },
19004            ]))]
19005        );
19006    }
19007
19008    #[test]
19009    fn segment_command_iter_splits_contiguous_shape_runs_at_uniform_batch_limit() {
19010        // The uniform batch cap is derived from the device binding size and
19011        // the 112-byte ShapeData, not from the compile-time ceiling.
19012        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
19013        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
19014            .map(|index| (index, SegmentDrawItem::Shape(index)))
19015            .collect();
19016        let shapes: Vec<_> = (0..=desktop_batch_cap)
19017            .map(|index| test_shape(index, BlendMode::SrcOver))
19018            .collect();
19019        let images = Vec::new();
19020
19021        let commands: Vec<_> = SegmentCommandIter::new(
19022            &ordered_items,
19023            &shapes,
19024            &images,
19025            ShapeBatchLimits::desktop(),
19026        )
19027        .collect();
19028
19029        assert_eq!(
19030            commands,
19031            vec![SegmentRenderCommand::DrawChunk(chunk(&[
19032                SegmentBatchPlan::Shape {
19033                    start: 0,
19034                    end: desktop_batch_cap,
19035                    blend_mode: BlendMode::SrcOver,
19036                },
19037                SegmentBatchPlan::Shape {
19038                    start: desktop_batch_cap,
19039                    end: desktop_batch_cap + 1,
19040                    blend_mode: BlendMode::SrcOver,
19041                },
19042            ]))]
19043        );
19044    }
19045
19046    #[test]
19047    fn segment_command_iter_keeps_shadows_as_explicit_boundaries() {
19048        let ordered_items = vec![
19049            (0, SegmentDrawItem::Shape(0)),
19050            (1, SegmentDrawItem::Shadow(0)),
19051            (2, SegmentDrawItem::Image(0)),
19052            (3, SegmentDrawItem::Text(0)),
19053        ];
19054        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
19055        let images = vec![test_image(2, BlendMode::SrcOver)];
19056
19057        let commands: Vec<_> = SegmentCommandIter::new(
19058            &ordered_items,
19059            &shapes,
19060            &images,
19061            ShapeBatchLimits::desktop(),
19062        )
19063        .collect();
19064
19065        assert_eq!(
19066            commands,
19067            vec![
19068                SegmentRenderCommand::DrawChunk(chunk(&[SegmentBatchPlan::Shape {
19069                    start: 0,
19070                    end: 1,
19071                    blend_mode: BlendMode::SrcOver,
19072                }])),
19073                SegmentRenderCommand::Shadow(0),
19074                SegmentRenderCommand::DrawChunk(chunk(&[
19075                    SegmentBatchPlan::Image {
19076                        start: 2,
19077                        end: 3,
19078                        blend_mode: BlendMode::SrcOver,
19079                    },
19080                    SegmentBatchPlan::Text { start: 3, end: 4 },
19081                ])),
19082            ]
19083        );
19084    }
19085
19086    #[test]
19087    fn staged_buffer_uploads_align_new_copies_to_copy_buffer_alignment() {
19088        let mut uploads = StagedBufferUploads::default();
19089        uploads.bytes.extend_from_slice(&[1, 2]);
19090
19091        uploads.stage(UploadTarget::ImageIndex, &[3, 4, 5, 6]);
19092
19093        assert_eq!(uploads.bytes, vec![1, 2, 0, 0, 3, 4, 5, 6]);
19094        assert_eq!(
19095            uploads.copies,
19096            vec![PendingBufferCopy {
19097                source_offset: 4,
19098                target_offset: 0,
19099                size: 4,
19100                target: UploadTarget::ImageIndex,
19101            }]
19102        );
19103    }
19104
19105    #[test]
19106    fn staged_buffer_uploads_ignore_empty_payloads() {
19107        let mut uploads = StagedBufferUploads::default();
19108
19109        uploads.stage(UploadTarget::Uniform, &[]);
19110
19111        assert!(uploads.is_empty());
19112        assert!(uploads.bytes.is_empty());
19113    }
19114
19115    #[test]
19116    fn staged_buffer_uploads_return_exact_payload_slice_for_copy() {
19117        let mut uploads = StagedBufferUploads::default();
19118        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
19119        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
19120
19121        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
19122        assert_eq!(uploads.payload_for_copy(uploads.copies[1]), &[5, 6, 7, 8]);
19123    }
19124
19125    #[test]
19126    fn staged_buffer_uploads_record_destination_offsets() {
19127        let mut uploads = StagedBufferUploads::default();
19128
19129        uploads.stage_at(UploadTarget::ImageIndex, 256, &[1, 2, 3, 4]);
19130
19131        assert_eq!(uploads.copies[0].target_offset, 256);
19132        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
19133    }
19134
19135    #[test]
19136    fn staged_buffer_uploads_truncate_restores_previous_state() {
19137        let mut uploads = StagedBufferUploads::default();
19138        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
19139        let bytes_len = uploads.bytes.len();
19140        let copies_len = uploads.copies.len();
19141        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
19142
19143        uploads.truncate(bytes_len, copies_len);
19144
19145        assert_eq!(uploads.bytes, vec![1, 2, 3, 4]);
19146        assert_eq!(uploads.copies.len(), 1);
19147    }
19148
19149    #[test]
19150    fn inner_shadow_composite_mask_uses_fill_shape_and_scale() {
19151        let mut fill = test_shape(0, BlendMode::SrcOver);
19152        fill.local_rect = Rect {
19153            x: 10.0,
19154            y: 12.0,
19155            width: 40.0,
19156            height: 20.0,
19157        };
19158        fill.shape = Some(RoundedCornerShape::uniform(6.0));
19159
19160        let cutout = test_shape(1, BlendMode::DstOut);
19161        let shadow = test_shadow_draw(vec![
19162            (fill, BlendMode::SrcOver),
19163            (cutout, BlendMode::DstOut),
19164        ]);
19165
19166        let mask = inner_shadow_composite_mask(&shadow, 1.5).expect("inner mask expected");
19167        assert_eq!(mask.rect, [15.0, 18.0, 60.0, 30.0]);
19168        assert_eq!(mask.radii, [9.0, 9.0, 9.0, 9.0]);
19169    }
19170
19171    #[test]
19172    fn inner_shadow_composite_mask_is_none_without_dst_out() {
19173        let fill = test_shape(0, BlendMode::SrcOver);
19174        let shadow = test_shadow_draw(vec![(fill, BlendMode::SrcOver)]);
19175        assert!(inner_shadow_composite_mask(&shadow, 1.0).is_none());
19176    }
19177
19178    #[test]
19179    fn render_effect_support_matrix_covers_all_variants() {
19180        let blur = RenderEffect::blur(4.0);
19181        let offset = RenderEffect::offset(2.0, 3.0);
19182        let shader = RenderEffect::runtime_shader(cranpose_ui_graphics::RuntimeShader::new(
19183            r#"
19184            @group(0) @binding(0) var input_texture: texture_2d<f32>;
19185            @group(0) @binding(1) var input_sampler: sampler;
19186            @group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
19187            struct VertexOutput {
19188                @builtin(position) position: vec4<f32>,
19189                @location(0) uv: vec2<f32>,
19190            }
19191            @vertex
19192            fn fullscreen_vs(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
19193                var output: VertexOutput;
19194                let x = f32(i32(vertex_index & 1u) * 2 - 1);
19195                let y = f32(i32(vertex_index >> 1u) * 2 - 1);
19196                output.uv = vec2<f32>(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
19197                output.position = vec4<f32>(x, y, 0.0, 1.0);
19198                return output;
19199            }
19200            @fragment
19201            fn effect_fs(input: VertexOutput) -> @location(0) vec4<f32> {
19202                return textureSample(input_texture, input_sampler, input.uv);
19203            }
19204            "#,
19205        ));
19206        let chain = blur.clone().then(offset.clone());
19207
19208        assert!(is_render_effect_supported(&blur));
19209        assert!(is_render_effect_supported(&offset));
19210        assert!(is_render_effect_supported(&shader));
19211        assert!(is_render_effect_supported(&chain));
19212    }
19213
19214    #[test]
19215    fn clip_to_bounds_propagates_visual_clip_to_all_descendant_shapes() {
19216        // Simulates: root → clip_to_bounds container → child with shapes above/below clip
19217        // All shapes inside the clip_to_bounds container must have a clip set.
19218        let container_local_bounds = Rect {
19219            x: 0.0,
19220            y: 0.0,
19221            width: 800.0,
19222            height: 500.0,
19223        };
19224        // Container is placed at y=50 in parent space via transform_to_parent
19225        let container_clip_in_parent = Rect {
19226            x: 0.0,
19227            y: 50.0,
19228            width: 800.0,
19229            height: 500.0,
19230        };
19231
19232        // Shape that extends above the clip boundary (scroll content scrolled up)
19233        let shape_above = RenderNode::Primitive(PrimitiveEntry {
19234            phase: PrimitivePhase::BeforeChildren,
19235            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19236                primitive: DrawPrimitive::Rect {
19237                    rect: Rect {
19238                        x: 10.0,
19239                        y: -30.0,
19240                        width: 100.0,
19241                        height: 40.0,
19242                    },
19243                    brush: Brush::solid(Color::WHITE),
19244                    stroke: None,
19245                },
19246                clip: None,
19247            }),
19248        });
19249
19250        // Shape within the clip boundary
19251        let shape_inside = RenderNode::Primitive(PrimitiveEntry {
19252            phase: PrimitivePhase::BeforeChildren,
19253            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19254                primitive: DrawPrimitive::Rect {
19255                    rect: Rect {
19256                        x: 10.0,
19257                        y: 100.0,
19258                        width: 100.0,
19259                        height: 40.0,
19260                    },
19261                    brush: Brush::solid(Color::WHITE),
19262                    stroke: None,
19263                },
19264                clip: None,
19265            }),
19266        });
19267
19268        // Shape below the clip boundary (scroll content below viewport)
19269        let shape_below = RenderNode::Primitive(PrimitiveEntry {
19270            phase: PrimitivePhase::BeforeChildren,
19271            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19272                primitive: DrawPrimitive::Rect {
19273                    rect: Rect {
19274                        x: 10.0,
19275                        y: 600.0,
19276                        width: 100.0,
19277                        height: 40.0,
19278                    },
19279                    brush: Brush::solid(Color::WHITE),
19280                    stroke: None,
19281                },
19282                clip: None,
19283            }),
19284        });
19285
19286        // Content child layer (represents scroll content, translated up by scroll offset)
19287        let mut content_layer = test_layer(
19288            Rect {
19289                x: 0.0,
19290                y: 0.0,
19291                width: 800.0,
19292                height: 1000.0,
19293            },
19294            vec![shape_above, shape_inside, shape_below],
19295        );
19296        content_layer.transform_to_parent = ProjectiveTransform::translation(0.0, -30.0);
19297        content_layer.translated_content_context = true;
19298
19299        // Clip container (e.g. TabContent with clip_to_bounds)
19300        let mut clip_container = test_layer(
19301            container_local_bounds,
19302            vec![RenderNode::Layer(Box::new(content_layer))],
19303        );
19304        clip_container.clip_to_bounds = true;
19305        clip_container.transform_to_parent = ProjectiveTransform::translation(0.0, 50.0);
19306
19307        // Root
19308        let root = test_layer(
19309            Rect {
19310                x: 0.0,
19311                y: 0.0,
19312                width: 800.0,
19313                height: 600.0,
19314            },
19315            vec![RenderNode::Layer(Box::new(clip_container))],
19316        );
19317
19318        let mut rect_cache = HashMap::new();
19319        let mut requirements_cache = HashMap::new();
19320        let collected =
19321            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19322
19323        assert_eq!(
19324            collected.scene.shapes.len(),
19325            3,
19326            "all three shapes should be flattened into the scene"
19327        );
19328
19329        for (i, shape) in collected.scene.shapes.iter().enumerate() {
19330            assert!(
19331                shape.clip.is_some(),
19332                "shape {} at rect {:?} must have a clip from clip_to_bounds container, but clip is None",
19333                i,
19334                shape.rect
19335            );
19336            let clip = shape.clip.unwrap();
19337            assert_eq!(
19338                clip, container_clip_in_parent,
19339                "shape {} clip should match the clip_to_bounds container bounds in parent space",
19340                i
19341            );
19342        }
19343    }
19344
19345    #[test]
19346    fn clip_to_bounds_culls_child_layers_outside_boundary() {
19347        // Reproduces the out-of-clip rendering bug: a child layer with
19348        // graphics_layer.clip=true (e.g. from rounded_surface()) positioned
19349        // entirely below the parent's clip_to_bounds boundary must be culled.
19350        // Before the fix, resolve_clip returned None for non-overlapping rects,
19351        // which downstream code interpreted as "no clipping" instead of "fully clipped",
19352        // causing invisible content to render everywhere.
19353
19354        let clip_container_bounds = Rect {
19355            x: 0.0,
19356            y: 0.0,
19357            width: 800.0,
19358            height: 500.0,
19359        };
19360
19361        let shape_in_card = RenderNode::Primitive(PrimitiveEntry {
19362            phase: PrimitivePhase::BeforeChildren,
19363            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19364                primitive: DrawPrimitive::Rect {
19365                    rect: Rect {
19366                        x: 0.0,
19367                        y: 0.0,
19368                        width: 300.0,
19369                        height: 80.0,
19370                    },
19371                    brush: Brush::solid(Color::WHITE),
19372                    stroke: None,
19373                },
19374                clip: None,
19375            }),
19376        });
19377
19378        // Card layer with graphics_layer.clip=true, positioned BELOW the clip boundary
19379        let mut card_outside = crate::test_support::layer_node(
19380            Rect {
19381                x: 0.0,
19382                y: 0.0,
19383                width: 300.0,
19384                height: 80.0,
19385            },
19386            ProjectiveTransform::identity(),
19387            GraphicsLayer {
19388                clip: true,
19389                ..GraphicsLayer::default()
19390            },
19391            vec![shape_in_card.clone()],
19392        );
19393        card_outside.transform_to_parent = ProjectiveTransform::translation(10.0, 600.0);
19394
19395        // Card layer with graphics_layer.clip=true, positioned INSIDE the clip boundary
19396        let mut card_inside = crate::test_support::layer_node(
19397            Rect {
19398                x: 0.0,
19399                y: 0.0,
19400                width: 300.0,
19401                height: 80.0,
19402            },
19403            ProjectiveTransform::identity(),
19404            GraphicsLayer {
19405                clip: true,
19406                ..GraphicsLayer::default()
19407            },
19408            vec![shape_in_card],
19409        );
19410        card_inside.transform_to_parent = ProjectiveTransform::translation(10.0, 100.0);
19411
19412        // Content layer holding both cards
19413        let content = test_layer(
19414            Rect {
19415                x: 0.0,
19416                y: 0.0,
19417                width: 800.0,
19418                height: 1000.0,
19419            },
19420            vec![
19421                RenderNode::Layer(Box::new(card_inside)),
19422                RenderNode::Layer(Box::new(card_outside)),
19423            ],
19424        );
19425
19426        // Clip container
19427        let mut clip_container = test_layer(
19428            clip_container_bounds,
19429            vec![RenderNode::Layer(Box::new(content))],
19430        );
19431        clip_container.clip_to_bounds = true;
19432
19433        // Root
19434        let root = test_layer(
19435            Rect {
19436                x: 0.0,
19437                y: 0.0,
19438                width: 800.0,
19439                height: 600.0,
19440            },
19441            vec![RenderNode::Layer(Box::new(clip_container))],
19442        );
19443
19444        let mut rect_cache = HashMap::new();
19445        let mut requirements_cache = HashMap::new();
19446        let collected =
19447            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19448
19449        assert_eq!(
19450            collected.scene.shapes.len(),
19451            1,
19452            "only the card inside the clip boundary should produce shapes; \
19453             the card outside must be culled entirely"
19454        );
19455
19456        let shape = &collected.scene.shapes[0];
19457        assert!(
19458            shape.clip.is_some(),
19459            "the visible card's shape must have a clip from clip_to_bounds"
19460        );
19461    }
19462
19463    #[test]
19464    fn flattened_layer_shadow_z_index_is_below_content() {
19465        // Shadow must render behind content. When a child layer with shadow_elevation
19466        // is flattened (no isolation), its shadow z-index must be lower than any
19467        // content z-index so shadow draws render first.
19468        let shape = RenderNode::Primitive(PrimitiveEntry {
19469            phase: PrimitivePhase::BeforeChildren,
19470            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19471                primitive: DrawPrimitive::Rect {
19472                    rect: Rect {
19473                        x: 0.0,
19474                        y: 0.0,
19475                        width: 100.0,
19476                        height: 100.0,
19477                    },
19478                    brush: Brush::solid(Color::WHITE),
19479                    stroke: None,
19480                },
19481                clip: None,
19482            }),
19483        });
19484
19485        let child_bounds = Rect {
19486            x: 0.0,
19487            y: 0.0,
19488            width: 100.0,
19489            height: 100.0,
19490        };
19491
19492        let child = crate::test_support::layer_node(
19493            child_bounds,
19494            ProjectiveTransform::translation(50.0, 50.0),
19495            GraphicsLayer {
19496                shadow_elevation: 20.0,
19497                ..GraphicsLayer::default()
19498            },
19499            vec![shape],
19500        );
19501
19502        let root = test_layer(
19503            Rect {
19504                x: 0.0,
19505                y: 0.0,
19506                width: 800.0,
19507                height: 600.0,
19508            },
19509            vec![RenderNode::Layer(Box::new(child))],
19510        );
19511
19512        let mut rect_cache = HashMap::new();
19513        let mut requirements_cache = HashMap::new();
19514        let collected =
19515            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19516
19517        assert!(
19518            !collected.scene.shadow_draws.is_empty(),
19519            "shadow_elevation > 0 must produce shadow draws"
19520        );
19521        let max_shadow_z = collected
19522            .scene
19523            .shadow_draws
19524            .iter()
19525            .map(|s| s.z_index)
19526            .max()
19527            .unwrap();
19528        let min_content_z = collected
19529            .scene
19530            .shapes
19531            .iter()
19532            .map(|s| s.z_index)
19533            .min()
19534            .unwrap();
19535        assert!(
19536            max_shadow_z < min_content_z,
19537            "shadow z-index ({}) must be less than content z-index ({}); \
19538             shadows must render behind their content",
19539            max_shadow_z,
19540            min_content_z
19541        );
19542    }
19543
19544    /// One retained bundle op key with the fields the invalidation tests
19545    /// vary; the rest stay representative constants.
19546    #[cfg(not(target_arch = "wasm32"))]
19547    fn bundle_op(slot: u32, epoch: Option<u64>, first: u32, last: u32) -> RetainedBundleOpKey {
19548        RetainedBundleOpKey {
19549            slot,
19550            capture_epoch: epoch,
19551            first,
19552            last,
19553            retained_index: slot,
19554            has_mesh: false,
19555        }
19556    }
19557
19558    #[cfg(not(target_arch = "wasm32"))]
19559    fn bundle_key(ops: &[RetainedBundleOpKey]) -> RetainedBundleKey {
19560        RetainedBundleKey { ops: ops.to_vec() }
19561    }
19562
19563    /// The same stretch on consecutive frames reuses its bundle: one
19564    /// rebuild, then cached executes.
19565    #[cfg(not(target_arch = "wasm32"))]
19566    #[test]
19567    fn retained_bundle_cache_reuses_stable_keys() {
19568        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
19569        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
19570        let key = bundle_key(&ops);
19571
19572        assert!(!cache.hit(&key), "empty cache must miss");
19573        cache.insert(key.clone(), 111);
19574        assert_eq!(cache.get(&key), Some(&111));
19575        cache.end_frame();
19576
19577        for _ in 0..3 {
19578            assert!(cache.hit(&bundle_key(&ops)), "stable key must stay cached");
19579            cache.end_frame();
19580        }
19581        assert_eq!(cache.stats(), (1, 3), "one rebuild, three cached executes");
19582    }
19583
19584    /// Recapture (epoch bump), span reorder, count change, range change and
19585    /// slot release each change the key, so a stale bundle can never satisfy
19586    /// the lookup.
19587    #[cfg(not(target_arch = "wasm32"))]
19588    #[test]
19589    fn retained_bundle_cache_invalidates_on_any_op_change() {
19590        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
19591        let variants: [Vec<RetainedBundleOpKey>; 5] = [
19592            // Recaptured slot 3: same id, bumped epoch.
19593            vec![bundle_op(3, Some(8), 0, 40), bundle_op(5, Some(9), 4, 12)],
19594            // Reordered stretch.
19595            vec![bundle_op(5, Some(9), 4, 12), bundle_op(3, Some(7), 0, 40)],
19596            // Op count changed.
19597            vec![bundle_op(3, Some(7), 0, 40)],
19598            // Draw range changed.
19599            vec![bundle_op(3, Some(7), 0, 41), bundle_op(5, Some(9), 4, 12)],
19600            // Slot 5 released: epoch gone.
19601            vec![bundle_op(3, Some(7), 0, 40), bundle_op(5, None, 4, 12)],
19602        ];
19603        for changed in variants {
19604            let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
19605            cache.insert(bundle_key(&ops), 111);
19606            cache.end_frame();
19607            assert!(
19608                !cache.hit(&RetainedBundleKey {
19609                    ops: changed.clone()
19610                }),
19611                "changed key {changed:?} must not reuse the stale bundle"
19612            );
19613        }
19614    }
19615
19616    /// Entries a frame does not use are evicted at its end — bundles pin
19617    /// slot buffers, so unused ones must not accumulate — and `clear` (the
19618    /// slot-release path) empties the cache outright.
19619    #[cfg(not(target_arch = "wasm32"))]
19620    #[test]
19621    fn retained_bundle_cache_evicts_unused_entries() {
19622        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
19623        let stale = bundle_key(&[bundle_op(1, Some(1), 0, 6)]);
19624        let live = bundle_key(&[bundle_op(2, Some(2), 0, 6)]);
19625        cache.insert(stale.clone(), 1);
19626        cache.insert(live.clone(), 2);
19627        cache.end_frame();
19628
19629        assert!(cache.hit(&live));
19630        cache.end_frame();
19631
19632        assert!(
19633            !cache.hit(&stale),
19634            "entry unused for a frame must have been evicted"
19635        );
19636        assert!(cache.hit(&live), "used entry must survive eviction");
19637
19638        cache.clear();
19639        assert!(!cache.hit(&live), "clear must drop every entry");
19640    }
19641}