Skip to main content

cranpose_render_wgpu/
render.rs

1//! GPU rendering implementation using WGPU
2
3use crate::effect_renderer::{
4    projective_dest_bounds_rect, CompositeBatchItem, CompositeSampleMode, EffectRenderer,
5    EffectScratchTargetProvider, ProjectiveSurfaceComposite, RoundedCompositeMask,
6    ShaderCompositeBatchItem,
7};
8use crate::frame_graph::{
9    FrameCommandRecorder, FrameTextureDescriptor, WgpuFrameGraph, WgpuFrameGraphExecutor,
10};
11use crate::frame_packet::{
12    CancelReason, FramePacket, PacketRoot, PresentOutcome, RenderReturns, RootSurfacePacket,
13};
14use crate::layer_events::{
15    collect_effect_ranges, collect_layer_events, LayerEvent, LayerEventKind,
16};
17use crate::layer_surface_cache::LayerSurfaceCache;
18use crate::lazy_resource::LazyGpuResource;
19#[cfg(test)]
20use crate::normalized_scene::{
21    build_scene_window, collect_layer_contents, collect_layer_contents_with_translation_context,
22    filtered_effect_layer_index, scene_bounds, SceneWindowSource,
23};
24#[cfg(test)]
25use crate::normalized_scene::{estimate_layer_surface_rect, motion_stable_capture_bounds};
26use crate::normalized_scene::{translate_quad, ChildLayerComposite, CollectedLayer};
27use crate::offscreen::OffscreenTarget;
28use crate::rect_to_quad;
29use crate::scene::{
30    BackdropLayer, CompositorScene, DrawOp, DrawOpKind, DrawShape, EffectLayer, ImageDraw,
31    RetainedDraw, ShadowDraw, SimilarityTransform, SnapAnchor, TextDraw,
32};
33use crate::shaders;
34#[cfg(test)]
35use crate::surface_executor::surface_target_size;
36use crate::surface_executor::{
37    apply_backdrop_layer_to_target as execute_apply_backdrop_layer_to_target,
38    axis_aligned_quad_rect, backdrop_underlay_is_covered_by_local_content,
39    canonicalize_device_coordinate, canonicalized_scaled_quad, canonicalized_scaled_rect,
40    composite_surface_to_view as execute_composite_surface_to_view, device_pixel_bounds_for_rect,
41    offscreen_byte_size, render_effect_layer_to_target as execute_render_effect_layer_to_target,
42    render_layer_surface as execute_render_layer_surface,
43    render_root_direct as execute_render_root_direct, root_direct_scene_events_are_supported,
44    scaled_quad, snap_delta_for_anchor, snap_motion_stable_dest_quad,
45    translation_stable_anchored_device_pixel_bounds, DevicePixelBounds, LayerSurfaceTexture,
46    SurfaceExecutionBackend,
47};
48#[cfg(test)]
49use crate::surface_executor::{clamp_effect_surface_scale, visible_layer_rect};
50#[cfg(test)]
51use crate::surface_plan::root_can_render_directly_cached;
52#[cfg(test)]
53use crate::surface_plan::{
54    composite_sample_mode_for_effect_layer, composite_sample_mode_for_requirements,
55    direct_translation, effect_layer_target_scale, layer_contains_descendant_backdrop,
56    layer_surface_requirements, layer_surface_requirements_cached, layer_surface_scale,
57    layer_surface_target_scale, layer_uses_external_backdrop_input, TranslatedContentAxes,
58};
59use crate::surface_plan::{LayerSurfaceRequest, TranslationRenderContext};
60#[cfg(test)]
61use crate::surface_requirements::SurfaceRequirement;
62use crate::surface_requirements::SurfaceRequirementSet;
63use crate::DebugCpuAllocationStats;
64use bytemuck::{Pod, Zeroable};
65#[cfg(any(not(target_arch = "wasm32"), test))]
66use cranpose_core::collections::map::HashMap;
67use cranpose_core::{hash::default as default_hash, NodeId};
68use cranpose_render_common::bounded_lru_cache::BoundedLruCache;
69use cranpose_render_common::geometry::blur_extent_margin;
70use cranpose_render_common::graph::quad_bounds;
71#[cfg(test)]
72use cranpose_render_common::graph::{
73    CachePolicy, LayerNode, PrimitiveEntry, PrimitiveNode, PrimitivePhase, ProjectiveTransform,
74    RenderNode,
75};
76use cranpose_render_common::raster_cache::LayerRasterCacheKey;
77#[cfg(test)]
78use cranpose_render_common::raster_cache::ScaleBucket;
79use cranpose_render_common::software_text_raster::{
80    collect_solid_text_atlas_run, measure_text_with_font,
81    rasterize_annotated_text_to_image_with_glyph_cache, rasterize_text_to_image_with_glyph_cache,
82    SoftwareGlyphAtlasGlyph, SoftwareGlyphAtlasKey, SoftwareGlyphAtlasPlacement,
83    SoftwareGlyphAtlasRunGlyph, SoftwareGlyphRasterCache, SoftwareTextFontSet,
84};
85#[cfg(test)]
86use cranpose_ui_graphics::GraphicsLayer;
87use cranpose_ui_graphics::{
88    BlendMode, Brush, Color, ColorFilter, FxHasher, ImageBitmap, ImageSampling, Point, Rect,
89    RenderEffect, RenderHash, RuntimeShader, StrokeCap, StrokeJoin, TileMode,
90};
91use std::borrow::Cow;
92use std::cell::Cell;
93use std::hash::{Hash, Hasher};
94use std::ops::Range;
95use std::rc::Rc;
96#[cfg(not(target_arch = "wasm32"))]
97use std::sync::atomic::{AtomicUsize, Ordering};
98use std::sync::{mpsc, Arc};
99use std::time::Duration;
100use web_time::Instant;
101
102use crate::gpu_stats;
103use crate::gpu_stats::gpu_stats_enabled;
104use crate::pipeline::push_layer_shadow;
105
106/// Must equal the `array<ShapeData, N>` literal in `shape.wgsl`: on wasm the
107/// shader source is used verbatim, so a larger batch cap here would index past
108/// the declared array. 102 x 160-byte ShapeData = 16320 bytes, the most that
109/// fits WebGL's 16 KiB uniform-binding floor.
110#[cfg(target_arch = "wasm32")]
111const MAX_SHAPES_PER_BATCH: usize = 102;
112#[cfg(not(target_arch = "wasm32"))]
113const MAX_SHAPES_PER_BATCH: usize = 768;
114#[cfg(target_arch = "wasm32")]
115const MAX_GRADIENT_STOPS: usize = 256;
116#[cfg(not(target_arch = "wasm32"))]
117const MAX_GRADIENT_STOPS: usize = 1024;
118
119/// Per-pass ceilings when the shape and gradient arrays live in storage
120/// buffers instead of uniforms. These are not hardware limits — storage
121/// bindings are hundreds of megabytes everywhere — they bound worst-case
122/// buffer growth: 65 536 shapes is a 7 MiB shape buffer and a 12 MiB vertex
123/// buffer, far past any real scene, while still forcing a batch split before
124/// a pathological one can ask for gigabytes.
125#[cfg(not(target_arch = "wasm32"))]
126const MAX_SHAPES_PER_STORAGE_BATCH: usize = 1 << 16;
127#[cfg(not(target_arch = "wasm32"))]
128const MAX_GRADIENT_STOPS_PER_STORAGE_BATCH: usize = 1 << 16;
129
130/// How many shapes/stops the storage-mode buffers start out sized for. In
131/// uniform mode the initial capacity must equal the cap (a uniform binding
132/// smaller than the shader's fixed-length array fails validation), but a
133/// runtime-sized storage array binds at any size, so start small and let
134/// `ensure_capacity` double toward the cap as scenes demand.
135#[cfg(not(target_arch = "wasm32"))]
136const INITIAL_STORAGE_BATCH_CAPACITY: usize = 1024;
137
138/// Shape/gradient batch capacities derived from the actual device limits.
139///
140/// Where storage buffers are available (any real Vulkan/Metal/D3D device, and
141/// GL only when it exposes SSBOs to fragment shaders) the arrays are bound as
142/// read-only storage and a whole scene fits one batch. Otherwise they fall
143/// back to uniform arrays: the compile-time `MAX_*` constants assume
144/// desktop-class 64 KiB uniform bindings, while Android downlevel and
145/// GLES-class devices may only offer the 16 KiB spec minimum; sizing the
146/// buffers (and the matching WGSL array lengths) past
147/// `max_uniform_buffer_binding_size` makes the very first "Shape Bind Group"
148/// fail validation and aborts the app.
149#[derive(Clone, Copy, Debug, Eq, PartialEq)]
150struct ShapeBatchLimits {
151    max_shapes_per_batch: usize,
152    max_gradient_stops: usize,
153    storage: bool,
154}
155
156impl ShapeBatchLimits {
157    fn for_device(device: &wgpu::Device) -> Self {
158        let limits = device.limits();
159        #[cfg(not(target_arch = "wasm32"))]
160        if limits.max_storage_buffers_per_shader_stage >= 2 {
161            return Self::for_storage_binding_size(limits.max_storage_buffer_binding_size);
162        }
163        Self::for_uniform_binding_size(limits.max_uniform_buffer_binding_size)
164    }
165
166    fn for_uniform_binding_size(max_uniform_buffer_binding_size: u64) -> Self {
167        let binding = max_uniform_buffer_binding_size as usize;
168        Self {
169            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
170                .clamp(1, MAX_SHAPES_PER_BATCH),
171            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
172                .clamp(1, MAX_GRADIENT_STOPS),
173            storage: false,
174        }
175    }
176
177    #[cfg(not(target_arch = "wasm32"))]
178    fn for_storage_binding_size(max_storage_buffer_binding_size: u64) -> Self {
179        let binding = max_storage_buffer_binding_size as usize;
180        Self {
181            max_shapes_per_batch: (binding / std::mem::size_of::<ShapeData>())
182                .clamp(1, MAX_SHAPES_PER_STORAGE_BATCH),
183            max_gradient_stops: (binding / std::mem::size_of::<GradientStop>())
184                .clamp(1, MAX_GRADIENT_STOPS_PER_STORAGE_BATCH),
185            storage: true,
186        }
187    }
188
189    fn initial_shape_capacity(&self) -> usize {
190        #[cfg(not(target_arch = "wasm32"))]
191        if self.storage {
192            return self
193                .max_shapes_per_batch
194                .min(INITIAL_STORAGE_BATCH_CAPACITY);
195        }
196        self.max_shapes_per_batch
197    }
198
199    fn initial_gradient_capacity(&self) -> usize {
200        #[cfg(not(target_arch = "wasm32"))]
201        if self.storage {
202            return self.max_gradient_stops.min(INITIAL_STORAGE_BATCH_CAPACITY);
203        }
204        self.max_gradient_stops
205    }
206
207    fn data_buffer_usage(&self) -> wgpu::BufferUsages {
208        if self.storage {
209            wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST
210        } else {
211            wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST
212        }
213    }
214
215    fn data_binding_type(&self) -> wgpu::BufferBindingType {
216        if self.storage {
217            wgpu::BufferBindingType::Storage { read_only: true }
218        } else {
219            wgpu::BufferBindingType::Uniform
220        }
221    }
222
223    #[cfg(test)]
224    fn desktop() -> Self {
225        Self::for_uniform_binding_size(wgpu::Limits::default().max_uniform_buffer_binding_size)
226    }
227}
228#[cfg(target_arch = "wasm32")]
229const HARD_MAX_BUFFER_MB: usize = 64; // Maximum 64MB per buffer (image vertex/index only)
230const MAX_SHADOW_SURFACE_CACHE_ITEMS: usize = 512;
231// Sized for HiDPI: a 4K fractional-scale screen full of shadowed panels needs
232// ~10-15 rasters of 4-12MB each; a 64MB budget made the large entries evict
233// each other every frame during scroll, re-blurring tens of megapixels.
234const MAX_SHADOW_SURFACE_CACHE_BYTES: u64 = 192 * 1024 * 1024;
235const MAX_TEXT_IMAGE_CACHE_ITEMS: usize = 1024;
236const MAX_TEXT_GLYPH_MASK_CACHE_ITEMS: usize = 8192;
237const MAX_TEXT_GLYPH_ATLAS_ITEMS: usize = 8192;
238const MAX_TEXT_GLYPH_RUN_CACHE_ITEMS: usize = 1024;
239#[cfg(not(target_arch = "wasm32"))]
240const MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS: usize = 1024;
241#[cfg(not(target_arch = "wasm32"))]
242const MIN_RETAINED_TEXT_GLYPH_QUADS: usize = 192;
243#[cfg(not(target_arch = "wasm32"))]
244const OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS: f64 = 0.75;
245#[cfg(not(target_arch = "wasm32"))]
246const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES: usize = 2;
247#[cfg(not(target_arch = "wasm32"))]
248const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS: usize = 160;
249#[cfg(not(target_arch = "wasm32"))]
250const MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS: usize = 160;
251/// Side length the glyph atlas starts at, and the one it doubles towards.
252///
253/// The atlas is square and `R8Unorm`, so the maximum is a 16 MiB texture. That
254/// was also the starting size until it became the single largest resource the
255/// renderer allocated: a 454x454 watch face draws a couple of hundred distinct
256/// glyphs and needs well under a megabyte of them, but paid the full 16 MiB at
257/// renderer construction, before a single glyph had been rastered. Starting at
258/// `MIN` and doubling on overflow (see `TextGlyphAtlas::reset`) costs at most
259/// three extra resets for a workload that genuinely needs the large atlas —
260/// which then behaves exactly as the fixed 4096 atlas did — and costs a
261/// text-light screen 256 KiB instead of 16 MiB, permanently.
262const TEXT_GLYPH_ATLAS_MIN_SIZE: u32 = 512;
263const TEXT_GLYPH_ATLAS_MAX_SIZE: u32 = 4096;
264const TEXT_GLYPH_ATLAS_PADDING: u32 = 1;
265const MAX_TEXT_LINE_INDEX_CACHE_ITEMS: usize = 512;
266const MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER: usize = 2;
267const MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES: usize = 128;
268const CACHE_MISS_WARMUP_FRAMES: u8 = 1;
269pub(crate) const CLEAR_COLOR: wgpu::Color = wgpu::Color {
270    r: cranpose_render_common::FRAME_CLEAR_COLOR[0] as f64,
271    g: cranpose_render_common::FRAME_CLEAR_COLOR[1] as f64,
272    b: cranpose_render_common::FRAME_CLEAR_COLOR[2] as f64,
273    a: cranpose_render_common::FRAME_CLEAR_COLOR[3] as f64,
274};
275#[cfg(not(target_arch = "wasm32"))]
276const INITIAL_UPLOAD_BUFFER_BYTES: u64 = 4 * 1024;
277#[cfg(not(target_arch = "wasm32"))]
278const INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS: usize = 128;
279const MAX_TEXTURE_CACHE_ITEMS: usize = 256;
280/// Byte ceiling for `image_texture_cache` (see `CachedImageTexture::bytes`).
281/// Generous enough for a screenful of full-page images plus thumbnails;
282/// small enough that a camera preview stream can never pin gigabytes.
283const MAX_IMAGE_TEXTURE_CACHE_BYTES: usize = 256 * 1024 * 1024;
284const RETAINED_STAGED_UPLOAD_BYTES: usize = 256 * 1024;
285const RETAINED_STAGED_UPLOAD_COPIES: usize = 128;
286pub(crate) const RETAINED_LAYER_REQUIREMENTS_CAPACITY: usize = 512;
287const DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS: f64 = 4.0;
288#[cfg(not(target_arch = "wasm32"))]
289static SEGMENT_DIAG_LINES: AtomicUsize = AtomicUsize::new(0);
290// Reclaim oversized text scratch allocations only after a meaningful 4x collapse
291// from a previously large frame; smaller swings are left alone to avoid churn.
292
293fn wgpu_render_stage_telemetry_threshold_ms() -> Option<f64> {
294    static THRESHOLD_MS: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
295    *THRESHOLD_MS.get_or_init(|| {
296        let explicit = std::env::var("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY_MS")
297            .ok()
298            .and_then(|value| value.parse::<f64>().ok())
299            .filter(|value| value.is_finite() && *value >= 0.0);
300        explicit.or_else(|| {
301            std::env::var_os("CRANPOSE_WGPU_RENDER_STAGE_TELEMETRY")
302                .is_some()
303                .then_some(DEFAULT_WGPU_RENDER_STAGE_TELEMETRY_THRESHOLD_MS)
304        })
305    })
306}
307
308pub(crate) fn instant_ms(start: Instant, end: Instant) -> f64 {
309    end.duration_since(start).as_secs_f64() * 1000.0
310}
311
312pub(crate) fn should_log_wgpu_render_stage(start: Instant, end: Instant) -> Option<f64> {
313    let threshold_ms = wgpu_render_stage_telemetry_threshold_ms()?;
314    let total_ms = instant_ms(start, end);
315    (total_ms >= threshold_ms).then_some(total_ms)
316}
317
318fn admit_layer_surface_cache_miss_impl(
319    key: &LayerRasterCacheKey,
320    observed_scene_range_misses: &mut BoundedLruCache<LayerRasterCacheKey, ()>,
321) -> bool {
322    if !key.is_scene_range() {
323        return true;
324    }
325    if observed_scene_range_misses.contains(key) {
326        return true;
327    }
328    observed_scene_range_misses.put(*key, ());
329    false
330}
331
332#[cfg(test)]
333fn first_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
334    let mut observed_scene_range_misses =
335        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
336    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
337}
338
339#[cfg(test)]
340fn repeated_cache_miss_admission(key: &LayerRasterCacheKey) -> bool {
341    let mut observed_scene_range_misses =
342        BoundedLruCache::with_capacity_at_least_one(MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES);
343    let _ = admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses);
344    admit_layer_surface_cache_miss_impl(key, &mut observed_scene_range_misses)
345}
346
347pub static PRESENTED_FRAMES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
348
349pub fn frames_presented() -> u64 {
350    PRESENTED_FRAMES.load(std::sync::atomic::Ordering::Relaxed)
351}
352
353fn frame_stats_need_warmup_frame(snapshot: &gpu_stats::FrameStatsSnapshot) -> bool {
354    snapshot.layer_cache_misses > 0
355        || snapshot.shadow_shape_cache_misses > 0
356        || snapshot.text_image_cache_misses > 0
357        || snapshot.text_glyph_atlas_misses > 0
358}
359
360fn update_frame_warmup_budget(pending_frames: &mut u8, snapshot: &gpu_stats::FrameStatsSnapshot) {
361    if *pending_frames > 0 {
362        *pending_frames = pending_frames.saturating_sub(1);
363    } else if frame_stats_need_warmup_frame(snapshot) {
364        *pending_frames = CACHE_MISS_WARMUP_FRAMES;
365    }
366}
367
368fn text_atlas_fallback_diag_enabled() -> bool {
369    cranpose_core::env_flag!("CRANPOSE_TEXT_ATLAS_FALLBACK_DIAG")
370}
371
372fn text_glyph_run_diag_enabled() -> bool {
373    cranpose_core::env_flag!("CRANPOSE_TEXT_GLYPH_RUN_DIAG")
374}
375
376fn root_direct_diag_enabled() -> bool {
377    cranpose_core::env_flag!("CRANPOSE_ROOT_DIRECT_DIAG")
378}
379
380fn scene_layer_events_precede_z(scene: &CompositorScene, z_index: usize) -> bool {
381    scene
382        .effect_layers
383        .iter()
384        .any(|layer| layer.z_start < z_index && 0 < layer.z_end)
385        || scene
386            .backdrop_layers
387            .iter()
388            .any(|layer| layer.z_index < z_index)
389}
390
391fn direct_root_child_can_be_replayed_into_later_underlay(child: &ChildLayerComposite) -> bool {
392    child.backdrop.is_none()
393        && !child.has_effect
394        && child.shadow_draws.is_empty()
395        && axis_aligned_quad_rect(child.dest_quad).is_some()
396}
397
398fn rects_overlap(a: Rect, b: Rect) -> bool {
399    let a_right = a.x + a.width;
400    let a_bottom = a.y + a.height;
401    let b_right = b.x + b.width;
402    let b_bottom = b.y + b.height;
403    a.x < b_right && b.x < a_right && a.y < b_bottom && b.y < a_bottom
404}
405
406pub(crate) fn direct_root_child_underlays_are_supported(collected: &CollectedLayer) -> bool {
407    for (child_index, child) in collected.child_layers.iter().enumerate() {
408        if child.backdrop.is_some() {
409            if root_direct_diag_enabled() {
410                log::warn!(
411                    "[root-direct-diag] reject self-backdrop child node={:?}",
412                    child.node_id
413                );
414            }
415            return false;
416        }
417        if child.needs_nested_underlay {
418            let Some(dest_rect) = axis_aligned_quad_rect(child.dest_quad) else {
419                if root_direct_diag_enabled() {
420                    log::warn!(
421                        "[root-direct-diag] reject projective underlay child node={:?}",
422                        child.node_id
423                    );
424                }
425                return false;
426            };
427            let translation_only = (dest_rect.width - child.logical_rect.width).abs() <= 0.001
428                && (dest_rect.height - child.logical_rect.height).abs() <= 0.001;
429            let unsupported_preceding_child_layer = collected.child_layers[..child_index]
430                .iter()
431                .any(|preceding| {
432                    if direct_root_child_can_be_replayed_into_later_underlay(preceding) {
433                        return false;
434                    }
435                    axis_aligned_quad_rect(preceding.dest_quad)
436                        .is_none_or(|preceding_rect| rects_overlap(preceding_rect, dest_rect))
437                });
438            let preceding_scene_events =
439                scene_layer_events_precede_z(&collected.scene, child.z_index);
440            if unsupported_preceding_child_layer || preceding_scene_events || !translation_only {
441                if root_direct_diag_enabled() {
442                    log::warn!(
443                        "[root-direct-diag] reject underlay child node={:?} unsupported_preceding_child_layer={} preceding_scene_events={} translation_only={} dest=({:.1},{:.1},{:.1},{:.1}) logical=({:.1},{:.1},{:.1},{:.1})",
444                        child.node_id,
445                        unsupported_preceding_child_layer,
446                        preceding_scene_events,
447                        translation_only,
448                        dest_rect.x,
449                        dest_rect.y,
450                        dest_rect.width,
451                        dest_rect.height,
452                        child.logical_rect.x,
453                        child.logical_rect.y,
454                        child.logical_rect.width,
455                        child.logical_rect.height
456                    );
457                }
458                return false;
459            }
460        }
461    }
462    true
463}
464
465#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
466struct ShadowSurfaceCacheKey {
467    content_hash: u64,
468    pixel_size: [u32; 2],
469    root_scale_bits: u32,
470    blur_radius_bits: u32,
471}
472
473struct CachedShadowSurface {
474    target: Rc<OffscreenTarget>,
475    byte_size: u64,
476}
477
478struct CachedShadowComposite {
479    source: Rc<OffscreenTarget>,
480    scissor: Option<(u32, u32, u32, u32)>,
481    rounded_mask: Option<RoundedCompositeMask>,
482    dest_viewport: Option<(f32, f32, f32, f32)>,
483}
484
485impl CachedShadowComposite {
486    fn batch_item(&self) -> CompositeBatchItem<'_> {
487        CompositeBatchItem {
488            source: &self.source,
489            alpha: 1.0,
490            scissor: self.scissor,
491            rounded_mask: self.rounded_mask,
492            blend_mode: BlendMode::SrcOver,
493            dest_viewport: self.dest_viewport,
494            source_viewport: None,
495            sample_mode: CompositeSampleMode::Nearest,
496        }
497    }
498}
499
500#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
501struct TextImageCacheKey(u64);
502
503struct CachedTextImage {
504    image: ImageBitmap,
505}
506
507#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
508struct TextGlyphRunCacheKey(u64);
509
510#[derive(Clone, Copy)]
511struct CachedTextGlyphQuad {
512    x: i32,
513    y: i32,
514    width: usize,
515    height: usize,
516    color: (f32, f32, f32, f32),
517    uv: ImageUvRect,
518}
519
520struct CachedTextGlyphRun {
521    glyphs: Rc<[SoftwareGlyphAtlasPlacement]>,
522    quads: Option<Rc<[CachedTextGlyphQuad]>>,
523    atlas_generation: u64,
524}
525
526const TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER: f32 = 2.0;
527
528#[cfg(not(target_arch = "wasm32"))]
529struct CachedGpuTextGlyphRun {
530    vertex_buffer: wgpu::Buffer,
531    index_buffer: wgpu::Buffer,
532    index_count: u32,
533    atlas_generation: u64,
534}
535
536#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
537struct TextLineIndexCacheKey(usize);
538
539struct CachedTextLineIndex {
540    text: std::sync::Weak<cranpose_ui::text::RenderString>,
541    len: usize,
542    starts: Rc<[usize]>,
543}
544
545struct TextLineIndexCache {
546    entries: BoundedLruCache<TextLineIndexCacheKey, CachedTextLineIndex>,
547}
548
549impl TextLineIndexCache {
550    fn new(capacity: usize) -> Self {
551        Self {
552            entries: BoundedLruCache::with_capacity_at_least_one(capacity),
553        }
554    }
555
556    fn line_starts(&mut self, text: &Arc<cranpose_ui::text::RenderString>) -> Rc<[usize]> {
557        let key = TextLineIndexCacheKey(Arc::as_ptr(text) as usize);
558        if let Some(cached) = self.entries.get(&key) {
559            if cached.len == text.text.len()
560                && cached
561                    .text
562                    .upgrade()
563                    .is_some_and(|cached_text| Arc::ptr_eq(&cached_text, text))
564            {
565                return cached.starts.clone();
566            }
567        }
568
569        let starts = Rc::<[usize]>::from(line_start_offsets(text.text.as_str()));
570        self.entries.put(
571            key,
572            CachedTextLineIndex {
573                text: Arc::downgrade(text),
574                len: text.text.len(),
575                starts: starts.clone(),
576            },
577        );
578        starts
579    }
580}
581
582#[derive(Clone, Copy, Debug, PartialEq)]
583struct ShapeShadowSurfacePlan {
584    source_device_bounds: DevicePixelBounds,
585    processing_scissor: Option<(u32, u32, u32, u32)>,
586    pixel_radius: f32,
587}
588
589#[derive(Default)]
590struct RendererWarningState {
591    unsupported_effect_reported: Cell<bool>,
592}
593
594impl RendererWarningState {
595    fn warn_unsupported_effect_once(&self) {
596        if !self.unsupported_effect_reported.replace(true) {
597            log::warn!(
598                "WGPU renderer received an unsupported RenderEffect variant; falling back to passthrough compositing"
599            );
600        }
601    }
602}
603
604fn is_blend_mode_supported(mode: BlendMode) -> bool {
605    matches!(mode, BlendMode::SrcOver | BlendMode::DstOut)
606}
607
608fn blend_state_for_mode(mode: BlendMode) -> wgpu::BlendState {
609    match mode {
610        BlendMode::DstOut => wgpu::BlendState {
611            color: wgpu::BlendComponent {
612                src_factor: wgpu::BlendFactor::Zero,
613                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
614                operation: wgpu::BlendOperation::Add,
615            },
616            alpha: wgpu::BlendComponent {
617                src_factor: wgpu::BlendFactor::Zero,
618                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
619                operation: wgpu::BlendOperation::Add,
620            },
621        },
622        _ => wgpu::BlendState::ALPHA_BLENDING,
623    }
624}
625
626fn supported_blend_mode(mode: BlendMode) -> BlendMode {
627    if is_blend_mode_supported(mode) {
628        return mode;
629    }
630
631    BlendMode::SrcOver
632}
633
634fn direct_shader_composite_viewport(
635    alpha: f32,
636    blend_mode: BlendMode,
637    dest_viewport: Option<(f32, f32, f32, f32)>,
638    sample_mode: CompositeSampleMode,
639    source_size: (u32, u32),
640) -> Option<(f32, f32, f32, f32)> {
641    if alpha != 1.0 || supported_blend_mode(blend_mode) != BlendMode::SrcOver {
642        return None;
643    }
644    let viewport = dest_viewport?;
645    if viewport.2 <= 0.0 || viewport.3 <= 0.0 {
646        return None;
647    }
648    match sample_mode {
649        CompositeSampleMode::Linear | CompositeSampleMode::Nearest => Some(viewport),
650        CompositeSampleMode::Box4
651            if shader_composite_preserves_source_pixel_grid(viewport, source_size) =>
652        {
653            Some(viewport)
654        }
655        CompositeSampleMode::Box4 => None,
656    }
657}
658
659fn shader_composite_preserves_source_pixel_grid(
660    viewport: (f32, f32, f32, f32),
661    source_size: (u32, u32),
662) -> bool {
663    const EPSILON: f32 = 0.01;
664    let (x, y, width, height) = viewport;
665    let (source_width, source_height) = source_size;
666    (x - x.round()).abs() <= EPSILON
667        && (y - y.round()).abs() <= EPSILON
668        && (width - source_width as f32).abs() <= EPSILON
669        && (height - source_height as f32).abs() <= EPSILON
670}
671
672type DirectShaderTailComposite<'a> = (&'a RenderEffect, &'a RuntimeShader, (f32, f32, f32, f32));
673
674fn direct_shader_tail_composite(
675    effect: &RenderEffect,
676    alpha: f32,
677    blend_mode: BlendMode,
678    dest_viewport: Option<(f32, f32, f32, f32)>,
679    sample_mode: CompositeSampleMode,
680    source_size: (u32, u32),
681) -> Option<DirectShaderTailComposite<'_>> {
682    let viewport = direct_shader_composite_viewport(
683        alpha,
684        blend_mode,
685        dest_viewport,
686        sample_mode,
687        source_size,
688    )?;
689    let RenderEffect::Chain { first, second } = effect else {
690        return None;
691    };
692    let RenderEffect::Shader { shader } = second.as_ref() else {
693        return None;
694    };
695    Some((first.as_ref(), shader, viewport))
696}
697
698fn hash_f32_for_cache<H: Hasher>(value: f32, state: &mut H) {
699    value.to_bits().hash(state);
700}
701
702fn hash_text_raster_geometry_for_cache<H: Hasher>(
703    rect: Rect,
704    static_text_motion: bool,
705    state: &mut H,
706) {
707    hash_f32_for_cache(rect.width, state);
708    hash_f32_for_cache(rect.height, state);
709    static_text_motion.hash(state);
710    if !static_text_motion {
711        hash_f32_for_cache(rect.x.fract(), state);
712        hash_f32_for_cache(rect.y.fract(), state);
713    }
714}
715
716fn text_raster_geometry_for_draw(
717    text_draw: &TextDraw,
718    root_scale: f32,
719) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
720    if text_draw.text.is_empty()
721        || text_draw.rect.width <= 0.0
722        || text_draw.rect.height <= 0.0
723        || !root_scale.is_finite()
724        || root_scale <= 0.0
725    {
726        return None;
727    }
728
729    let text_scale = text_draw.scale * root_scale;
730    if !text_scale.is_finite() || text_scale <= 0.0 {
731        return None;
732    }
733
734    let static_text_motion = text_draw
735        .text_style
736        .paragraph_style
737        .text_motion
738        .unwrap_or(cranpose_ui::text::TextMotion::Static)
739        == cranpose_ui::text::TextMotion::Static;
740    let snap_delta = text_draw
741        .snap_anchor
742        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
743        .unwrap_or_default();
744    let logical_rect = text_draw.rect.translate(snap_delta.x, snap_delta.y);
745    // Clips are resolved in scene space from their own layer ancestry. A draw
746    // item's raster snap must never move a fixed ancestor clip.
747    let clip = text_draw.clip;
748    let mut raster_rect = Rect {
749        x: logical_rect.x * root_scale,
750        y: logical_rect.y * root_scale,
751        width: logical_rect.width * root_scale,
752        height: logical_rect.height * root_scale,
753    };
754    if text_draw.snap_anchor.is_some() {
755        raster_rect.x = canonicalize_device_coordinate(raster_rect.x);
756        raster_rect.y = canonicalize_device_coordinate(raster_rect.y);
757    }
758    if static_text_motion {
759        raster_rect.x = raster_rect.x.round();
760        raster_rect.y = raster_rect.y.round();
761    }
762    raster_rect.width = raster_rect.width.ceil().max(1.0);
763    raster_rect.height = raster_rect.height.ceil().max(1.0);
764    Some((
765        logical_rect,
766        raster_rect,
767        clip,
768        text_scale,
769        static_text_motion,
770    ))
771}
772
773fn text_draw_is_visible_in_viewport(
774    logical_rect: Rect,
775    clip: Option<Rect>,
776    viewport: ViewportUniformParams,
777    root_scale: f32,
778) -> bool {
779    draw_rect_is_visible_in_viewport(logical_rect, clip, viewport, root_scale)
780}
781
782fn text_draw_should_prewarm_in_viewport(
783    logical_rect: Rect,
784    clip: Option<Rect>,
785    viewport: ViewportUniformParams,
786    root_scale: f32,
787) -> bool {
788    if !root_scale.is_finite() || root_scale <= 0.0 {
789        return false;
790    }
791    let viewport_rect = Rect {
792        x: viewport.offset[0] / root_scale,
793        y: viewport.offset[1] / root_scale,
794        width: viewport.width as f32 / root_scale,
795        height: viewport.height as f32 / root_scale,
796    };
797    let margin_x = viewport_rect.width * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
798    let margin_y = viewport_rect.height * TEXT_GLYPH_PREWARM_VIEWPORT_MULTIPLIER;
799    let prewarm_viewport = expand_rect(viewport_rect, margin_x, margin_y);
800    let prewarm_rect = match clip {
801        Some(clip) => expand_rect(clip, margin_x, margin_y).intersect(prewarm_viewport),
802        None => Some(prewarm_viewport),
803    };
804    prewarm_rect.is_some_and(|rect| logical_rect.intersect(rect).is_some())
805}
806
807fn expand_rect(rect: Rect, margin_x: f32, margin_y: f32) -> Rect {
808    Rect {
809        x: rect.x - margin_x,
810        y: rect.y - margin_y,
811        width: rect.width + margin_x * 2.0,
812        height: rect.height + margin_y * 2.0,
813    }
814}
815
816fn draw_rect_is_visible_in_viewport(
817    rect: Rect,
818    clip: Option<Rect>,
819    viewport: ViewportUniformParams,
820    root_scale: f32,
821) -> bool {
822    if !root_scale.is_finite() || root_scale <= 0.0 {
823        return false;
824    }
825    let viewport_rect = Rect {
826        x: viewport.offset[0] / root_scale,
827        y: viewport.offset[1] / root_scale,
828        width: viewport.width as f32 / root_scale,
829        height: viewport.height as f32 / root_scale,
830    };
831    let visible_rect = match clip {
832        Some(clip) => clip.intersect(viewport_rect),
833        None => Some(viewport_rect),
834    };
835    visible_rect.is_some_and(|visible| rect.intersect(visible).is_some())
836}
837
838fn shape_draw_is_visible_in_viewport(
839    shape: &DrawShape,
840    viewport: ViewportUniformParams,
841    root_scale: f32,
842) -> bool {
843    let snap_delta = shape
844        .snap_anchor
845        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
846        .unwrap_or_default();
847    let rect = quad_bounds(translate_quad(shape.quad, snap_delta));
848    let clip = shape.clip;
849    draw_rect_is_visible_in_viewport(rect, clip, viewport, root_scale)
850}
851
852fn cached_text_glyph_quad(
853    glyph: &SoftwareGlyphAtlasPlacement,
854    entry: GlyphAtlasEntry,
855    atlas_size: u32,
856) -> CachedTextGlyphQuad {
857    CachedTextGlyphQuad {
858        x: glyph.x,
859        y: glyph.y,
860        width: glyph.width,
861        height: glyph.height,
862        color: (
863            glyph.color.0.clamp(0.0, 1.0),
864            glyph.color.1.clamp(0.0, 1.0),
865            glyph.color.2.clamp(0.0, 1.0),
866            glyph.color.3.clamp(0.0, 1.0),
867        ),
868        uv: glyph_atlas_uv_rect(entry, atlas_size),
869    }
870}
871
872fn append_cached_text_glyph_quad(
873    source_raster_rect: Rect,
874    quad: &CachedTextGlyphQuad,
875    image_vertices: &mut Vec<Vertex>,
876    image_indices: &mut Vec<u32>,
877) -> bool {
878    if quad.width == 0 || quad.height == 0 || quad.color.3 <= 0.0 {
879        return false;
880    }
881
882    let base_vertex = image_vertices.len() as u32;
883    image_indices.extend_from_slice(&[
884        base_vertex,
885        base_vertex + 1,
886        base_vertex + 2,
887        base_vertex + 2,
888        base_vertex + 1,
889        base_vertex + 3,
890    ]);
891
892    let x0 = source_raster_rect.x + quad.x as f32;
893    let y0 = source_raster_rect.y + quad.y as f32;
894    let x1 = x0 + quad.width as f32;
895    let y1 = y0 + quad.height as f32;
896    let color = [quad.color.0, quad.color.1, quad.color.2, quad.color.3];
897
898    image_vertices.extend_from_slice(&[
899        Vertex {
900            position: [x0, y0],
901            color,
902            uv: [quad.uv.min[0], quad.uv.min[1]],
903            uv_bounds: quad.uv.sample_bounds,
904        },
905        Vertex {
906            position: [x1, y0],
907            color,
908            uv: [quad.uv.max[0], quad.uv.min[1]],
909            uv_bounds: quad.uv.sample_bounds,
910        },
911        Vertex {
912            position: [x0, y1],
913            color,
914            uv: [quad.uv.min[0], quad.uv.max[1]],
915            uv_bounds: quad.uv.sample_bounds,
916        },
917        Vertex {
918            position: [x1, y1],
919            color,
920            uv: [quad.uv.max[0], quad.uv.max[1]],
921            uv_bounds: quad.uv.sample_bounds,
922        },
923    ]);
924    true
925}
926
927fn cached_text_glyph_quad_logical_rect(
928    source_raster_rect: Rect,
929    quad: &CachedTextGlyphQuad,
930    root_scale: f32,
931) -> Option<Rect> {
932    if !root_scale.is_finite() || root_scale <= 0.0 {
933        return None;
934    }
935    Some(Rect {
936        x: (source_raster_rect.x + quad.x as f32) / root_scale,
937        y: (source_raster_rect.y + quad.y as f32) / root_scale,
938        width: quad.width as f32 / root_scale,
939        height: quad.height as f32 / root_scale,
940    })
941}
942
943fn cached_text_glyph_quad_is_visible_in_viewport(
944    source_raster_rect: Rect,
945    quad: &CachedTextGlyphQuad,
946    clip: Option<Rect>,
947    viewport: ViewportUniformParams,
948    root_scale: f32,
949) -> bool {
950    cached_text_glyph_quad_logical_rect(source_raster_rect, quad, root_scale)
951        .is_some_and(|rect| draw_rect_is_visible_in_viewport(rect, clip, viewport, root_scale))
952}
953
954#[derive(Clone, Copy, Debug, Eq, PartialEq)]
955enum TextGlyphDrawAction {
956    DrawVisible,
957    PrewarmOffscreen,
958    Skip,
959}
960
961fn text_glyph_draw_action(
962    is_visible: bool,
963    is_prewarm_candidate: bool,
964    allow_offscreen_prewarm: bool,
965) -> TextGlyphDrawAction {
966    if is_visible {
967        TextGlyphDrawAction::DrawVisible
968    } else if allow_offscreen_prewarm && is_prewarm_candidate {
969        TextGlyphDrawAction::PrewarmOffscreen
970    } else {
971        TextGlyphDrawAction::Skip
972    }
973}
974
975#[cfg(not(target_arch = "wasm32"))]
976fn should_use_retained_text_glyph_run(quads_len: usize, clip: Option<Rect>) -> bool {
977    clip.is_none() && quads_len >= MIN_RETAINED_TEXT_GLYPH_QUADS
978}
979
980#[cfg(not(target_arch = "wasm32"))]
981fn offscreen_text_glyph_prewarm_work_is_bounded(
982    cached_glyphs: Option<usize>,
983    text_len: usize,
984) -> bool {
985    match cached_glyphs {
986        Some(glyphs) => glyphs <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS,
987        None => text_len <= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
988    }
989}
990
991#[cfg(not(target_arch = "wasm32"))]
992fn offscreen_text_glyph_prewarm_budget_exhausted(
993    start: Instant,
994    admitted_candidates: usize,
995) -> bool {
996    admitted_candidates >= MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES
997        || instant_ms(start, Instant::now()) >= OFFSCREEN_TEXT_GLYPH_PREWARM_BUDGET_MS
998}
999
1000fn text_draws_for_ordered_range<'a>(
1001    ordered_items: &'a [(usize, SegmentDrawItem)],
1002    texts: &'a [TextDraw],
1003    start: usize,
1004    end: usize,
1005) -> Result<impl Iterator<Item = &'a TextDraw>, String> {
1006    let range_items = ordered_items
1007        .get(start..end)
1008        .ok_or_else(|| format!("text batch range {start}..{end} is outside ordered draw items"))?;
1009    for (_, item) in range_items {
1010        match item {
1011            SegmentDrawItem::Text(text_index) if *text_index < texts.len() => {}
1012            SegmentDrawItem::Text(text_index) => {
1013                return Err(format!(
1014                    "text batch references missing text draw index: {text_index}"
1015                ));
1016            }
1017            _ => return Err(format!("text batch contains non-text draw item: {item:?}")),
1018        }
1019    }
1020
1021    Ok(range_items.iter().filter_map(move |(_, item)| match item {
1022        SegmentDrawItem::Text(text_index) => texts.get(*text_index),
1023        _ => None,
1024    }))
1025}
1026
1027/// Shadow geometry is hashed in device pixels quantized to 1/16 px so rigid
1028/// translations reuse the cached blurred raster. The cached surface is
1029/// composited one-to-one with texel-exact sampling; translation may not change
1030/// either the blur or its sampling phase.
1031const SHADOW_CACHE_DEVICE_QUANT: f32 = 16.0;
1032
1033fn hash_shadow_device_offset<H: Hasher>(value: f32, origin: f32, root_scale: f32, state: &mut H) {
1034    let quantized = ((value - origin) * root_scale * SHADOW_CACHE_DEVICE_QUANT).round();
1035    (quantized as i64).hash(state);
1036}
1037
1038fn hash_shadow_device_rect<H: Hasher>(
1039    rect: Rect,
1040    origin_x: f32,
1041    origin_y: f32,
1042    root_scale: f32,
1043    state: &mut H,
1044) {
1045    hash_shadow_device_offset(rect.x, origin_x, root_scale, state);
1046    hash_shadow_device_offset(rect.y, origin_y, root_scale, state);
1047    hash_shadow_device_offset(rect.width, 0.0, root_scale, state);
1048    hash_shadow_device_offset(rect.height, 0.0, root_scale, state);
1049}
1050
1051fn hash_shape_shadow_item<H: Hasher>(
1052    shape: &DrawShape,
1053    blend_mode: BlendMode,
1054    origin_x: f32,
1055    origin_y: f32,
1056    root_scale: f32,
1057    state: &mut H,
1058) {
1059    hash_shadow_device_rect(shape.rect, origin_x, origin_y, root_scale, state);
1060    hash_shadow_device_rect(shape.local_rect, origin_x, origin_y, root_scale, state);
1061    for point in shape.quad {
1062        hash_shadow_device_offset(point[0], origin_x, root_scale, state);
1063        hash_shadow_device_offset(point[1], origin_y, root_scale, state);
1064    }
1065    match shape.snap_anchor {
1066        Some(anchor) => {
1067            1u8.hash(state);
1068            hash_shadow_device_offset(anchor.origin.x, origin_x, root_scale, state);
1069            hash_shadow_device_offset(anchor.origin.y, origin_y, root_scale, state);
1070            hash_f32_for_cache(anchor.device_pixel_step, state);
1071        }
1072        None => 0u8.hash(state),
1073    }
1074    shape.brush.render_hash().hash(state);
1075    match shape.shape {
1076        Some(corner_shape) => {
1077            1u8.hash(state);
1078            corner_shape.radii().render_hash().hash(state);
1079        }
1080        None => 0u8.hash(state),
1081    }
1082    match shape.clip {
1083        Some(clip) => {
1084            1u8.hash(state);
1085            hash_shadow_device_rect(clip, origin_x, origin_y, root_scale, state);
1086        }
1087        None => 0u8.hash(state),
1088    }
1089    blend_mode.hash(state);
1090    shape.blend_mode.hash(state);
1091}
1092
1093fn shape_shadow_content_hash(shapes: &[(DrawShape, BlendMode)], root_scale: f32) -> u64 {
1094    let mut hasher = FxHasher::default();
1095    // Anchor the hash to the shapes' own (unfloored) bounds so rigid translation
1096    // cancels out exactly. Anchoring to floored device-pixel bounds would leak
1097    // the device subpixel phase into the hash and defeat the cache at
1098    // fractional display scales.
1099    let origin = shape_shadow_bounds(shapes).unwrap_or(Rect {
1100        x: 0.0,
1101        y: 0.0,
1102        width: 0.0,
1103        height: 0.0,
1104    });
1105
1106    shapes.len().hash(&mut hasher);
1107    for (shape, blend_mode) in shapes {
1108        hash_shape_shadow_item(
1109            shape,
1110            *blend_mode,
1111            origin.x,
1112            origin.y,
1113            root_scale,
1114            &mut hasher,
1115        );
1116    }
1117    hasher.finish()
1118}
1119
1120fn shape_shadow_surface_cache_key(
1121    shapes: &[(DrawShape, BlendMode)],
1122    device_bounds: DevicePixelBounds,
1123    pixel_radius: f32,
1124    root_scale: f32,
1125) -> Option<ShadowSurfaceCacheKey> {
1126    (root_scale.is_finite() && root_scale > 0.0).then(|| ShadowSurfaceCacheKey {
1127        content_hash: shape_shadow_content_hash(shapes, root_scale),
1128        pixel_size: [device_bounds.width, device_bounds.height],
1129        root_scale_bits: root_scale.to_bits(),
1130        blur_radius_bits: pixel_radius.to_bits(),
1131    })
1132}
1133
1134fn shape_shadow_bounds(shapes: &[(DrawShape, BlendMode)]) -> Option<Rect> {
1135    shapes
1136        .iter()
1137        .map(|(shape, _)| shape.rect)
1138        .reduce(|a, b| Rect {
1139            x: a.x.min(b.x),
1140            y: a.y.min(b.y),
1141            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1142            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1143        })
1144}
1145
1146fn shared_shape_shadow_snap_anchor(shapes: &[(DrawShape, BlendMode)]) -> Option<SnapAnchor> {
1147    let anchor = shapes.first()?.0.snap_anchor?;
1148    shapes
1149        .iter()
1150        .all(|(shape, _)| shape.snap_anchor == Some(anchor))
1151        .then_some(anchor)
1152}
1153
1154fn shadow_draw_bounds(shadow: &ShadowDraw) -> Option<Rect> {
1155    shadow
1156        .shapes
1157        .iter()
1158        .map(|(shape, _)| shape.rect)
1159        .chain(shadow.texts.iter().map(|text| text.rect))
1160        .reduce(|a, b| Rect {
1161            x: a.x.min(b.x),
1162            y: a.y.min(b.y),
1163            width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
1164            height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
1165        })
1166}
1167
1168fn shadow_draw_may_render(
1169    shadow: &ShadowDraw,
1170    width: u32,
1171    height: u32,
1172    root_scale: f32,
1173    max_texture_dim: u32,
1174) -> bool {
1175    if shadow.texts.is_empty() && !shadow.shapes.is_empty() && shadow.blur_radius > 0.0 {
1176        return shape_shadow_surface_plan(
1177            &shadow.shapes,
1178            shadow.clip,
1179            shadow.blur_radius,
1180            width,
1181            height,
1182            root_scale,
1183            max_texture_dim,
1184        )
1185        .is_some();
1186    }
1187
1188    let Some(bounds) = shadow_draw_bounds(shadow) else {
1189        return false;
1190    };
1191    let blur_margin = blur_extent_margin(shadow.blur_radius);
1192    let mut visible_bounds = Rect {
1193        x: bounds.x - blur_margin,
1194        y: bounds.y - blur_margin,
1195        width: bounds.width + blur_margin * 2.0,
1196        height: bounds.height + blur_margin * 2.0,
1197    };
1198    if let Some(clip) = shadow.clip {
1199        let clip_expanded = Rect {
1200            x: clip.x - blur_margin,
1201            y: clip.y - blur_margin,
1202            width: clip.width + blur_margin * 2.0,
1203            height: clip.height + blur_margin * 2.0,
1204        };
1205        let Some(intersection) = visible_bounds.intersect(clip_expanded) else {
1206            return false;
1207        };
1208        visible_bounds = intersection;
1209    }
1210
1211    scissor_rect_for_rect(visible_bounds, root_scale, width, height).is_some()
1212}
1213
1214fn shape_shadow_surface_plan(
1215    shapes: &[(DrawShape, BlendMode)],
1216    clip: Option<Rect>,
1217    blur_radius: f32,
1218    width: u32,
1219    height: u32,
1220    root_scale: f32,
1221    max_texture_dim: u32,
1222) -> Option<ShapeShadowSurfacePlan> {
1223    let shape_bounds = shape_shadow_bounds(shapes)?;
1224    let blur_margin = blur_extent_margin(blur_radius);
1225    let source_blur_bounds = Rect {
1226        x: shape_bounds.x - blur_margin,
1227        y: shape_bounds.y - blur_margin,
1228        width: shape_bounds.width + blur_margin * 2.0,
1229        height: shape_bounds.height + blur_margin * 2.0,
1230    };
1231
1232    let mut visible_blur_bounds = source_blur_bounds;
1233    if let Some(clip) = clip {
1234        let clip_expanded = Rect {
1235            x: clip.x - blur_margin,
1236            y: clip.y - blur_margin,
1237            width: clip.width + blur_margin * 2.0,
1238            height: clip.height + blur_margin * 2.0,
1239        };
1240        visible_blur_bounds = visible_blur_bounds.intersect(clip_expanded)?;
1241    }
1242
1243    let processing_scissor = scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
1244    processing_scissor?;
1245    let visible_device_bounds =
1246        device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)?;
1247    let source_device_bounds = translation_stable_anchored_device_pixel_bounds(
1248        source_blur_bounds,
1249        shared_shape_shadow_snap_anchor(shapes),
1250        root_scale,
1251        max_texture_dim,
1252    )
1253    .unwrap_or(visible_device_bounds);
1254
1255    Some(ShapeShadowSurfacePlan {
1256        source_device_bounds,
1257        processing_scissor,
1258        pixel_radius: blur_radius * root_scale,
1259    })
1260}
1261
1262fn is_render_effect_supported(effect: &RenderEffect) -> bool {
1263    match effect {
1264        RenderEffect::Blur { .. } => true,
1265        RenderEffect::Offset { .. } => true,
1266        RenderEffect::Shader { .. } => true,
1267        RenderEffect::Chain { first, second } => {
1268            is_render_effect_supported(first) && is_render_effect_supported(second)
1269        }
1270    }
1271}
1272
1273fn resolve_gradient_point(origin: f32, extent: f32, value: f32) -> f32 {
1274    if value.is_finite() {
1275        origin + value
1276    } else if value.is_sign_positive() {
1277        origin + extent
1278    } else {
1279        origin
1280    }
1281}
1282
1283fn gradient_tile_mode_value(tile_mode: TileMode) -> u32 {
1284    match tile_mode {
1285        TileMode::Clamp => 0,
1286        TileMode::Repeated => 1,
1287        TileMode::Mirror => 2,
1288        TileMode::Decal => 3,
1289    }
1290}
1291
1292#[cfg(not(target_arch = "wasm32"))]
1293fn shape_shader_source(batch_limits: ShapeBatchLimits) -> Cow<'static, str> {
1294    // These literals must stay in sync with `shape.wgsl`; a mismatch makes
1295    // the substitution silently no-op and leaves the shader sized for the
1296    // downlevel floor.
1297    if batch_limits.storage {
1298        return Cow::Owned(
1299            shaders::SHADER
1300                .replace(
1301                    "var<uniform> shape_data: array<ShapeData, 102>;",
1302                    "var<storage, read> shape_data: array<ShapeData>;",
1303                )
1304                .replace(
1305                    "var<uniform> gradient_stops: array<GradientStop, 256>;",
1306                    // Also inject the retained-paint array here: one mutable
1307                    // color per shape, read when `similarity.paint_select`
1308                    // is set, so recolor patches upload 16-byte colors
1309                    // instead of whole ShapeData records. The base text
1310                    // never declares it — uniform-mode devices cannot bind
1311                    // storage and never host retained slots.
1312                    "var<storage, read> gradient_stops: array<GradientStop>;\n\n\
1313                     @group(1) @binding(3)\n\
1314                     var<storage, read> paint: array<vec4<f32>>;",
1315                )
1316                .replace(
1317                    "output.color = shape.color;",
1318                    "output.color = \
1319                     select(shape.color, paint[shape_idx], similarity.paint_select > 0.5);",
1320                ),
1321        );
1322    }
1323    Cow::Owned(
1324        shaders::SHADER
1325            .replace(
1326                "array<ShapeData, 102>",
1327                &format!("array<ShapeData, {}>", batch_limits.max_shapes_per_batch),
1328            )
1329            .replace(
1330                "array<GradientStop, 256>",
1331                &format!("array<GradientStop, {}>", batch_limits.max_gradient_stops),
1332            ),
1333    )
1334}
1335
1336#[cfg(target_arch = "wasm32")]
1337fn shape_shader_source(_batch_limits: ShapeBatchLimits) -> Cow<'static, str> {
1338    Cow::Borrowed(shaders::SHADER)
1339}
1340
1341fn create_shape_pipeline(
1342    device: &wgpu::Device,
1343    surface_format: wgpu::TextureFormat,
1344    uniform_layout: &wgpu::BindGroupLayout,
1345    shape_layout: &wgpu::BindGroupLayout,
1346    blend_mode: BlendMode,
1347    batch_limits: ShapeBatchLimits,
1348) -> wgpu::RenderPipeline {
1349    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1350        label: Some("Shape Shader"),
1351        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1352    });
1353
1354    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1355        label: Some("Render Pipeline Layout"),
1356        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1357        immediate_size: 0,
1358    });
1359
1360    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1361        label: Some("Render Pipeline"),
1362        layout: Some(&pipeline_layout),
1363        vertex: wgpu::VertexState {
1364            module: &shader,
1365            entry_point: Some("vs_main"),
1366            compilation_options: wgpu::PipelineCompilationOptions::default(),
1367            // No vertex buffer: `vs_main` pulls quad corners from ShapeData
1368            // by `vertex_index`.
1369            buffers: &[],
1370        },
1371        fragment: Some(wgpu::FragmentState {
1372            module: &shader,
1373            entry_point: Some("fs_main"),
1374            compilation_options: wgpu::PipelineCompilationOptions::default(),
1375            targets: &[Some(wgpu::ColorTargetState {
1376                format: surface_format,
1377                blend: Some(blend_state_for_mode(blend_mode)),
1378                write_mask: wgpu::ColorWrites::ALL,
1379            })],
1380        }),
1381        primitive: wgpu::PrimitiveState {
1382            topology: wgpu::PrimitiveTopology::TriangleList,
1383            strip_index_format: None,
1384            front_face: wgpu::FrontFace::Ccw,
1385            cull_mode: None,
1386            unclipped_depth: false,
1387            polygon_mode: wgpu::PolygonMode::Fill,
1388            conservative: false,
1389        },
1390        depth_stencil: None,
1391        multisample: wgpu::MultisampleState::default(),
1392        multiview_mask: None,
1393        cache: None,
1394    })
1395}
1396
1397/// Storage-mode pipeline for retained slots that captured a conservative arc
1398/// mesh: `vs_mesh` consumes `{position, uv, shape_idx}` vertices instead of
1399/// expanding six corners per shape. Fragment stage, bind group layouts
1400/// (including the dynamic-offset similarity binding and the retained paint
1401/// binding) and the SrcOver blend are exactly the ones the quad-expansion retained
1402/// path uses — only the vertex fetch differs.
1403#[cfg(not(target_arch = "wasm32"))]
1404fn create_mesh_shape_pipeline(
1405    device: &wgpu::Device,
1406    surface_format: wgpu::TextureFormat,
1407    uniform_layout: &wgpu::BindGroupLayout,
1408    shape_layout: &wgpu::BindGroupLayout,
1409    batch_limits: ShapeBatchLimits,
1410) -> wgpu::RenderPipeline {
1411    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1412        label: Some("Shape Mesh Shader"),
1413        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1414    });
1415
1416    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1417        label: Some("Mesh Render Pipeline Layout"),
1418        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1419        immediate_size: 0,
1420    });
1421
1422    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1423        label: Some("Retained Mesh Pipeline"),
1424        layout: Some(&pipeline_layout),
1425        vertex: wgpu::VertexState {
1426            module: &shader,
1427            entry_point: Some("vs_mesh"),
1428            compilation_options: wgpu::PipelineCompilationOptions::default(),
1429            buffers: &[MeshVertex::desc()],
1430        },
1431        fragment: Some(wgpu::FragmentState {
1432            module: &shader,
1433            entry_point: Some("fs_main"),
1434            compilation_options: wgpu::PipelineCompilationOptions::default(),
1435            targets: &[Some(wgpu::ColorTargetState {
1436                format: surface_format,
1437                blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1438                write_mask: wgpu::ColorWrites::ALL,
1439            })],
1440        }),
1441        primitive: wgpu::PrimitiveState {
1442            topology: wgpu::PrimitiveTopology::TriangleList,
1443            strip_index_format: None,
1444            front_face: wgpu::FrontFace::Ccw,
1445            cull_mode: None,
1446            unclipped_depth: false,
1447            polygon_mode: wgpu::PolygonMode::Fill,
1448            conservative: false,
1449        },
1450        depth_stencil: None,
1451        multisample: wgpu::MultisampleState::default(),
1452        multiview_mask: None,
1453        cache: None,
1454    })
1455}
1456
1457/// Storage-mode pipeline for ordinary shape batches drawn as instanced
1458/// indexed quads (`vs_shape_instanced`): four vertex executions per shape
1459/// through the static `[0, 1, 2, 2, 1, 3]` index buffer instead of six
1460/// unindexed corner expansions. Everything but the vertex entry point is
1461/// exactly `create_shape_pipeline` — same fragment stage, same layouts,
1462/// same blend per mode — so a draw-time fallback to `vs_main` (the
1463/// `CRANPOSE_INSTANCED_QUADS=0` kill switch) changes nothing else.
1464#[cfg(not(target_arch = "wasm32"))]
1465fn create_instanced_shape_pipeline(
1466    device: &wgpu::Device,
1467    surface_format: wgpu::TextureFormat,
1468    uniform_layout: &wgpu::BindGroupLayout,
1469    shape_layout: &wgpu::BindGroupLayout,
1470    blend_mode: BlendMode,
1471    batch_limits: ShapeBatchLimits,
1472) -> wgpu::RenderPipeline {
1473    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1474        label: Some("Shape Instanced Shader"),
1475        source: wgpu::ShaderSource::Wgsl(shape_shader_source(batch_limits)),
1476    });
1477
1478    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1479        label: Some("Instanced Render Pipeline Layout"),
1480        bind_group_layouts: &[Some(uniform_layout), Some(shape_layout)],
1481        immediate_size: 0,
1482    });
1483
1484    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1485        label: Some("Instanced Render Pipeline"),
1486        layout: Some(&pipeline_layout),
1487        vertex: wgpu::VertexState {
1488            module: &shader,
1489            entry_point: Some("vs_shape_instanced"),
1490            compilation_options: wgpu::PipelineCompilationOptions::default(),
1491            // No vertex buffer: like `vs_main`, the corners come from
1492            // ShapeData; only the shape index source differs
1493            // (`instance_index` instead of `vertex_index / 6`).
1494            buffers: &[],
1495        },
1496        fragment: Some(wgpu::FragmentState {
1497            module: &shader,
1498            entry_point: Some("fs_main"),
1499            compilation_options: wgpu::PipelineCompilationOptions::default(),
1500            targets: &[Some(wgpu::ColorTargetState {
1501                format: surface_format,
1502                blend: Some(blend_state_for_mode(blend_mode)),
1503                write_mask: wgpu::ColorWrites::ALL,
1504            })],
1505        }),
1506        primitive: wgpu::PrimitiveState {
1507            topology: wgpu::PrimitiveTopology::TriangleList,
1508            strip_index_format: None,
1509            front_face: wgpu::FrontFace::Ccw,
1510            cull_mode: None,
1511            unclipped_depth: false,
1512            polygon_mode: wgpu::PolygonMode::Fill,
1513            conservative: false,
1514        },
1515        depth_stencil: None,
1516        multisample: wgpu::MultisampleState::default(),
1517        multiview_mask: None,
1518        cache: None,
1519    })
1520}
1521
1522fn create_image_pipeline(
1523    device: &wgpu::Device,
1524    surface_format: wgpu::TextureFormat,
1525    uniform_layout: &wgpu::BindGroupLayout,
1526    image_layout: &wgpu::BindGroupLayout,
1527    blend_mode: BlendMode,
1528) -> wgpu::RenderPipeline {
1529    let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1530        label: Some("Image Shader"),
1531        source: wgpu::ShaderSource::Wgsl(shaders::IMAGE_SHADER.into()),
1532    });
1533
1534    let image_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1535        label: Some("Image Pipeline Layout"),
1536        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1537        immediate_size: 0,
1538    });
1539
1540    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1541        label: Some("Image Pipeline"),
1542        layout: Some(&image_pipeline_layout),
1543        vertex: wgpu::VertexState {
1544            module: &image_shader,
1545            entry_point: Some("image_vs_main"),
1546            compilation_options: wgpu::PipelineCompilationOptions::default(),
1547            buffers: &[Vertex::desc()],
1548        },
1549        fragment: Some(wgpu::FragmentState {
1550            module: &image_shader,
1551            entry_point: Some("image_fs_main"),
1552            compilation_options: wgpu::PipelineCompilationOptions::default(),
1553            targets: &[Some(wgpu::ColorTargetState {
1554                format: surface_format,
1555                blend: Some(blend_state_for_mode(blend_mode)),
1556                write_mask: wgpu::ColorWrites::ALL,
1557            })],
1558        }),
1559        primitive: wgpu::PrimitiveState {
1560            topology: wgpu::PrimitiveTopology::TriangleList,
1561            strip_index_format: None,
1562            front_face: wgpu::FrontFace::Ccw,
1563            cull_mode: None,
1564            unclipped_depth: false,
1565            polygon_mode: wgpu::PolygonMode::Fill,
1566            conservative: false,
1567        },
1568        depth_stencil: None,
1569        multisample: wgpu::MultisampleState::default(),
1570        multiview_mask: None,
1571        cache: None,
1572    })
1573}
1574
1575fn create_glyph_atlas_pipeline(
1576    device: &wgpu::Device,
1577    surface_format: wgpu::TextureFormat,
1578    uniform_layout: &wgpu::BindGroupLayout,
1579    image_layout: &wgpu::BindGroupLayout,
1580) -> wgpu::RenderPipeline {
1581    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1582        label: Some("Glyph Atlas Shader"),
1583        source: wgpu::ShaderSource::Wgsl(shaders::GLYPH_ATLAS_SHADER.into()),
1584    });
1585
1586    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1587        label: Some("Glyph Atlas Pipeline Layout"),
1588        bind_group_layouts: &[Some(uniform_layout), Some(image_layout)],
1589        immediate_size: 0,
1590    });
1591
1592    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1593        label: Some("Glyph Atlas Pipeline"),
1594        layout: Some(&pipeline_layout),
1595        vertex: wgpu::VertexState {
1596            module: &shader,
1597            entry_point: Some("glyph_atlas_vs_main"),
1598            compilation_options: wgpu::PipelineCompilationOptions::default(),
1599            buffers: &[Vertex::desc()],
1600        },
1601        fragment: Some(wgpu::FragmentState {
1602            module: &shader,
1603            entry_point: Some("glyph_atlas_fs_main"),
1604            compilation_options: wgpu::PipelineCompilationOptions::default(),
1605            targets: &[Some(wgpu::ColorTargetState {
1606                format: surface_format,
1607                blend: Some(blend_state_for_mode(BlendMode::SrcOver)),
1608                write_mask: wgpu::ColorWrites::ALL,
1609            })],
1610        }),
1611        primitive: wgpu::PrimitiveState {
1612            topology: wgpu::PrimitiveTopology::TriangleList,
1613            strip_index_format: None,
1614            front_face: wgpu::FrontFace::Ccw,
1615            cull_mode: None,
1616            unclipped_depth: false,
1617            polygon_mode: wgpu::PolygonMode::Fill,
1618            conservative: false,
1619        },
1620        depth_stencil: None,
1621        multisample: wgpu::MultisampleState::default(),
1622        multiview_mask: None,
1623        cache: None,
1624    })
1625}
1626
1627#[repr(C)]
1628#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1629struct Vertex {
1630    position: [f32; 2],
1631    color: [f32; 4],
1632    uv: [f32; 2],
1633    uv_bounds: [f32; 4],
1634}
1635
1636impl Vertex {
1637    const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
1638        0 => Float32x2,
1639        1 => Float32x4,
1640        2 => Float32x2,
1641        3 => Float32x4
1642    ];
1643
1644    fn desc() -> wgpu::VertexBufferLayout<'static> {
1645        wgpu::VertexBufferLayout {
1646            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
1647            step_mode: wgpu::VertexStepMode::Vertex,
1648            attributes: &Self::ATTRIBS,
1649        }
1650    }
1651}
1652
1653#[repr(C)]
1654#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1655struct Uniforms {
1656    viewport: [f32; 2],
1657    viewport_offset: [f32; 2],
1658}
1659
1660/// Mirror of `struct ShapeData` in `shape.wgsl`. Field order and sizes must
1661/// match exactly: 10 x 16 bytes = 160 bytes, every member 16-byte aligned as
1662/// the uniform address space requires. The quad corners and vertex color ride
1663/// in here because the shape pipeline has no vertex buffer: the vertex shader
1664/// pulls all six corners of a shape straight from this struct.
1665#[repr(C)]
1666#[derive(Copy, Clone, Debug, Pod, Zeroable)]
1667struct ShapeData {
1668    rect: [f32; 4], // x, y, width, height
1669    /// Rects: top_left, top_right, bottom_left, bottom_right corner radii.
1670    /// Arcs: (sin, cos) of the mid angle and of the half sweep — the shader's
1671    /// per-shape trig, precomputed so `sdf_arc_band` needs none per fragment.
1672    radii: [f32; 4],
1673    gradient_params: [f32; 4], // linear: start.xy,end.xy; radial: center.xy,radius,unused
1674    clip_rect: [f32; 4],       // clip_x, clip_y, clip_width, clip_height (0,0,0,0 = no clip)
1675    /// stroke width, packed flags (see [`pack_shape_flags`]), arc outer radius,
1676    /// arc inner radius. All zero for a plain fill.
1677    stroke_params: [f32; 4],
1678    /// arc center.xy, start angle, sweep angle (radians, 0 = +X, clockwise).
1679    arc_params: [f32; 4],
1680    /// Device-space quad corners 0 (xy) and 1 (zw).
1681    quad01: [f32; 4],
1682    /// Device-space quad corners 2 (xy) and 3 (zw).
1683    quad23: [f32; 4],
1684    /// Vertex color: the solid brush color, or the first gradient stop.
1685    color: [f32; 4],
1686    brush_type: u32,         // 0=solid, 1=linear_gradient, 2=radial_gradient
1687    gradient_start: u32,     // Starting index in gradient buffer
1688    gradient_count: u32,     // Number of gradient stops
1689    gradient_tile_mode: u32, // 0=Clamp, 1=Repeated, 2=Mirror, 3=Decal
1690}
1691
1692/// Shape kinds understood by `shape.wgsl`.
1693const SHAPE_KIND_FILL: u32 = 0;
1694const SHAPE_KIND_STROKE: u32 = 1;
1695const SHAPE_KIND_ARC: u32 = 2;
1696
1697fn stroke_cap_code(cap: StrokeCap) -> u32 {
1698    match cap {
1699        StrokeCap::Butt => 0,
1700        StrokeCap::Round => 1,
1701        StrokeCap::Square => 2,
1702    }
1703}
1704
1705fn stroke_join_code(join: StrokeJoin) -> u32 {
1706    match join {
1707        StrokeJoin::Miter => 0,
1708        StrokeJoin::Round => 1,
1709        StrokeJoin::Bevel => 2,
1710    }
1711}
1712
1713/// Packs kind/cap/join into the single float `ShapeData::stroke_params[1]`.
1714///
1715/// Three 2-bit fields fit in one f32 exactly (integers below 2^24 are exact),
1716/// which keeps `ShapeData` a slot smaller than it would be if each field got
1717/// its own float — batch capacity is set by this size on uniform backends.
1718fn pack_shape_flags(kind: u32, cap: StrokeCap, join: StrokeJoin) -> f32 {
1719    ((kind & 3) | (stroke_cap_code(cap) << 2) | (stroke_join_code(join) << 4)) as f32
1720}
1721
1722/// Whether a batch conversion fans out is decided by measurement — see
1723/// [`crate::cost_tuner::CostTuner`]. The floor of 256 matters: a device
1724/// whose uniform binding caps batches at ~409 shapes never crossed the old
1725/// fixed threshold of 512, so conversion ran serial on exactly the class of
1726/// hardware (watch-grade in-order cores) where fanning out pays most. The
1727/// 400 µs cheap floor keeps a big phone core, which clears such a batch in
1728/// well under that, from ever paying for a spawn wave.
1729#[cfg(not(target_arch = "wasm32"))]
1730static SHAPE_CONVERT_TUNER: crate::cost_tuner::CostTuner =
1731    crate::cost_tuner::CostTuner::new("shape-convert", 256, 400_000);
1732
1733#[cfg(not(target_arch = "wasm32"))]
1734pub(crate) fn shape_convert_worker_count() -> usize {
1735    static WORKERS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
1736    *WORKERS.get_or_init(|| {
1737        let cpus = std::thread::available_parallelism()
1738            .map(|count| count.get())
1739            .unwrap_or(1);
1740        let workers = cpus.clamp(1, 4);
1741        // One line per process: on devices whose scheduler confines the
1742        // process (affinity masks, cpusets), this is the number that
1743        // explains why fan-out stages stayed serial.
1744        log::info!("[shape-convert] fan-out width {workers} (available parallelism {cpus})");
1745        workers
1746    })
1747}
1748
1749#[cfg(target_arch = "wasm32")]
1750pub(crate) fn shape_convert_worker_count() -> usize {
1751    1
1752}
1753
1754fn shape_gradient_stop_count(shape: &DrawShape) -> usize {
1755    match &shape.brush {
1756        Brush::Solid(_) => 0,
1757        Brush::LinearGradient { colors, .. }
1758        | Brush::RadialGradient { colors, .. }
1759        | Brush::SweepGradient { colors, .. } => colors.len(),
1760    }
1761}
1762
1763/// Converts one [`DrawShape`] into its GPU representation, writing into
1764/// pre-sized slots so a batch can convert in parallel across disjoint
1765/// sub-slices. `gradient_start` is the shape's global offset into the batch
1766/// gradient buffer; `gradient_out` is exactly its span of that buffer.
1767fn convert_shape_into_slots(
1768    shape: &DrawShape,
1769    root_scale: f32,
1770    gradient_start: u32,
1771    shape_out: &mut ShapeData,
1772    gradient_out: &mut [GradientStop],
1773) {
1774    let snap_delta = shape
1775        .snap_anchor
1776        .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
1777        .unwrap_or_default();
1778    let local_rect = shape.local_rect.translate(snap_delta.x, snap_delta.y);
1779    let quad = translate_quad(shape.quad, snap_delta);
1780    // Clips are resolved in scene space from their own layer ancestry. A draw
1781    // item's raster snap must never move a fixed ancestor clip.
1782    let clip = shape.clip;
1783    let canonicalize = shape.snap_anchor.is_some();
1784    let device_local_rect = if canonicalize {
1785        canonicalized_scaled_rect(local_rect, root_scale)
1786    } else {
1787        Rect {
1788            x: local_rect.x * root_scale,
1789            y: local_rect.y * root_scale,
1790            width: local_rect.width * root_scale,
1791            height: local_rect.height * root_scale,
1792        }
1793    };
1794    let device_quad = if canonicalize {
1795        canonicalized_scaled_quad(quad, root_scale)
1796    } else {
1797        scaled_quad(quad, root_scale)
1798    };
1799    let canonicalize_brush_coordinate = |value| {
1800        if canonicalize {
1801            canonicalize_device_coordinate(value)
1802        } else {
1803            value
1804        }
1805    };
1806
1807    // Clip rect (scaled to physical pixels)
1808    let clip_rect = if let Some(clip) = clip {
1809        let device_clip = if canonicalize {
1810            canonicalized_scaled_rect(clip, root_scale)
1811        } else {
1812            Rect {
1813                x: clip.x * root_scale,
1814                y: clip.y * root_scale,
1815                width: clip.width * root_scale,
1816                height: clip.height * root_scale,
1817            }
1818        };
1819        [
1820            device_clip.x,
1821            device_clip.y,
1822            device_clip.width,
1823            device_clip.height,
1824        ]
1825    } else {
1826        [0.0, 0.0, 0.0, 0.0]
1827    };
1828
1829    // Gradient parameters
1830    let mut fill_gradient_entries = |colors: &[Color], stops: Option<&[f32]>| {
1831        let count = colors.len();
1832        let explicit_stops = stops.filter(|values| values.len() == count);
1833        for (index, color) in colors.iter().enumerate() {
1834            let position = explicit_stops
1835                .map(|values| values[index])
1836                .unwrap_or_else(|| {
1837                    if count <= 1 {
1838                        0.0
1839                    } else {
1840                        index as f32 / (count - 1) as f32
1841                    }
1842                });
1843            gradient_out[index] = GradientStop {
1844                color: [color.r(), color.g(), color.b(), color.a()],
1845                position: [position, 0.0, 0.0, 0.0],
1846            };
1847        }
1848        count as u32
1849    };
1850    let mut gradient_params = [0.0f32; 4];
1851    let (brush_type, gradient_count, gradient_tile_mode) = match &shape.brush {
1852        Brush::Solid(_) => (0u32, 0u32, gradient_tile_mode_value(TileMode::Clamp)),
1853        Brush::LinearGradient {
1854            colors,
1855            stops,
1856            start,
1857            end,
1858            tile_mode,
1859        } => {
1860            let count = fill_gradient_entries(colors, stops.as_deref());
1861            gradient_params = [
1862                canonicalize_brush_coordinate(resolve_gradient_point(
1863                    device_local_rect.x,
1864                    device_local_rect.width,
1865                    start.x * root_scale,
1866                )),
1867                canonicalize_brush_coordinate(resolve_gradient_point(
1868                    device_local_rect.y,
1869                    device_local_rect.height,
1870                    start.y * root_scale,
1871                )),
1872                canonicalize_brush_coordinate(resolve_gradient_point(
1873                    device_local_rect.x,
1874                    device_local_rect.width,
1875                    end.x * root_scale,
1876                )),
1877                canonicalize_brush_coordinate(resolve_gradient_point(
1878                    device_local_rect.y,
1879                    device_local_rect.height,
1880                    end.y * root_scale,
1881                )),
1882            ];
1883            (1u32, count, gradient_tile_mode_value(*tile_mode))
1884        }
1885        Brush::RadialGradient {
1886            colors,
1887            stops,
1888            center,
1889            radius,
1890            tile_mode,
1891        } => {
1892            let count = fill_gradient_entries(colors, stops.as_deref());
1893            gradient_params = [
1894                canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
1895                canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
1896                (radius * root_scale).max(f32::EPSILON),
1897                0.0,
1898            ];
1899            (2u32, count, gradient_tile_mode_value(*tile_mode))
1900        }
1901        Brush::SweepGradient {
1902            colors,
1903            stops,
1904            center,
1905        } => {
1906            let count = fill_gradient_entries(colors, stops.as_deref());
1907            gradient_params = [
1908                canonicalize_brush_coordinate(device_local_rect.x + center.x * root_scale),
1909                canonicalize_brush_coordinate(device_local_rect.y + center.y * root_scale),
1910                0.0,
1911                0.0,
1912            ];
1913            (3u32, count, gradient_tile_mode_value(TileMode::Clamp))
1914        }
1915    };
1916
1917    // A stroked rect/round-rect was emitted with `local_rect` already
1918    // inflated by half the stroke width, so corner radii must resolve
1919    // against the geometry that was actually asked for, not the
1920    // inflated box. The shader shrinks `half_size` by the same amount.
1921    let stroke_outset = shape
1922        .stroke
1923        .map(|stroke| stroke.half_width())
1924        .unwrap_or(0.0);
1925    let geometry_width = (local_rect.width - stroke_outset * 2.0).max(0.0);
1926    let geometry_height = (local_rect.height - stroke_outset * 2.0).max(0.0);
1927
1928    let radii = if let Some(arc) = shape.arc {
1929        // Arcs never carry corner radii, so this slot ships the shader's
1930        // per-shape trig instead: (sin, cos) of the sweep's mid angle and of
1931        // the half sweep. Computing these here — once per shape — is what
1932        // lets `sdf_arc_band` run without a single transcendental per
1933        // fragment. A full ring is the common case (dots, particles) and
1934        // `ArcGeometry::new` normalizes it to start 0 / sweep TAU, whose
1935        // values are exact constants; the half-sweep sine is pinned to
1936        // non-negative just like the shader used to, so a closed ring keeps
1937        // its seam-free (0, -1) form.
1938        if arc.sweep_angle >= cranpose_ui_graphics::TAU && arc.start_angle == 0.0 {
1939            [0.0, -1.0, 0.0, -1.0]
1940        } else {
1941            let half_sweep = arc.sweep_angle.clamp(0.0, cranpose_ui_graphics::TAU) * 0.5;
1942            let (mid_sin, mid_cos) = (arc.start_angle + half_sweep).sin_cos();
1943            let (half_sin, half_cos) = half_sweep.sin_cos();
1944            [mid_sin, mid_cos, half_sin.max(0.0), half_cos]
1945        }
1946    } else if let Some(rounded) = shape.shape {
1947        let resolved = rounded.resolve(geometry_width, geometry_height);
1948        [
1949            resolved.top_left * root_scale,
1950            resolved.top_right * root_scale,
1951            resolved.bottom_left * root_scale,
1952            resolved.bottom_right * root_scale,
1953        ]
1954    } else {
1955        [0.0, 0.0, 0.0, 0.0]
1956    };
1957
1958    let device_rect = [
1959        device_local_rect.x,
1960        device_local_rect.y,
1961        device_local_rect.width,
1962        device_local_rect.height,
1963    ];
1964
1965    // Stroke/arc parameters ride in the same ShapeData and the same
1966    // pipeline as fills, so a stroked or arc shape never splits a
1967    // batch.
1968    let (stroke_params, arc_params) = match (shape.arc, shape.stroke) {
1969        (Some(arc), _) => (
1970            [
1971                0.0,
1972                pack_shape_flags(SHAPE_KIND_ARC, arc.cap, StrokeJoin::Miter),
1973                arc.outer_radius * root_scale,
1974                arc.inner_radius * root_scale,
1975            ],
1976            [
1977                (arc.center.x + snap_delta.x) * root_scale,
1978                (arc.center.y + snap_delta.y) * root_scale,
1979                arc.start_angle,
1980                arc.sweep_angle,
1981            ],
1982        ),
1983        (None, Some(stroke)) => (
1984            [
1985                stroke.width.max(0.0) * root_scale,
1986                pack_shape_flags(SHAPE_KIND_STROKE, stroke.cap, stroke.join),
1987                0.0,
1988                0.0,
1989            ],
1990            [0.0; 4],
1991        ),
1992        (None, None) => (
1993            [
1994                0.0,
1995                pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
1996                0.0,
1997                0.0,
1998            ],
1999            [0.0; 4],
2000        ),
2001    };
2002
2003    let color = match &shape.brush {
2004        Brush::Solid(c) => [c.r(), c.g(), c.b(), c.a()],
2005        Brush::LinearGradient { colors, .. } => {
2006            let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2007            [first.r(), first.g(), first.b(), first.a()]
2008        }
2009        Brush::RadialGradient { colors, .. } | Brush::SweepGradient { colors, .. } => {
2010            let first = colors.first().unwrap_or(&Color(1.0, 1.0, 1.0, 1.0));
2011            [first.r(), first.g(), first.b(), first.a()]
2012        }
2013    };
2014
2015    *shape_out = ShapeData {
2016        rect: device_rect,
2017        radii,
2018        gradient_params,
2019        clip_rect,
2020        stroke_params,
2021        arc_params,
2022        quad01: [
2023            device_quad[0][0],
2024            device_quad[0][1],
2025            device_quad[1][0],
2026            device_quad[1][1],
2027        ],
2028        quad23: [
2029            device_quad[2][0],
2030            device_quad[2][1],
2031            device_quad[3][0],
2032            device_quad[3][1],
2033        ],
2034        color,
2035        brush_type,
2036        gradient_start,
2037        gradient_count,
2038        gradient_tile_mode,
2039    };
2040}
2041
2042/// `CRANPOSE_QUAD_AREA_DIAG=1` prints, per shape batch, how many device
2043/// pixels the emitted quads cover — split into arc quads, the true arc band
2044/// coverage inside them, and everything else. Fill cost is the product of
2045/// fragment count and shader cost, and this is the fragment-count half: it
2046/// is how the MEGA scene's ~10x overdraw (and the ~50% of arc-quad area that
2047/// the SDF discards) was measured.
2048fn quad_area_diag_enabled() -> bool {
2049    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2050    *ENABLED.get_or_init(|| std::env::var_os("CRANPOSE_QUAD_AREA_DIAG").is_some())
2051}
2052
2053/// Converts a batch of shapes into pre-sized output slices, fanning the work
2054/// across scoped threads when the batch is large enough to pay for spawns.
2055/// The outputs may be scratch vectors or mapped GPU staging memory; each
2056/// shape writes only its own disjoint slots, so chunked `split_at_mut`
2057/// hand-off keeps the parallel path free of any synchronization.
2058fn convert_shapes_into_outputs(
2059    shape_refs: &[&DrawShape],
2060    gradient_offsets: &[u32],
2061    root_scale: f32,
2062    shape_data_out: &mut [ShapeData],
2063    gradients_out: &mut [GradientStop],
2064) {
2065    let shape_count = shape_refs.len();
2066    #[cfg(not(target_arch = "wasm32"))]
2067    let convert_started = Instant::now();
2068    #[cfg(not(target_arch = "wasm32"))]
2069    let parallel =
2070        SHAPE_CONVERT_TUNER.choose_parallel(shape_count) && shape_convert_worker_count() > 1;
2071    if quad_area_diag_enabled() {
2072        let quad_area = |q: [[f32; 2]; 4]| {
2073            // Shoelace over the quad polygon TL, TR, BR, BL (corners 0,1,3,2).
2074            let poly = [q[0], q[1], q[3], q[2]];
2075            let mut twice = 0.0f64;
2076            for i in 0..4 {
2077                let a = poly[i];
2078                let b = poly[(i + 1) % 4];
2079                twice += a[0] as f64 * b[1] as f64 - b[0] as f64 * a[1] as f64;
2080            }
2081            twice.abs() * 0.5
2082        };
2083        let mut arc_quad = 0.0f64; // quad px of arc shapes
2084        let mut arc_band = 0.0f64; // true band coverage of those arcs
2085        let mut arc_count = 0usize;
2086        let mut ring_count = 0usize;
2087        let mut other_quad = 0.0f64;
2088        let mut other_count = 0usize;
2089        // Largest non-arc quads: (area, index) so the tail of the diag can
2090        // name what the aggregate "other" fill actually is.
2091        let mut top_other: Vec<(f64, usize)> = Vec::new();
2092        for (index, shape) in shape_refs.iter().enumerate() {
2093            let area = quad_area(shape.quad);
2094            if let Some(arc) = shape.arc {
2095                arc_quad += area;
2096                arc_count += 1;
2097                if arc.sweep_angle >= cranpose_ui_graphics::TAU {
2098                    ring_count += 1;
2099                }
2100                let ra = arc.mid_radius() as f64;
2101                let rb = arc.half_thickness() as f64;
2102                arc_band +=
2103                    arc.sweep_angle as f64 * ra * (2.0 * rb) + std::f64::consts::PI * rb * rb;
2104            } else {
2105                other_quad += area;
2106                other_count += 1;
2107                top_other.push((area, index));
2108            }
2109        }
2110        let scale2 = (root_scale as f64) * (root_scale as f64);
2111        eprintln!(
2112            "[quad-area] arcs={arc_count} (rings={ring_count}) arc_quad_px={:.0} arc_band_px={:.0} | other={other_count} other_px={:.0}",
2113            arc_quad * scale2,
2114            arc_band * scale2,
2115            other_quad * scale2,
2116        );
2117        top_other.sort_by(|a, b| b.0.total_cmp(&a.0));
2118        for &(area, index) in top_other.iter().take(4) {
2119            let shape = shape_refs[index];
2120            let brush = match &shape.brush {
2121                cranpose_ui_graphics::Brush::Solid(color) => format!("solid a={:.2}", color.3),
2122                cranpose_ui_graphics::Brush::LinearGradient { colors, .. } => {
2123                    format!("linear n={}", colors.len())
2124                }
2125                cranpose_ui_graphics::Brush::RadialGradient { colors, .. } => {
2126                    format!("radial n={}", colors.len())
2127                }
2128                cranpose_ui_graphics::Brush::SweepGradient { colors, .. } => {
2129                    format!("sweep n={}", colors.len())
2130                }
2131            };
2132            eprintln!(
2133                "[quad-area]   top other: {:.0}px {}x{} at ({:.0},{:.0}) {} shape={} stroke={} clip={} blend={:?} z={}",
2134                area * scale2,
2135                shape.rect.width.round(),
2136                shape.rect.height.round(),
2137                shape.rect.x,
2138                shape.rect.y,
2139                brush,
2140                shape.shape.is_some(),
2141                shape.stroke.is_some(),
2142                shape.clip.is_some(),
2143                shape.blend_mode,
2144                shape.z_index,
2145            );
2146        }
2147    }
2148    #[cfg(target_arch = "wasm32")]
2149    let parallel = false;
2150    let workers = if parallel {
2151        shape_convert_worker_count()
2152    } else {
2153        1
2154    };
2155    if workers <= 1 {
2156        for (idx, shape) in shape_refs.iter().enumerate() {
2157            let gradient_start = gradient_offsets[idx];
2158            let gradient_end = gradient_offsets[idx + 1];
2159            convert_shape_into_slots(
2160                shape,
2161                root_scale,
2162                gradient_start,
2163                &mut shape_data_out[idx],
2164                &mut gradients_out[gradient_start as usize..gradient_end as usize],
2165            );
2166        }
2167        #[cfg(not(target_arch = "wasm32"))]
2168        SHAPE_CONVERT_TUNER.record(
2169            false,
2170            shape_count,
2171            convert_started.elapsed().as_nanos() as u64,
2172        );
2173        return;
2174    }
2175
2176    let chunk_len = shape_count.div_ceil(workers);
2177    let mut shape_data_rest = shape_data_out;
2178    let mut gradients_rest = gradients_out;
2179    std::thread::scope(|scope| {
2180        let mut chunk_start = 0usize;
2181        while chunk_start < shape_count {
2182            let chunk_end = (chunk_start + chunk_len).min(shape_count);
2183            let count = chunk_end - chunk_start;
2184            let gradient_base = gradient_offsets[chunk_start];
2185            let gradient_span = (gradient_offsets[chunk_end] - gradient_base) as usize;
2186            let (shape_data_chunk, rest) = std::mem::take(&mut shape_data_rest).split_at_mut(count);
2187            shape_data_rest = rest;
2188            let (gradient_chunk, rest) =
2189                std::mem::take(&mut gradients_rest).split_at_mut(gradient_span);
2190            gradients_rest = rest;
2191            let chunk_refs = &shape_refs[chunk_start..chunk_end];
2192            let chunk_offsets = &gradient_offsets[chunk_start..=chunk_end];
2193            let mut convert_chunk = move || {
2194                for (j, shape) in chunk_refs.iter().enumerate() {
2195                    let gradient_start = chunk_offsets[j];
2196                    let local_start = (gradient_start - gradient_base) as usize;
2197                    let local_end = (chunk_offsets[j + 1] - gradient_base) as usize;
2198                    convert_shape_into_slots(
2199                        shape,
2200                        root_scale,
2201                        gradient_start,
2202                        &mut shape_data_chunk[j],
2203                        &mut gradient_chunk[local_start..local_end],
2204                    );
2205                }
2206            };
2207            if chunk_end == shape_count {
2208                // The caller would only block at the scope join; converting
2209                // the final chunk inline puts that time to work and saves a
2210                // spawn.
2211                convert_chunk();
2212            } else {
2213                scope.spawn(convert_chunk);
2214            }
2215            chunk_start = chunk_end;
2216        }
2217    });
2218    #[cfg(not(target_arch = "wasm32"))]
2219    SHAPE_CONVERT_TUNER.record(
2220        true,
2221        shape_count,
2222        convert_started.elapsed().as_nanos() as u64,
2223    );
2224}
2225
2226#[repr(C)]
2227#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2228struct GradientStop {
2229    color: [f32; 4],
2230    position: [f32; 4],
2231}
2232
2233/// How many replay slots the shared transform buffer holds. Each slot's
2234/// transform lives at `slot * REPLAY_TRANSFORM_STRIDE`, aligned for the
2235/// strictest uniform-offset requirement any backend reports.
2236#[cfg(not(target_arch = "wasm32"))]
2237const MAX_REPLAY_SLOTS: u32 = 128;
2238#[cfg(not(target_arch = "wasm32"))]
2239const REPLAY_TRANSFORM_STRIDE: u64 = 256;
2240
2241/// One retained replay batch: converted shape slots captured on an earlier
2242/// frame, kept on the GPU and re-drawn each frame under the similarity
2243/// transform staged at `transform_offset`.
2244///
2245/// The immutable `ShapeData` and gradient buffers hold no handle here:
2246/// nothing addresses them after capture, and `bind_group` keeps them alive.
2247#[cfg(not(target_arch = "wasm32"))]
2248struct ReplaySlot {
2249    /// One `vec4<f32>` color per shape — the mutable paint the shader reads
2250    /// under `paint_select`, split out so recolor patches upload 16 bytes
2251    /// per shape while the 160-byte `ShapeData` stays immutable on the GPU
2252    /// from capture to release.
2253    paint_buffer: wgpu::Buffer,
2254    bind_group: wgpu::BindGroup,
2255    shape_count: u32,
2256    /// CPU mirror of the paint buffer. Recolor patches apply here first
2257    /// and upload as one contiguous span per slot per frame — MEGA's
2258    /// twinkle field recolors ~1.7k dots a frame, and that many individual
2259    /// copy commands stall a mobile GPU for longer than the spans' extra
2260    /// bytes ever could.
2261    paint_mirror: Vec<[f32; 4]>,
2262    /// Conservative capture-space arc/ring mesh, built once at capture.
2263    /// `None` when the kill switch is off, the slot meshed no arcs, or the
2264    /// vertex budget overflowed — those slots replay through the quad-expansion
2265    /// six-vertices-per-shape path.
2266    mesh: Option<ReplaySlotMesh>,
2267    /// Which capture created this slot's buffers, from the store's global
2268    /// monotone counter. Retained bundle keys carry it so a slot id that is
2269    /// released and recaptured — new bind group, new buffers, same id — can
2270    /// never be drawn through a bundle recorded against the old capture.
2271    capture_epoch: u64,
2272}
2273
2274/// Vertex geometry a retained slot replays instead of per-shape quads: arc
2275/// bands get trapezoid strips covering only their antialiasing footprint,
2276/// every other shape gets a passthrough pair of triangles identical to the
2277/// quad expansion. See [`build_arc_mesh_vertices`].
2278#[cfg(not(target_arch = "wasm32"))]
2279struct ReplaySlotMesh {
2280    vertex_buffer: wgpu::Buffer,
2281    /// `u32` triangle-list indices into `vertex_buffer`: band-boundary
2282    /// vertices are emitted once and shared by both adjacent trapezoids, so
2283    /// per-arc vertex-shader work drops from ~30 executions to the unique
2284    /// boundary vertices (~10-14) — the amplification that made the
2285    /// non-indexed mesh SLOWER than plain quads on the watch's Adreno 702.
2286    index_buffer: wgpu::Buffer,
2287    /// Prefix table, `shape_count + 1` entries: shape `i`'s triangles occupy
2288    /// indices `index_prefix[i]..index_prefix[i + 1]`, so a retained span
2289    /// draws `index_prefix[first]..index_prefix[first + count]` — one
2290    /// `draw_indexed` per op, identical shape order, z untouched.
2291    index_prefix: Vec<u32>,
2292}
2293
2294/// Vertex of a retained slot's conservative arc mesh: capture-device-space
2295/// position, the uv reproducing `vs_main`'s affine rect map at that position,
2296/// and the shape index standing in for `vertex_index / 6`.
2297#[cfg(not(target_arch = "wasm32"))]
2298#[repr(C)]
2299#[derive(Copy, Clone, Debug, Pod, Zeroable)]
2300struct MeshVertex {
2301    position: [f32; 2],
2302    uv: [f32; 2],
2303    shape_idx: u32,
2304}
2305
2306#[cfg(not(target_arch = "wasm32"))]
2307impl MeshVertex {
2308    const ATTRIBS: [wgpu::VertexAttribute; 3] =
2309        wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Uint32];
2310
2311    fn desc() -> wgpu::VertexBufferLayout<'static> {
2312        wgpu::VertexBufferLayout {
2313            array_stride: std::mem::size_of::<MeshVertex>() as wgpu::BufferAddress,
2314            step_mode: wgpu::VertexStepMode::Vertex,
2315            attributes: &Self::ATTRIBS,
2316        }
2317    }
2318}
2319
2320/// Kill switch, mirroring `command_feed_enabled`: default ON,
2321/// `CRANPOSE_ARC_MESH=0` (or the `debug.cranpose.arc_mesh` property on
2322/// Android) makes the next capture skip mesh building entirely, so a device
2323/// A/B needs no rebuild. Read per capture — captures are rare.
2324#[cfg(not(target_arch = "wasm32"))]
2325fn arc_mesh_enabled() -> bool {
2326    // Opt-in (CRANPOSE_ARC_MESH=1 / debug.cranpose.arc_mesh): the Gate 0
2327    // off-charger watch A/B measured the non-indexed mesh 5-7 fps SLOWER
2328    // than plain quads on the Adreno 702 — the 4-6x vertex amplification
2329    // outweighs the fragment savings on a small binning GPU (big desktop
2330    // GPUs and the at-vsync-ceiling Huawei masked it). Default returns to
2331    // quad expansion until indexed band-boundary geometry removes the
2332    // amplification; then the A/B is repeated.
2333    matches!(std::env::var("CRANPOSE_ARC_MESH").as_deref(), Ok(v) if v != "0")
2334}
2335
2336/// Dilation applied to the band's half-thickness before meshing, in capture
2337/// device pixels. The fragment SDF feathers over ±0.5 px
2338/// (`smoothstep(-0.5, 0.5, dist)`), so every pixel the shader keeps sits
2339/// within 0.5 px of the band; the other 0.5 px absorbs f32 slop between this
2340/// builder's trig and the converted shape's precomputed (sin, cos) pairs.
2341#[cfg(not(target_arch = "wasm32"))]
2342const ARC_MESH_MARGIN: f32 = 1.0;
2343
2344/// Chord overshoot budget in pixels: the segment count is chosen so pushing
2345/// outer edges tangent-outside the dilated outer circle overshoots it by
2346/// about this much at the chord ends.
2347#[cfg(not(target_arch = "wasm32"))]
2348const ARC_MESH_OVERSHOOT: f32 = 2.0;
2349
2350#[cfg(not(target_arch = "wasm32"))]
2351const ARC_MESH_MIN_SEGMENTS: usize = 4;
2352#[cfg(not(target_arch = "wasm32"))]
2353const ARC_MESH_MAX_SEGMENTS: usize = 64;
2354
2355/// Per-slot geometry budget in BYTES: 48 vertex-equivalents (~1 KB) per
2356/// shape, floored for tiny slots so a single huge ring still fits. The
2357/// non-indexed mesh spent this entirely on 20-byte vertices; the indexed
2358/// mesh counts vertices AND 4-byte indices against the same byte ceiling,
2359/// which indexed geometry fits with more headroom (MEGA's retained arcs
2360/// drop from ~30 vertices ≈ 600 B to ~12 unique vertices + ~30 indices
2361/// ≈ 360 B). Overflow falls back to whole-slot passthrough WITH a warning —
2362/// truncating silently would break the containment invariant.
2363#[cfg(not(target_arch = "wasm32"))]
2364const ARC_MESH_BUDGET_BYTES_PER_SHAPE: usize = 48 * std::mem::size_of::<MeshVertex>();
2365#[cfg(not(target_arch = "wasm32"))]
2366const ARC_MESH_BUDGET_FLOOR_BYTES: usize = 4096 * std::mem::size_of::<MeshVertex>();
2367
2368/// The budget-relevant size of an indexed mesh: what the GPU buffers will
2369/// actually hold.
2370#[cfg(not(target_arch = "wasm32"))]
2371fn arc_mesh_bytes(vertices: usize, indices: usize) -> usize {
2372    vertices * std::mem::size_of::<MeshVertex>() + indices * std::mem::size_of::<u32>()
2373}
2374
2375/// Band parameters of a captured arc that qualifies for a conservative mesh:
2376/// solid brush, no clip, and a quad that is exactly — tolerance zero — the
2377/// axis-aligned box of its rect. Everything else returns `None` and passes
2378/// through as today's two quad triangles.
2379#[cfg(not(target_arch = "wasm32"))]
2380struct ArcMeshBand {
2381    center: [f32; 2],
2382    inner: f32,
2383    outer: f32,
2384    start: f32,
2385    sweep: f32,
2386}
2387
2388#[cfg(not(target_arch = "wasm32"))]
2389fn arc_mesh_band(shape: &ShapeData) -> Option<ArcMeshBand> {
2390    // Mirror the fragment shader's flag decode (`u32(max(x, 0.0))`).
2391    let flags = shape.stroke_params[1].max(0.0) as u32;
2392    if flags & 3 != SHAPE_KIND_ARC {
2393        return None;
2394    }
2395    // Solid brushes only: gradients also derive from `rect_pos` and would
2396    // mesh in principle, but the hot retained scenes are solid and a narrow
2397    // gate keeps the byte-exactness surface small.
2398    if shape.brush_type != 0 {
2399        return None;
2400    }
2401    // A live clip is a hard `world_pos` comparison in the fragment shader.
2402    // Meshed arcs interpolate `world_pos` across different triangles than
2403    // the quad would, and one ulp of difference at the clip boundary flips
2404    // whole pixels — clipped arcs pass through untouched.
2405    if shape.clip_rect[2] > 0.0 && shape.clip_rect[3] > 0.0 {
2406        return None;
2407    }
2408    let [_, _, w, h] = shape.rect;
2409    if !(w > 0.0 && h > 0.0) {
2410        return None;
2411    }
2412    // The quad must be an axis-aligned box, tolerance zero: the mesh is
2413    // clipped to the quad's own corners, so as long as the quad IS a box its
2414    // rasterized pixel set equals the mesh clip region and the tight-AABB
2415    // tangent-point crop is reproduced exactly. (Comparing against `rect`
2416    // instead is an over-tight gate: under a non-dyadic root scale
2417    // `(x + w) * s` differs from `x * s + w * s` by an ulp and every arc
2418    // fell back to passthrough — observed on the Huawei at scale 2.75.)
2419    let [left, top, right, _] = shape.quad01;
2420    let [bl_x, bottom, br_x, br_y] = shape.quad23;
2421    let axis_aligned = shape.quad01[3] == top
2422        && bl_x == left
2423        && br_x == right
2424        && br_y == bottom
2425        && left < right
2426        && top < bottom;
2427    if !axis_aligned {
2428        return None;
2429    }
2430    let center = [shape.arc_params[0], shape.arc_params[1]];
2431    let start = shape.arc_params[2];
2432    let sweep = shape.arc_params[3];
2433    let outer = shape.stroke_params[2];
2434    let inner = shape.stroke_params[3];
2435    let finite = center[0].is_finite()
2436        && center[1].is_finite()
2437        && start.is_finite()
2438        && sweep.is_finite()
2439        && outer.is_finite()
2440        && inner.is_finite();
2441    if !finite || outer <= 0.0 || sweep <= 0.0 {
2442        return None;
2443    }
2444    Some(ArcMeshBand {
2445        center,
2446        inner,
2447        outer,
2448        start,
2449        sweep,
2450    })
2451}
2452
2453/// Emits the quad `vs_main` would expand for this shape as four shared
2454/// vertices plus the index pattern (0, 1, 2)(2, 1, 3) — the identical corner
2455/// order, corner uvs and positions straight from the captured quad, so a
2456/// passthrough shape rasterizes bit-identically to the quad-expansion
2457/// indexless path while spending four vertex executions instead of six.
2458#[cfg(not(target_arch = "wasm32"))]
2459fn emit_passthrough_quad(
2460    shape: &ShapeData,
2461    shape_idx: u32,
2462    vertices: &mut Vec<MeshVertex>,
2463    indices: &mut Vec<u32>,
2464) {
2465    let base = vertices.len() as u32;
2466    let corners = [
2467        ([shape.quad01[0], shape.quad01[1]], [0.0, 0.0]),
2468        ([shape.quad01[2], shape.quad01[3]], [1.0, 0.0]),
2469        ([shape.quad23[0], shape.quad23[1]], [0.0, 1.0]),
2470        ([shape.quad23[2], shape.quad23[3]], [1.0, 1.0]),
2471    ];
2472    for (position, uv) in corners {
2473        vertices.push(MeshVertex {
2474            position,
2475            uv,
2476            shape_idx,
2477        });
2478    }
2479    indices.extend([0u32, 1, 2, 2, 1, 3].map(|corner| base + corner));
2480}
2481
2482/// One Sutherland–Hodgman pass against an axis-aligned half-plane.
2483///
2484/// Two properties the byte-exactness bar depends on:
2485/// * the clipped coordinate is set to `bound` EXACTLY rather than recomputed
2486///   through `p + t * (q - p)`, so every clipped polygon's boundary lies
2487///   bitwise on the clip line;
2488/// * the intersection is computed on the lexicographically ordered endpoint
2489///   pair, so the shared radial edge of two adjacent trapezoids — traversed
2490///   in opposite directions — clips to bitwise-identical points, keeping the
2491///   strip watertight (no pixel shaded twice or missed along the seam).
2492#[cfg(not(target_arch = "wasm32"))]
2493fn clip_polygon_axis(
2494    input: &[[f32; 2]],
2495    axis: usize,
2496    bound: f32,
2497    keep_at_most: bool,
2498    output: &mut Vec<[f32; 2]>,
2499) {
2500    output.clear();
2501    let inside = |p: [f32; 2]| {
2502        if keep_at_most {
2503            p[axis] <= bound
2504        } else {
2505            p[axis] >= bound
2506        }
2507    };
2508    let intersect = |a: [f32; 2], b: [f32; 2]| {
2509        let (p, q) = if (b[0], b[1]) < (a[0], a[1]) {
2510            (b, a)
2511        } else {
2512            (a, b)
2513        };
2514        let t = (bound - p[axis]) / (q[axis] - p[axis]);
2515        let mut point = [0.0f32; 2];
2516        point[axis] = bound;
2517        point[1 - axis] = p[1 - axis] + t * (q[1 - axis] - p[1 - axis]);
2518        point
2519    };
2520    for (index, &current) in input.iter().enumerate() {
2521        let previous = input[(index + input.len() - 1) % input.len()];
2522        match (inside(previous), inside(current)) {
2523            (true, true) => output.push(current),
2524            (true, false) => output.push(intersect(previous, current)),
2525            (false, true) => {
2526                output.push(intersect(previous, current));
2527                output.push(current);
2528            }
2529            (false, false) => {}
2530        }
2531    }
2532}
2533
2534/// Emits the conservative trapezoid-strip mesh for one qualifying arc band.
2535///
2536/// CONTAINMENT INVARIANT (the byte-exactness bar): the union of emitted
2537/// triangles is a superset of `{ p in the capture quad's box :
2538/// sdf_arc_band(p) <= 0.5 }` — every pixel the fragment shader would keep.
2539/// Over-inclusion is free (the SDF discards those pixels identically to
2540/// today's quad); only under-inclusion can diverge, and
2541/// `arc_mesh_contains_every_band_pixel` checks it never happens.
2542///
2543/// Geometry: outer vertices ride at `Ro / cos(step / 2)` so every chord is
2544/// tangent-outside the dilated outer circle; inner vertices ride at the
2545/// dilated inner radius, whose chords lie inside the hole. Cap coverage is
2546/// bounded by the round-cap disc about the band endpoint (butt/square caps
2547/// only cut that disc with planes — see `sdf_arc_band`), so padding the
2548/// angular range by the disc's angular half-extent contains every cap. Each
2549/// trapezoid is clipped to the quad box and fan-triangulated IN INDEX SPACE:
2550/// a trapezoid the clipper left untouched shares its two boundary vertices
2551/// with each neighbor through the index list (closed rings wrap the sharing
2552/// modulo the boundary count), so the strip is watertight by construction —
2553/// the seam edge is one vertex pair, not two bitwise-equal copies — and the
2554/// per-arc vertex count collapses from three-per-triangle to the unique
2555/// boundary vertices. Clipped trapezoids cannot share boundary vertices (the
2556/// clipper rewrote them), so their fan vertices are appended PRIVATELY after
2557/// the shared block and indexed directly; seams against neighbors still hold
2558/// because a boundary edge either survives the clip on both sides
2559/// bitwise-identically (same input edge, same planes, same float ops — see
2560/// `clip_polygon_axis`) or is cut on both sides identically. Triangles are
2561/// emitted in exact segment order either way, so the indexed mesh's
2562/// primitive stream is triangle-for-triangle the one the non-indexed
2563/// emitter produced.
2564///
2565/// Returns the emitted segment count, or `None` when the mesh came out empty
2566/// — the caller emits the passthrough quad instead (never risk
2567/// under-coverage).
2568#[cfg(not(target_arch = "wasm32"))]
2569fn emit_arc_band_mesh(
2570    shape: &ShapeData,
2571    shape_idx: u32,
2572    band: &ArcMeshBand,
2573    vertices: &mut Vec<MeshVertex>,
2574    indices: &mut Vec<u32>,
2575) -> Option<usize> {
2576    let [cx, cy] = band.center;
2577    let ra = (band.outer + band.inner) * 0.5;
2578    let rb = ((band.outer - band.inner) * 0.5).max(0.0);
2579    let rb_m = rb + ARC_MESH_MARGIN;
2580    let ro = ra + rb_m;
2581    let ri = (ra - rb_m).max(0.0);
2582    let tau = cranpose_ui_graphics::TAU;
2583
2584    let (range_start, range) = if band.sweep >= tau {
2585        (0.0, tau)
2586    } else {
2587        let pad = if rb_m < ra {
2588            (rb_m / ra).asin() + 0.05
2589        } else {
2590            // The cap disc wraps the center; such shapes are tiny, take the
2591            // whole circle.
2592            std::f32::consts::PI
2593        };
2594        let padded = band.sweep + pad + pad;
2595        if padded >= tau {
2596            (0.0, tau)
2597        } else {
2598            (band.start - pad, padded)
2599        }
2600    };
2601    let closed = range >= tau;
2602
2603    let dtheta = (2.0 * (ro / (ro + ARC_MESH_OVERSHOOT)).acos()).clamp(tau / 64.0, tau / 6.0);
2604    let segments =
2605        ((range / dtheta).ceil() as usize).clamp(ARC_MESH_MIN_SEGMENTS, ARC_MESH_MAX_SEGMENTS);
2606    let step = range / segments as f32;
2607    let rc = ro / (step * 0.5).cos();
2608
2609    // Boundary vertices are computed once and shared by both adjacent
2610    // trapezoids: bitwise-equal edge endpoints are what let the rasterizer's
2611    // fill rule shade each seam exactly once.
2612    let boundary_count = if closed { segments } else { segments + 1 };
2613    let mut boundaries = Vec::with_capacity(boundary_count);
2614    for j in 0..boundary_count {
2615        let (sin, cos) = (range_start + step * j as f32).sin_cos();
2616        boundaries.push((
2617            [cx + cos * ri, cy + sin * ri],
2618            [cx + cos * rc, cy + sin * rc],
2619        ));
2620    }
2621
2622    let quad_min = [shape.quad01[0], shape.quad01[1]];
2623    let quad_max = [shape.quad23[2], shape.quad23[3]];
2624
2625    /// One trapezoid's clip outcome (see the function docs): `Shared` means
2626    /// the clip output is bitwise the input quad, so its corners index the
2627    /// shared boundary block; `Fan` carries the clipped polygon for private
2628    /// fan triangulation; `Empty` was clipped away entirely.
2629    enum SegmentGeometry {
2630        Shared,
2631        Fan(Vec<[f32; 2]>),
2632        Empty,
2633    }
2634
2635    // Phase 1: clip every trapezoid and classify it.
2636    let mut polygon: Vec<[f32; 2]> = Vec::with_capacity(8);
2637    let mut scratch: Vec<[f32; 2]> = Vec::with_capacity(8);
2638    let mut segment_geometry = Vec::with_capacity(segments);
2639    let mut boundary_used = vec![false; boundary_count];
2640    for j in 0..segments {
2641        let jb = (j + 1) % boundary_count;
2642        let (inner_a, outer_a) = boundaries[j];
2643        let (inner_b, outer_b) = boundaries[jb];
2644        polygon.clear();
2645        polygon.extend_from_slice(&[inner_a, outer_a, outer_b, inner_b]);
2646        clip_polygon_axis(&polygon, 0, quad_min[0], false, &mut scratch);
2647        clip_polygon_axis(&scratch, 0, quad_max[0], true, &mut polygon);
2648        clip_polygon_axis(&polygon, 1, quad_min[1], false, &mut scratch);
2649        clip_polygon_axis(&scratch, 1, quad_max[1], true, &mut polygon);
2650        // Collapse exact duplicates (an `Ri == 0` pie wedge duplicates the
2651        // center) before fanning.
2652        scratch.clear();
2653        for &point in polygon.iter() {
2654            if scratch.last() != Some(&point) {
2655                scratch.push(point);
2656            }
2657        }
2658        while scratch.len() > 1 && scratch.first() == scratch.last() {
2659            scratch.pop();
2660        }
2661        if scratch.len() < 3 {
2662            segment_geometry.push(SegmentGeometry::Empty);
2663        } else if scratch[..] == [inner_a, outer_a, outer_b, inner_b] {
2664            boundary_used[j] = true;
2665            boundary_used[jb] = true;
2666            segment_geometry.push(SegmentGeometry::Shared);
2667        } else {
2668            segment_geometry.push(SegmentGeometry::Fan(scratch.clone()));
2669        }
2670    }
2671
2672    let push_vertex = |vertices: &mut Vec<MeshVertex>, position: [f32; 2]| -> u32 {
2673        let index = vertices.len() as u32;
2674        vertices.push(MeshVertex {
2675            position,
2676            uv: [
2677                (position[0] - shape.rect[0]) / shape.rect[2],
2678                (position[1] - shape.rect[1]) / shape.rect[3],
2679            ],
2680            shape_idx,
2681        });
2682        index
2683    };
2684
2685    // Shared block: every boundary referenced by a surviving whole trapezoid
2686    // gets its (inner, outer) vertex pair exactly once, in boundary order.
2687    let mut boundary_vertex = vec![[0u32; 2]; boundary_count];
2688    for (j, used) in boundary_used.iter().enumerate() {
2689        if *used {
2690            let (inner, outer) = boundaries[j];
2691            boundary_vertex[j] = [push_vertex(vertices, inner), push_vertex(vertices, outer)];
2692        }
2693    }
2694
2695    // Phase 2: indices in exact segment order — the primitive stream matches
2696    // the non-indexed emitter triangle for triangle.
2697    let start_len = indices.len();
2698    for (j, geometry) in segment_geometry.iter().enumerate() {
2699        match geometry {
2700            SegmentGeometry::Empty => {}
2701            SegmentGeometry::Shared => {
2702                let jb = (j + 1) % boundary_count;
2703                let [in_a, out_a] = boundary_vertex[j];
2704                let [in_b, out_b] = boundary_vertex[jb];
2705                // The fan the non-indexed emitter produced for an untouched
2706                // trapezoid: (in_a, out_a, out_b)(in_a, out_b, in_b) — the
2707                // same quad diagonal.
2708                indices.extend_from_slice(&[in_a, out_a, out_b, in_a, out_b, in_b]);
2709            }
2710            SegmentGeometry::Fan(points) => {
2711                let base = vertices.len() as u32;
2712                for &point in points {
2713                    push_vertex(vertices, point);
2714                }
2715                for i in 1..points.len() as u32 - 1 {
2716                    indices.extend_from_slice(&[base, base + i, base + i + 1]);
2717                }
2718            }
2719        }
2720    }
2721    if indices.len() == start_len {
2722        return None;
2723    }
2724    Some(segments)
2725}
2726
2727/// Unsigned shoelace area of an emitted indexed triangle list, for
2728/// telemetry.
2729#[cfg(not(target_arch = "wasm32"))]
2730fn triangles_shoelace_area(vertices: &[MeshVertex], indices: &[u32]) -> f64 {
2731    indices
2732        .chunks_exact(3)
2733        .map(|tri| {
2734            let [a, b, c] = [
2735                vertices[tri[0] as usize].position,
2736                vertices[tri[1] as usize].position,
2737                vertices[tri[2] as usize].position,
2738            ];
2739            let cross = (b[0] as f64 - a[0] as f64) * (c[1] as f64 - a[1] as f64)
2740                - (b[1] as f64 - a[1] as f64) * (c[0] as f64 - a[0] as f64);
2741            cross.abs() * 0.5
2742        })
2743        .sum()
2744}
2745
2746/// Unsigned area of the two triangles the quad-expansion path would rasterize for
2747/// this shape, for telemetry.
2748#[cfg(not(target_arch = "wasm32"))]
2749fn quad_shoelace_area(shape: &ShapeData) -> f64 {
2750    let corners = [
2751        [shape.quad01[0] as f64, shape.quad01[1] as f64],
2752        [shape.quad01[2] as f64, shape.quad01[3] as f64],
2753        [shape.quad23[0] as f64, shape.quad23[1] as f64],
2754        [shape.quad23[2] as f64, shape.quad23[3] as f64],
2755    ];
2756    let tri = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| {
2757        ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs() * 0.5
2758    };
2759    tri(corners[0], corners[1], corners[2]) + tri(corners[2], corners[1], corners[3])
2760}
2761
2762#[cfg(not(target_arch = "wasm32"))]
2763struct ArcMeshBuild {
2764    vertices: Vec<MeshVertex>,
2765    /// Triangle-list indices into `vertices`; see [`ReplaySlotMesh`].
2766    indices: Vec<u32>,
2767    /// `shape_count + 1` entries; shape `i` owns triangles
2768    /// `indices[index_prefix[i]..index_prefix[i + 1]]`.
2769    index_prefix: Vec<u32>,
2770    meshed_arcs: usize,
2771    meshed_segments: usize,
2772    passthrough: usize,
2773    quad_area: f64,
2774    mesh_area: f64,
2775}
2776
2777/// Builds a slot's conservative indexed mesh: arc bands become
2778/// vertex-sharing trapezoid strips, every other shape a passthrough quad
2779/// (four vertices, six indices), in the exact capture shape order. Returns
2780/// `None` when the byte budget overflows — the caller warns and the whole
2781/// slot replays through the quad-expansion path (silent truncation would
2782/// break the containment invariant).
2783#[cfg(not(target_arch = "wasm32"))]
2784fn build_arc_mesh_vertices(shape_data: &[ShapeData]) -> Option<ArcMeshBuild> {
2785    let budget_bytes =
2786        (shape_data.len() * ARC_MESH_BUDGET_BYTES_PER_SHAPE).max(ARC_MESH_BUDGET_FLOOR_BYTES);
2787    let mut build = ArcMeshBuild {
2788        vertices: Vec::new(),
2789        indices: Vec::new(),
2790        index_prefix: Vec::with_capacity(shape_data.len() + 1),
2791        meshed_arcs: 0,
2792        meshed_segments: 0,
2793        passthrough: 0,
2794        quad_area: 0.0,
2795        mesh_area: 0.0,
2796    };
2797    build.index_prefix.push(0);
2798    for (index, shape) in shape_data.iter().enumerate() {
2799        let start = build.indices.len();
2800        let meshed = arc_mesh_band(shape).and_then(|band| {
2801            emit_arc_band_mesh(
2802                shape,
2803                index as u32,
2804                &band,
2805                &mut build.vertices,
2806                &mut build.indices,
2807            )
2808        });
2809        match meshed {
2810            Some(segments) => {
2811                build.meshed_arcs += 1;
2812                build.meshed_segments += segments;
2813            }
2814            None => {
2815                emit_passthrough_quad(shape, index as u32, &mut build.vertices, &mut build.indices);
2816                build.passthrough += 1;
2817            }
2818        }
2819        if arc_mesh_bytes(build.vertices.len(), build.indices.len()) > budget_bytes {
2820            return None;
2821        }
2822        build.index_prefix.push(build.indices.len() as u32);
2823        build.quad_area += quad_shoelace_area(shape);
2824        build.mesh_area += triangles_shoelace_area(&build.vertices, &build.indices[start..]);
2825    }
2826    Some(build)
2827}
2828
2829/// The renderer's registry of live replay slots. The replay cache (scene
2830/// side) owns slot LIFECYCLE decisions; this store owns the GPU resources.
2831#[cfg(not(target_arch = "wasm32"))]
2832struct ReplaySlotStore {
2833    slots: std::collections::HashMap<u32, ReplaySlot, cranpose_ui_graphics::FxBuildHasher>,
2834    transform_buffer: wgpu::Buffer,
2835    free_ids: Vec<u32>,
2836    /// Global capture counter feeding [`ReplaySlot::capture_epoch`]: bumped
2837    /// on every capture, never reused, so an epoch identifies one capture's
2838    /// buffers for the renderer's whole lifetime.
2839    next_capture_epoch: u64,
2840}
2841
2842#[cfg(not(target_arch = "wasm32"))]
2843impl ReplaySlotStore {
2844    fn new(device: &wgpu::Device) -> Self {
2845        let transform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2846            label: Some("Replay Transform Buffer"),
2847            size: MAX_REPLAY_SLOTS as u64 * REPLAY_TRANSFORM_STRIDE,
2848            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2849            mapped_at_creation: false,
2850        });
2851        Self {
2852            slots: std::collections::HashMap::default(),
2853            transform_buffer,
2854            free_ids: (0..MAX_REPLAY_SLOTS).rev().collect(),
2855            next_capture_epoch: 1,
2856        }
2857    }
2858}
2859
2860/// Kill switch for cached retained render bundles, mirroring
2861/// `command_feed_enabled`: default ON, `CRANPOSE_RETAINED_BUNDLES=0` (or the
2862/// `debug.cranpose.retained_bundles` property on Android) drops the fused
2863/// retained arms back to direct per-op encoding, so a device A/B needs no
2864/// rebuild. Read per partition — the parity harness flips it between passes.
2865#[cfg(not(target_arch = "wasm32"))]
2866fn retained_bundles_enabled() -> bool {
2867    std::env::var("CRANPOSE_RETAINED_BUNDLES").as_deref() != Ok("0")
2868}
2869
2870/// Kill switch for instanced ordinary-shape quads: default ON,
2871/// `CRANPOSE_INSTANCED_QUADS=0` (or the `debug.cranpose.instanced_quads`
2872/// property on Android) reverts every ordinary shape draw to the six-vertex
2873/// `vs_main` expansion. Unlike the per-partition bundle flag this is read
2874/// ONCE per [`GpuRenderer`] construction into a field: cached retained
2875/// bundles encode the selected pipeline, so a flag that moved per draw would
2876/// let a cached bundle replay a selection the direct path no longer makes.
2877#[cfg(not(target_arch = "wasm32"))]
2878fn instanced_quads_enabled() -> bool {
2879    std::env::var("CRANPOSE_INSTANCED_QUADS").as_deref() != Ok("0")
2880}
2881
2882/// The index pattern of one instanced quad: the exact triangle pair
2883/// `vs_main`'s six-slot corner mapping produces — (0, 1, 2)(2, 1, 3), same
2884/// diagonal, same winding — shared by every instance.
2885#[cfg(not(target_arch = "wasm32"))]
2886const INSTANCED_QUAD_INDICES: [u16; 6] = [0, 1, 2, 2, 1, 3];
2887
2888/// The latched instanced-quad selection: `Some` exactly when the renderer
2889/// was constructed in storage mode with [`instanced_quads_enabled`]. Both
2890/// blend variants exist because ordinary batches draw SrcOver and DstOut;
2891/// the `vs_main` pipelines coexist untouched so the `=0` revert (and the
2892/// uniform-mode path) still has its six-vertex draws.
2893#[cfg(not(target_arch = "wasm32"))]
2894struct InstancedQuadPipelines {
2895    pipeline: LazyGpuResource<wgpu::RenderPipeline>,
2896    pipeline_dst_out: LazyGpuResource<wgpu::RenderPipeline>,
2897    /// Static `[0, 1, 2, 2, 1, 3]` u16 index buffer, created once and shared
2898    /// by every instanced draw.
2899    index_buffer: wgpu::Buffer,
2900}
2901
2902/// Everything that decides the commands one retained op contributes to a
2903/// cached bundle. Equal op keys imply identical encoded commands:
2904/// `capture_epoch` pins the slot's bind group and buffers to one capture,
2905/// `has_mesh` pins the pipeline and vertex-buffer choice, `first..last` is
2906/// the clamped draw range, and `retained_index` is the dynamic transform
2907/// offset. Transforms and paints are NOT here — they are data-buffer
2908/// contents the bundle reads at execution.
2909#[cfg(not(target_arch = "wasm32"))]
2910#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2911struct RetainedBundleOpKey {
2912    slot: u32,
2913    /// The slot's capture epoch at key time, `None` while the slot is absent
2914    /// from the store (the op encodes nothing). Epochs are globally unique
2915    /// per capture, so a recaptured slot reusing its id can never satisfy a
2916    /// key recorded against the previous capture's buffers.
2917    capture_epoch: Option<u64>,
2918    first: u32,
2919    last: u32,
2920    retained_index: u32,
2921    has_mesh: bool,
2922}
2923
2924/// Key of one maximal consecutive retained stretch: the op keys in draw
2925/// order. Any reorder, count change, range change, recapture, or slot
2926/// release changes the key and forces a rebuild.
2927#[cfg(not(target_arch = "wasm32"))]
2928#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
2929struct RetainedBundleKey {
2930    ops: Vec<RetainedBundleOpKey>,
2931}
2932
2933#[cfg(not(target_arch = "wasm32"))]
2934struct RetainedBundleCacheEntry<B> {
2935    bundle: B,
2936    last_used_frame: u64,
2937}
2938
2939/// Cache of encoded render bundles for retained stretches, generic over the
2940/// bundle payload so the reuse/invalidation/eviction logic is unit-testable
2941/// without a GPU. The full [`RetainedBundleKey`] is the map key — a fresh
2942/// key can only ever build a fresh bundle, never alias a stale one.
2943///
2944/// The surface format and the group-0 uniform bind group are deliberately
2945/// not part of the key: both are fixed for a `GpuRenderer`'s lifetime (a
2946/// surface reconfigure builds a new renderer, and with it an empty cache).
2947#[cfg(not(target_arch = "wasm32"))]
2948struct RetainedBundleCacheImpl<B> {
2949    entries: HashMap<RetainedBundleKey, RetainedBundleCacheEntry<B>>,
2950    frame: u64,
2951    rebuilds: u64,
2952    cached_executes: u64,
2953    window_rebuilds: u64,
2954    window_executes: u64,
2955}
2956
2957#[cfg(not(target_arch = "wasm32"))]
2958type RetainedBundleCache = RetainedBundleCacheImpl<wgpu::RenderBundle>;
2959
2960#[cfg(not(target_arch = "wasm32"))]
2961impl<B> RetainedBundleCacheImpl<B> {
2962    fn new() -> Self {
2963        Self {
2964            entries: HashMap::default(),
2965            frame: 0,
2966            rebuilds: 0,
2967            cached_executes: 0,
2968            window_rebuilds: 0,
2969            window_executes: 0,
2970        }
2971    }
2972
2973    /// True when a bundle for `key` is cached; marks it used this frame and
2974    /// counts a cached execute.
2975    fn hit(&mut self, key: &RetainedBundleKey) -> bool {
2976        let frame = self.frame;
2977        match self.entries.get_mut(key) {
2978            Some(entry) => {
2979                entry.last_used_frame = frame;
2980                self.cached_executes += 1;
2981                self.window_executes += 1;
2982                true
2983            }
2984            None => false,
2985        }
2986    }
2987
2988    /// Stores a freshly built bundle, counting a rebuild.
2989    fn insert(&mut self, key: RetainedBundleKey, bundle: B) {
2990        self.rebuilds += 1;
2991        self.window_rebuilds += 1;
2992        self.entries.insert(
2993            key,
2994            RetainedBundleCacheEntry {
2995                bundle,
2996                last_used_frame: self.frame,
2997            },
2998        );
2999    }
3000
3001    fn get(&self, key: &RetainedBundleKey) -> Option<&B> {
3002        self.entries.get(key).map(|entry| &entry.bundle)
3003    }
3004
3005    /// Drops every cached bundle. Called whenever a replay slot is released:
3006    /// the key compare already makes stale entries unreachable (their epochs
3007    /// can never recur), so this only releases the dropped capture's GPU
3008    /// resources promptly instead of one frame later via eviction.
3009    fn clear(&mut self) {
3010        self.entries.clear();
3011    }
3012
3013    /// Frame boundary: evicts entries the frame did not use — a bundle
3014    /// holds references on its slot's buffers, so unused entries must not
3015    /// accumulate — and emits the rate-limited rebuild/execute telemetry.
3016    fn end_frame(&mut self) {
3017        let frame = self.frame;
3018        self.entries
3019            .retain(|_, entry| entry.last_used_frame >= frame);
3020        self.frame = self.frame.wrapping_add(1);
3021        // Always-on at a cadence that cannot spam; every perf window (120
3022        // frames) under the replay diagnostics flag so short A/B runs see
3023        // the counts. log::warn because log::info is invisible on the
3024        // desktop console.
3025        let due = self.frame.is_multiple_of(1024)
3026            || (cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG")
3027                && self.frame.is_multiple_of(120));
3028        if due && self.window_rebuilds + self.window_executes > 0 {
3029            log::warn!(
3030                "[retained-bundles] {} stretches, {} rebuilds, {} cached executes ({} live bundles)",
3031                self.window_rebuilds + self.window_executes,
3032                self.window_rebuilds,
3033                self.window_executes,
3034                self.entries.len(),
3035            );
3036            self.window_rebuilds = 0;
3037            self.window_executes = 0;
3038        }
3039    }
3040
3041    /// Lifetime (rebuilds, cached executes) for tests and diagnostics.
3042    fn stats(&self) -> (u64, u64) {
3043        (self.rebuilds, self.cached_executes)
3044    }
3045}
3046
3047struct CachedImageTexture {
3048    _texture: wgpu::Texture,
3049    _view: wgpu::TextureView,
3050    nearest_bind_group: wgpu::BindGroup,
3051    linear_bind_group: wgpu::BindGroup,
3052    /// GPU bytes this entry pins (w×h×4): the cache is bounded by BYTES as
3053    /// well as count. A live camera publishes a new multi-MB bitmap id every
3054    /// frame; 256 count-slots of those is ~1.5GB of dead preview textures —
3055    /// which on iOS unified memory counts straight against the process's
3056    /// jetsam limit (measured: the app died mid-scan under an open camera
3057    /// with exactly that ballast).
3058    bytes: usize,
3059}
3060
3061impl CachedImageTexture {
3062    fn bind_group(&self, sampling: ImageSampling) -> &wgpu::BindGroup {
3063        match sampling {
3064            ImageSampling::Nearest => &self.nearest_bind_group,
3065            ImageSampling::Linear => &self.linear_bind_group,
3066        }
3067    }
3068}
3069
3070#[derive(Clone, Copy)]
3071struct GlyphAtlasEntry {
3072    x: u32,
3073    y: u32,
3074    width: u32,
3075    height: u32,
3076}
3077
3078/// Side length the glyph atlas should be rebuilt at after it overflowed at
3079/// `current`: one doubling, never past `max`.
3080///
3081/// Doubling (rather than jumping straight to `max`) is what makes the atlas
3082/// cost track the workload: an app that overflows once needs a little more
3083/// room, not sixteen times more.
3084fn next_glyph_atlas_size(current: u32, max: u32) -> u32 {
3085    current.saturating_mul(2).clamp(1, max.max(1))
3086}
3087
3088struct TextGlyphAtlas {
3089    texture: wgpu::Texture,
3090    _view: wgpu::TextureView,
3091    bind_group: wgpu::BindGroup,
3092    entries: BoundedLruCache<SoftwareGlyphAtlasKey, GlyphAtlasEntry>,
3093    generation: u64,
3094    /// Side length of `texture`, between `TEXT_GLYPH_ATLAS_MIN_SIZE` and the
3095    /// device's ceiling. Every UV is normalised against it, so it has to travel
3096    /// with the atlas rather than be read back off a constant.
3097    size: u32,
3098    /// Largest side length this atlas may grow to: the smaller of
3099    /// `TEXT_GLYPH_ATLAS_MAX_SIZE` and what the device grants. Mobile devices
3100    /// are requested `downlevel_defaults()` limits raised by `using_resolution`,
3101    /// so a device that only offers 2048 would otherwise fail to create the
3102    /// texture outright.
3103    max_size: u32,
3104    cursor_x: u32,
3105    cursor_y: u32,
3106    row_height: u32,
3107    upload_scratch: Vec<u8>,
3108}
3109
3110impl TextGlyphAtlas {
3111    fn new(
3112        device: &wgpu::Device,
3113        image_layout: &wgpu::BindGroupLayout,
3114        sampler: &wgpu::Sampler,
3115        size: u32,
3116    ) -> Self {
3117        let max_size = TEXT_GLYPH_ATLAS_MAX_SIZE.min(device.limits().max_texture_dimension_2d);
3118        let size = size.clamp(TEXT_GLYPH_ATLAS_MIN_SIZE.min(max_size), max_size);
3119        let texture = Self::create_texture(device, size);
3120        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3121        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3122            label: Some("Text Glyph Atlas Bind Group"),
3123            layout: image_layout,
3124            entries: &[
3125                wgpu::BindGroupEntry {
3126                    binding: 0,
3127                    resource: wgpu::BindingResource::TextureView(&view),
3128                },
3129                wgpu::BindGroupEntry {
3130                    binding: 1,
3131                    resource: wgpu::BindingResource::Sampler(sampler),
3132                },
3133            ],
3134        });
3135        Self {
3136            texture,
3137            _view: view,
3138            bind_group,
3139            entries: BoundedLruCache::with_capacity_at_least_one(MAX_TEXT_GLYPH_ATLAS_ITEMS),
3140            generation: 0,
3141            size,
3142            max_size,
3143            cursor_x: TEXT_GLYPH_ATLAS_PADDING,
3144            cursor_y: TEXT_GLYPH_ATLAS_PADDING,
3145            row_height: 0,
3146            upload_scratch: Vec::new(),
3147        }
3148    }
3149
3150    fn create_texture(device: &wgpu::Device, size: u32) -> wgpu::Texture {
3151        device.create_texture(&wgpu::TextureDescriptor {
3152            label: Some("Text Glyph Atlas Texture"),
3153            size: wgpu::Extent3d {
3154                width: size,
3155                height: size,
3156                depth_or_array_layers: 1,
3157            },
3158            mip_level_count: 1,
3159            sample_count: 1,
3160            dimension: wgpu::TextureDimension::D2,
3161            format: wgpu::TextureFormat::R8Unorm,
3162            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
3163            view_formats: &[],
3164        })
3165    }
3166
3167    /// Throws every cached glyph away and starts over on a texture one doubling
3168    /// larger, up to [`TextGlyphAtlas::max_size`].
3169    ///
3170    /// `allocate` is a one-way shelf cursor with no compaction, so the only
3171    /// recovery from a full atlas is to start again — and starting again at the
3172    /// same size makes a workload whose live glyph set genuinely does not fit
3173    /// re-raster every glyph every frame. Treating each overflow as the signal
3174    /// to double means the atlas converges on the size the workload actually
3175    /// needs: a text-heavy screen reaches the old fixed 4096 after at most three
3176    /// resets and behaves identically from then on, while a watch face that
3177    /// never overflows never pays for space it will not use.
3178    ///
3179    /// Bumping the generation is what invalidates the cached glyph runs, whose
3180    /// UVs are normalised against the previous size and would otherwise sample
3181    /// the wrong part of the new texture.
3182    fn reset(
3183        &mut self,
3184        device: &wgpu::Device,
3185        image_layout: &wgpu::BindGroupLayout,
3186        sampler: &wgpu::Sampler,
3187    ) {
3188        let generation = self.generation.wrapping_add(1);
3189        let grown = next_glyph_atlas_size(self.size, self.max_size);
3190        let mut next = Self::new(device, image_layout, sampler, grown);
3191        next.generation = generation;
3192        *self = next;
3193    }
3194
3195    fn generation(&self) -> u64 {
3196        self.generation
3197    }
3198
3199    fn size(&self) -> u32 {
3200        self.size
3201    }
3202
3203    fn entry(&mut self, key: &SoftwareGlyphAtlasKey) -> Option<GlyphAtlasEntry> {
3204        self.entries.get(key).copied()
3205    }
3206
3207    fn allocate(&mut self, width: u32, height: u32) -> Option<GlyphAtlasEntry> {
3208        if width == 0
3209            || height == 0
3210            || width + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
3211            || height + TEXT_GLYPH_ATLAS_PADDING * 2 > self.size
3212        {
3213            return None;
3214        }
3215
3216        if self.cursor_x + width + TEXT_GLYPH_ATLAS_PADDING > self.size {
3217            self.cursor_x = TEXT_GLYPH_ATLAS_PADDING;
3218            self.cursor_y = self
3219                .cursor_y
3220                .saturating_add(self.row_height)
3221                .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
3222            self.row_height = 0;
3223        }
3224        if self.cursor_y + height + TEXT_GLYPH_ATLAS_PADDING > self.size {
3225            return None;
3226        }
3227
3228        let entry = GlyphAtlasEntry {
3229            x: self.cursor_x,
3230            y: self.cursor_y,
3231            width,
3232            height,
3233        };
3234        self.cursor_x = self
3235            .cursor_x
3236            .saturating_add(width)
3237            .saturating_add(TEXT_GLYPH_ATLAS_PADDING);
3238        self.row_height = self.row_height.max(height);
3239        Some(entry)
3240    }
3241
3242    fn upload_glyph(
3243        &mut self,
3244        key: SoftwareGlyphAtlasKey,
3245        glyph: &SoftwareGlyphAtlasGlyph,
3246        queue: &wgpu::Queue,
3247        executor: &mut WgpuFrameGraphExecutor,
3248        frame_stats: &mut gpu_stats::FrameStats,
3249    ) -> Option<GlyphAtlasEntry> {
3250        if let Some(entry) = self.entry(&key) {
3251            frame_stats.record_text_glyph_atlas_hit();
3252            return Some(entry);
3253        }
3254
3255        let width = u32::try_from(glyph.mask.width).ok()?;
3256        let height = u32::try_from(glyph.mask.height).ok()?;
3257        let entry = self.allocate(width, height)?;
3258        self.upload_scratch.clear();
3259        self.upload_scratch.reserve(
3260            glyph
3261                .mask
3262                .alpha
3263                .len()
3264                .saturating_sub(self.upload_scratch.capacity()),
3265        );
3266        self.upload_scratch.extend(
3267            glyph
3268                .mask
3269                .alpha
3270                .iter()
3271                .map(|alpha| (alpha.clamp(0.0, 1.0) * 255.0).round() as u8),
3272        );
3273
3274        let upload_stats = executor.upload_texture(
3275            queue,
3276            wgpu::TexelCopyTextureInfo {
3277                texture: &self.texture,
3278                mip_level: 0,
3279                origin: wgpu::Origin3d {
3280                    x: entry.x,
3281                    y: entry.y,
3282                    z: 0,
3283                },
3284                aspect: wgpu::TextureAspect::All,
3285            },
3286            &self.upload_scratch,
3287            wgpu::TexelCopyBufferLayout {
3288                offset: 0,
3289                bytes_per_row: Some(entry.width),
3290                rows_per_image: Some(entry.height),
3291            },
3292            wgpu::Extent3d {
3293                width: entry.width,
3294                height: entry.height,
3295                depth_or_array_layers: 1,
3296            },
3297        );
3298        frame_stats.record_command_stats(upload_stats);
3299        frame_stats.record_text_glyph_atlas_miss(entry.width, entry.height);
3300        self.entries.put(key, entry);
3301        Some(entry)
3302    }
3303}
3304
3305struct ImageDrawCmd {
3306    index_start: u32,
3307    scissor: (u32, u32, u32, u32),
3308    image_id: u64,
3309    sampling: ImageSampling,
3310}
3311
3312#[derive(Clone, Copy)]
3313enum GlyphDrawSource {
3314    Shared {
3315        index_start: u32,
3316        index_count: u32,
3317    },
3318    #[cfg(not(target_arch = "wasm32"))]
3319    Retained {
3320        cache_key: TextGlyphRunCacheKey,
3321        uniform_slot: usize,
3322    },
3323}
3324
3325#[derive(Clone, Copy)]
3326struct GlyphDrawCmd {
3327    source: GlyphDrawSource,
3328    scissor: (u32, u32, u32, u32),
3329}
3330
3331impl GlyphDrawCmd {
3332    fn shared(index_start: u32, index_count: u32, scissor: (u32, u32, u32, u32)) -> Self {
3333        Self {
3334            source: GlyphDrawSource::Shared {
3335                index_start,
3336                index_count,
3337            },
3338            scissor,
3339        }
3340    }
3341
3342    #[cfg(not(target_arch = "wasm32"))]
3343    fn retained(
3344        cache_key: TextGlyphRunCacheKey,
3345        uniform_slot: usize,
3346        scissor: (u32, u32, u32, u32),
3347    ) -> Self {
3348        Self {
3349            source: GlyphDrawSource::Retained {
3350                cache_key,
3351                uniform_slot,
3352            },
3353            scissor,
3354        }
3355    }
3356}
3357
3358#[derive(Clone, Copy, Debug, PartialEq)]
3359struct ImageUvRect {
3360    min: [f32; 2],
3361    max: [f32; 2],
3362    sample_bounds: [f32; 4],
3363}
3364
3365// Text raster cache is owned by GpuRenderer and backed by software text images
3366// between measurement and rendering to eliminate duplicate text shaping
3367
3368/// Persistent GPU buffers for batched shape rendering. There is no vertex or
3369/// index buffer: the shape shader pulls quad corners straight out of
3370/// `ShapeData` by `vertex_index`, so the batch is drawn unindexed.
3371struct ShapeBatchBuffers {
3372    shape_buffer: wgpu::Buffer,
3373    gradient_buffer: wgpu::Buffer,
3374    bind_group: wgpu::BindGroup,
3375    shape_capacity: usize,
3376    gradient_capacity: usize,
3377    batch_limits: ShapeBatchLimits,
3378}
3379
3380#[cfg(target_arch = "wasm32")]
3381struct UniformBatchBuffer {
3382    buffer: wgpu::Buffer,
3383    bind_group: wgpu::BindGroup,
3384}
3385
3386#[cfg(target_arch = "wasm32")]
3387struct ImageBatchBuffers {
3388    vertex_buffer: wgpu::Buffer,
3389    index_buffer: wgpu::Buffer,
3390    vertex_capacity: usize,
3391    index_capacity: usize,
3392}
3393
3394#[derive(Clone, Copy, Debug, PartialEq)]
3395struct ViewportUniformParams {
3396    width: u32,
3397    height: u32,
3398    offset: [f32; 2],
3399}
3400
3401#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3402#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
3403enum UploadTarget {
3404    Uniform,
3405    ShapeData,
3406    ShapeGradient,
3407    ImageVertex,
3408    ImageIndex,
3409    #[cfg(not(target_arch = "wasm32"))]
3410    RetainedGlyphUniform,
3411    /// The shared replay-transform buffer; copies land at each slot's fixed
3412    /// 256-byte-aligned offset.
3413    #[cfg(not(target_arch = "wasm32"))]
3414    ReplayTransform,
3415    /// A replay slot's retained paint buffer (color patches land here).
3416    #[cfg(not(target_arch = "wasm32"))]
3417    ReplayPaintData(u32),
3418}
3419
3420#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3421#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
3422struct PendingBufferCopy {
3423    source_offset: u64,
3424    target_offset: u64,
3425    size: u64,
3426    target: UploadTarget,
3427}
3428
3429#[derive(Default)]
3430struct StagedBufferUploads {
3431    bytes: Vec<u8>,
3432    copies: Vec<PendingBufferCopy>,
3433}
3434
3435impl StagedBufferUploads {
3436    fn clear(&mut self) {
3437        self.bytes.clear();
3438        self.copies.clear();
3439    }
3440
3441    fn shrink_retained_capacity(&mut self, max_bytes: usize, max_copies: usize) -> bool {
3442        let mut shrunk = false;
3443        if self.bytes.len() <= max_bytes && self.bytes.capacity() > max_bytes {
3444            self.bytes.shrink_to(max_bytes);
3445            shrunk = true;
3446        }
3447        if self.copies.len() <= max_copies && self.copies.capacity() > max_copies {
3448            self.copies.shrink_to(max_copies);
3449            shrunk = true;
3450        }
3451        shrunk
3452    }
3453
3454    fn is_empty(&self) -> bool {
3455        self.copies.is_empty()
3456    }
3457
3458    #[cfg(test)]
3459    fn payload_for_copy(&self, copy: PendingBufferCopy) -> &[u8] {
3460        let start = copy.source_offset as usize;
3461        let end = start + copy.size as usize;
3462        &self.bytes[start..end]
3463    }
3464
3465    #[cfg(not(target_arch = "wasm32"))]
3466    fn stage(&mut self, target: UploadTarget, bytes: &[u8]) {
3467        self.stage_at(target, 0, bytes);
3468    }
3469
3470    /// Records a GPU copy whose source bytes were already written into the
3471    /// frame upload buffer (via `Queue::write_buffer_with`), so nothing is
3472    /// appended to `bytes`. `source_offset` is relative to the same base the
3473    /// caller later passes to `flush_staged_uploads_at`.
3474    #[cfg(not(target_arch = "wasm32"))]
3475    fn record_upload_copy(
3476        &mut self,
3477        target: UploadTarget,
3478        source_offset: u64,
3479        target_offset: u64,
3480        size: u64,
3481    ) {
3482        if size == 0 {
3483            return;
3484        }
3485        self.copies.push(PendingBufferCopy {
3486            source_offset,
3487            target_offset,
3488            size,
3489            target,
3490        });
3491    }
3492
3493    #[cfg(not(target_arch = "wasm32"))]
3494    fn stage_at(&mut self, target: UploadTarget, target_offset: u64, bytes: &[u8]) {
3495        if bytes.is_empty() {
3496            return;
3497        }
3498
3499        debug_assert_eq!(
3500            bytes.len() % wgpu::COPY_BUFFER_ALIGNMENT as usize,
3501            0,
3502            "buffer uploads must be aligned to copy requirements"
3503        );
3504
3505        let aligned_offset = align_usize_to(self.bytes.len(), wgpu::COPY_BUFFER_ALIGNMENT as usize);
3506        if aligned_offset > self.bytes.len() {
3507            self.bytes.resize(aligned_offset, 0);
3508        }
3509
3510        let source_offset = self.bytes.len() as u64;
3511        self.bytes.extend_from_slice(bytes);
3512        self.copies.push(PendingBufferCopy {
3513            source_offset,
3514            target_offset,
3515            size: bytes.len() as u64,
3516            target,
3517        });
3518    }
3519
3520    fn truncate(&mut self, bytes_len: usize, copies_len: usize) {
3521        self.bytes.truncate(bytes_len);
3522        self.copies.truncate(copies_len);
3523    }
3524}
3525
3526/// The fresh-batch entry list for the shape bind group layout: the batch's
3527/// own data buffers, the shared identity similarity buffer, and — storage
3528/// mode only, where the layout carries the paint entry — the renderer-wide
3529/// dummy paint buffer (fresh draws leave `paint_select` at 0.0).
3530fn shape_batch_bind_group_entries<'a>(
3531    shape_buffer: &'a wgpu::Buffer,
3532    gradient_buffer: &'a wgpu::Buffer,
3533    similarity_buffer: &'a wgpu::Buffer,
3534    paint_buffer: Option<&'a wgpu::Buffer>,
3535) -> Vec<wgpu::BindGroupEntry<'a>> {
3536    let mut entries = vec![
3537        wgpu::BindGroupEntry {
3538            binding: 0,
3539            resource: shape_buffer.as_entire_binding(),
3540        },
3541        wgpu::BindGroupEntry {
3542            binding: 1,
3543            resource: gradient_buffer.as_entire_binding(),
3544        },
3545        wgpu::BindGroupEntry {
3546            binding: 2,
3547            resource: similarity_buffer.as_entire_binding(),
3548        },
3549    ];
3550    if let Some(paint_buffer) = paint_buffer {
3551        entries.push(wgpu::BindGroupEntry {
3552            binding: 3,
3553            resource: paint_buffer.as_entire_binding(),
3554        });
3555    }
3556    entries
3557}
3558
3559impl ShapeBatchBuffers {
3560    fn new(
3561        device: &wgpu::Device,
3562        bind_group_layout: &wgpu::BindGroupLayout,
3563        similarity_buffer: &wgpu::Buffer,
3564        paint_buffer: Option<&wgpu::Buffer>,
3565        batch_limits: ShapeBatchLimits,
3566    ) -> Self {
3567        debug_assert_eq!(
3568            paint_buffer.is_some(),
3569            batch_limits.storage,
3570            "the paint binding exists exactly when the layout is in storage mode"
3571        );
3572        let initial_shape_cap = batch_limits.initial_shape_capacity();
3573        let initial_gradient_cap = batch_limits.initial_gradient_capacity();
3574
3575        let shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3576            label: Some("Shape Data Buffer"),
3577            size: (std::mem::size_of::<ShapeData>() * initial_shape_cap) as u64,
3578            usage: batch_limits.data_buffer_usage(),
3579            mapped_at_creation: false,
3580        });
3581
3582        let gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3583            label: Some("Gradient Buffer"),
3584            size: (std::mem::size_of::<GradientStop>() * initial_gradient_cap) as u64,
3585            usage: batch_limits.data_buffer_usage(),
3586            mapped_at_creation: false,
3587        });
3588
3589        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3590            label: Some("Shape Bind Group"),
3591            layout: bind_group_layout,
3592            entries: &shape_batch_bind_group_entries(
3593                &shape_buffer,
3594                &gradient_buffer,
3595                similarity_buffer,
3596                paint_buffer,
3597            ),
3598        });
3599
3600        Self {
3601            shape_buffer,
3602            gradient_buffer,
3603            bind_group,
3604            shape_capacity: initial_shape_cap,
3605            gradient_capacity: initial_gradient_cap,
3606            batch_limits,
3607        }
3608    }
3609
3610    /// Ensure buffers have enough capacity, resizing if needed.
3611    /// Clamps growth to prevent excessive allocations for huge scenes.
3612    fn ensure_capacity(
3613        &mut self,
3614        device: &wgpu::Device,
3615        bind_group_layout: &wgpu::BindGroupLayout,
3616        similarity_buffer: &wgpu::Buffer,
3617        paint_buffer: Option<&wgpu::Buffer>,
3618        shapes_needed: usize,
3619        gradients_needed: usize,
3620    ) {
3621        let mut need_bind_group_update = false;
3622
3623        // In uniform mode the shape and gradient buffers start at the cap
3624        // (the shader's fixed-size array length) so these never fire; in
3625        // storage mode they double toward the cap as scenes demand.
3626        if shapes_needed > self.shape_capacity
3627            && self.shape_capacity < self.batch_limits.max_shapes_per_batch
3628        {
3629            let new_cap = shapes_needed
3630                .next_power_of_two()
3631                .min(self.batch_limits.max_shapes_per_batch);
3632            self.shape_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3633                label: Some("Shape Data Buffer"),
3634                size: (std::mem::size_of::<ShapeData>() * new_cap) as u64,
3635                usage: self.batch_limits.data_buffer_usage(),
3636                mapped_at_creation: false,
3637            });
3638            self.shape_capacity = new_cap;
3639            need_bind_group_update = true;
3640        }
3641
3642        if gradients_needed > self.gradient_capacity
3643            && self.gradient_capacity < self.batch_limits.max_gradient_stops
3644        {
3645            let new_cap = gradients_needed
3646                .max(1)
3647                .next_power_of_two()
3648                .min(self.batch_limits.max_gradient_stops);
3649            self.gradient_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3650                label: Some("Gradient Buffer"),
3651                size: (std::mem::size_of::<GradientStop>() * new_cap) as u64,
3652                usage: self.batch_limits.data_buffer_usage(),
3653                mapped_at_creation: false,
3654            });
3655            self.gradient_capacity = new_cap;
3656            need_bind_group_update = true;
3657        }
3658
3659        if need_bind_group_update {
3660            self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3661                label: Some("Shape Bind Group"),
3662                layout: bind_group_layout,
3663                entries: &shape_batch_bind_group_entries(
3664                    &self.shape_buffer,
3665                    &self.gradient_buffer,
3666                    similarity_buffer,
3667                    paint_buffer,
3668                ),
3669            });
3670        }
3671    }
3672}
3673
3674#[cfg(target_arch = "wasm32")]
3675impl UniformBatchBuffer {
3676    fn new(device: &wgpu::Device, bind_group_layout: &wgpu::BindGroupLayout) -> Self {
3677        let buffer = device.create_buffer(&wgpu::BufferDescriptor {
3678            label: Some("Viewport Uniform Batch Buffer"),
3679            size: std::mem::size_of::<Uniforms>() as u64,
3680            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
3681            mapped_at_creation: false,
3682        });
3683        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3684            label: Some("Viewport Uniform Batch Bind Group"),
3685            layout: bind_group_layout,
3686            entries: &[wgpu::BindGroupEntry {
3687                binding: 0,
3688                resource: buffer.as_entire_binding(),
3689            }],
3690        });
3691        Self { buffer, bind_group }
3692    }
3693}
3694
3695#[cfg(target_arch = "wasm32")]
3696impl ImageBatchBuffers {
3697    fn new(device: &wgpu::Device) -> Self {
3698        let vertex_capacity = 4;
3699        let index_capacity = 6;
3700        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3701            label: Some("Image Vertex Batch Buffer"),
3702            size: (std::mem::size_of::<Vertex>() * vertex_capacity) as u64,
3703            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
3704            mapped_at_creation: false,
3705        });
3706        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3707            label: Some("Image Index Batch Buffer"),
3708            size: (std::mem::size_of::<u32>() * index_capacity) as u64,
3709            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
3710            mapped_at_creation: false,
3711        });
3712        Self {
3713            vertex_buffer,
3714            index_buffer,
3715            vertex_capacity,
3716            index_capacity,
3717        }
3718    }
3719
3720    fn ensure_capacity(
3721        &mut self,
3722        device: &wgpu::Device,
3723        vertices_needed: usize,
3724        indices_needed: usize,
3725    ) {
3726        let hard_max_bytes = HARD_MAX_BUFFER_MB * 1024 * 1024;
3727        if vertices_needed > self.vertex_capacity {
3728            let desired = vertices_needed.next_power_of_two();
3729            let max_count = hard_max_bytes / std::mem::size_of::<Vertex>();
3730            let new_cap = desired.min(max_count);
3731            self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3732                label: Some("Image Vertex Batch Buffer"),
3733                size: (std::mem::size_of::<Vertex>() * new_cap) as u64,
3734                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
3735                mapped_at_creation: false,
3736            });
3737            self.vertex_capacity = new_cap;
3738        }
3739        if indices_needed > self.index_capacity {
3740            let desired = indices_needed.next_power_of_two();
3741            let max_count = hard_max_bytes / std::mem::size_of::<u32>();
3742            let new_cap = desired.min(max_count);
3743            self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
3744                label: Some("Image Index Batch Buffer"),
3745                size: (std::mem::size_of::<u32>() * new_cap) as u64,
3746                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
3747                mapped_at_creation: false,
3748            });
3749            self.index_capacity = new_cap;
3750        }
3751    }
3752}
3753
3754// Text image cache keys are local to rasterized WGPU text batches
3755
3756pub struct GpuRenderer {
3757    pub(crate) device: Arc<wgpu::Device>,
3758    pub(crate) queue: Arc<wgpu::Queue>,
3759    /// This instance's renderer epoch, stamped by `init_gpu` at
3760    /// construction. A packet whose `renderer_epoch` differs was built
3761    /// against another instance and is cancelled at the head of
3762    /// [`Self::render`], never drawn.
3763    renderer_epoch: u64,
3764    /// The producer feed generation this store's slot universe belongs to:
3765    /// seeded at construction, advanced by `consume_replay_ops` when a
3766    /// higher-generation batch arrives (the batch itself carries the
3767    /// retirement releases). The store never reads the producer's
3768    /// thread-local — this field is its only generation authority.
3769    #[cfg(not(target_arch = "wasm32"))]
3770    store_feed_generation: u64,
3771    surface_format: wgpu::TextureFormat,
3772    adapter_backend: wgpu::Backend,
3773    shape_batch_limits: ShapeBatchLimits,
3774    pipeline: LazyGpuResource<wgpu::RenderPipeline>,
3775    pipeline_dst_out: LazyGpuResource<wgpu::RenderPipeline>,
3776    /// `Some` exactly in storage mode: the retained-mesh pipeline (`vs_mesh`
3777    /// over a vertex buffer) that replay slots with a captured arc mesh draw
3778    /// through. Uniform-mode devices never host retained slots.
3779    #[cfg(not(target_arch = "wasm32"))]
3780    mesh_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
3781    /// `Some` exactly when this renderer latched the instanced-quad path at
3782    /// construction (storage mode && `CRANPOSE_INSTANCED_QUADS` != 0). Read
3783    /// ONCE per renderer lifetime — cached retained bundles encode the
3784    /// selection, so it must never move under them (see
3785    /// [`instanced_quads_enabled`]).
3786    #[cfg(not(target_arch = "wasm32"))]
3787    instanced_quads: Option<InstancedQuadPipelines>,
3788    uniform_bind_group_layout: wgpu::BindGroupLayout,
3789    shape_bind_group_layout: wgpu::BindGroupLayout,
3790    /// `Some` exactly in storage mode: the 16-byte stand-in every fresh
3791    /// batch binds at the paint entry (see `shape_batch_bind_group_entries`).
3792    dummy_paint_buffer: Option<wgpu::Buffer>,
3793    /// Shared identity binding for `@group(1) @binding(2)`: every freshly
3794    /// converted shape batch draws untransformed through this one buffer.
3795    identity_similarity_buffer: wgpu::Buffer,
3796    #[cfg(not(target_arch = "wasm32"))]
3797    replay_slots: ReplaySlotStore,
3798    image_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
3799    image_pipeline_dst_out: LazyGpuResource<wgpu::RenderPipeline>,
3800    glyph_atlas_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
3801    #[cfg(not(target_arch = "wasm32"))]
3802    retained_glyph_atlas_pipeline: LazyGpuResource<wgpu::RenderPipeline>,
3803    image_bind_group_layout: wgpu::BindGroupLayout,
3804    #[cfg(not(target_arch = "wasm32"))]
3805    retained_glyph_uniform_bind_group_layout: wgpu::BindGroupLayout,
3806    image_nearest_sampler: wgpu::Sampler,
3807    image_linear_sampler: wgpu::Sampler,
3808    text_fonts: SoftwareTextFontSet,
3809    // Persistent GPU buffers (reused across frames)
3810    #[cfg(not(target_arch = "wasm32"))]
3811    upload_buffer: wgpu::Buffer,
3812    #[cfg(not(target_arch = "wasm32"))]
3813    uniform_buffer: wgpu::Buffer,
3814    #[cfg(not(target_arch = "wasm32"))]
3815    uniform_bind_group: wgpu::BindGroup,
3816    #[cfg(not(target_arch = "wasm32"))]
3817    shape_buffers: ShapeBatchBuffers,
3818    #[cfg(not(target_arch = "wasm32"))]
3819    image_vertex_buffer: wgpu::Buffer,
3820    #[cfg(not(target_arch = "wasm32"))]
3821    image_index_buffer: wgpu::Buffer,
3822    #[cfg(not(target_arch = "wasm32"))]
3823    retained_glyph_uniform_buffer: wgpu::Buffer,
3824    #[cfg(not(target_arch = "wasm32"))]
3825    retained_glyph_uniform_bind_group: wgpu::BindGroup,
3826    #[cfg(not(target_arch = "wasm32"))]
3827    retained_glyph_uniform_stride: u64,
3828    #[cfg(not(target_arch = "wasm32"))]
3829    retained_glyph_uniform_capacity: usize,
3830    #[cfg(not(target_arch = "wasm32"))]
3831    retained_glyph_uniform_cursor: usize,
3832    #[cfg(target_arch = "wasm32")]
3833    wasm_uniform_batches: Vec<UniformBatchBuffer>,
3834    #[cfg(target_arch = "wasm32")]
3835    wasm_uniform_batch_cursor: usize,
3836    #[cfg(target_arch = "wasm32")]
3837    wasm_shape_batches: Vec<ShapeBatchBuffers>,
3838    #[cfg(target_arch = "wasm32")]
3839    wasm_shape_batch_cursor: usize,
3840    #[cfg(target_arch = "wasm32")]
3841    wasm_image_batches: Vec<ImageBatchBuffers>,
3842    #[cfg(target_arch = "wasm32")]
3843    wasm_image_batch_cursor: usize,
3844    image_texture_cache: BoundedLruCache<u64, CachedImageTexture>,
3845    /// Total `CachedImageTexture::bytes` currently in the cache.
3846    image_texture_cache_bytes: usize,
3847    text_image_cache: BoundedLruCache<TextImageCacheKey, CachedTextImage>,
3848    text_glyph_atlas: TextGlyphAtlas,
3849    text_glyph_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedTextGlyphRun>,
3850    #[cfg(not(target_arch = "wasm32"))]
3851    text_glyph_gpu_run_cache: BoundedLruCache<TextGlyphRunCacheKey, CachedGpuTextGlyphRun>,
3852    text_glyph_mask_cache: SoftwareGlyphRasterCache,
3853    text_line_index_cache: TextLineIndexCache,
3854    scratch_shape_data: Vec<ShapeData>,
3855    scratch_gradients: Vec<GradientStop>,
3856    scratch_image_vertices: Vec<Vertex>,
3857    scratch_image_indices: Vec<u32>,
3858    scratch_image_cmds: Vec<ImageDrawCmd>,
3859    scratch_glyph_cmds: Vec<GlyphDrawCmd>,
3860    scratch_text_glyph_run: Vec<SoftwareGlyphAtlasRunGlyph>,
3861    scratch_text_glyph_placements: Vec<SoftwareGlyphAtlasPlacement>,
3862    scratch_text_glyph_quads: Vec<CachedTextGlyphQuad>,
3863    scratch_segment_items: Vec<(usize, SegmentDrawItem)>,
3864    scratch_effect_ranges: Vec<Range<usize>>,
3865    scratch_layer_events: Vec<LayerEvent>,
3866    staged_uploads: StagedBufferUploads,
3867    frame_graph_executor: WgpuFrameGraphExecutor,
3868    deferred_offscreen_releases: Vec<OffscreenTarget>,
3869    effect_renderer: EffectRenderer,
3870    layer_surface_cache: LayerSurfaceCache,
3871    observed_scene_range_cache_misses: BoundedLruCache<LayerRasterCacheKey, ()>,
3872    shadow_surface_cache: BoundedLruCache<ShadowSurfaceCacheKey, CachedShadowSurface>,
3873    shadow_surface_cache_bytes: u64,
3874    frame_stats: gpu_stats::FrameStats,
3875    last_frame_stats: Option<gpu_stats::FrameStatsSnapshot>,
3876    pending_frame_warmup_frames: u8,
3877    frame_count: u64,
3878    gpu_stats_enabled: bool,
3879    warning_state: RendererWarningState,
3880    #[cfg(not(target_arch = "wasm32"))]
3881    replay_upload_stats: ReplayUploadStats,
3882    /// The frame's replay recolor patches, parked here by
3883    /// `consume_replay_ops` until the retained prepare arms drain them
3884    /// (`stage_replay_patches`). The vec this frame's ops displace is last
3885    /// frame's, already drained empty, and returns to the producer with
3886    /// the ack — capacity ping-pongs planner queue → packet ops → here →
3887    /// ack return, so neither side allocates per frame (P4b).
3888    #[cfg(not(target_arch = "wasm32"))]
3889    replay_color_patches: Vec<crate::scene::ColorPatch>,
3890    /// Drain arena for `replay_color_patches`: `stage_replay_patches`
3891    /// swaps against this instead of `mem::take`, so both keep their
3892    /// high-water capacity across frames. Always empty between drains.
3893    #[cfg(not(target_arch = "wasm32"))]
3894    color_patch_scratch: Vec<crate::scene::ColorPatch>,
3895    /// Recycled confirmations buffer for the next [`crate::frame_packet::ReplayAck`]:
3896    /// `consume_replay_ops` fills it, the planner drains it in `apply_ack`,
3897    /// and the render loop hands the emptied vec (capacity intact) back
3898    /// here — the ack channel's half of the P4b no-allocation contract.
3899    #[cfg(not(target_arch = "wasm32"))]
3900    replay_ack_confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
3901    /// Lifetime count of replay-ops batches dropped whole by the
3902    /// generation check in `consume_replay_ops` — fail-closed against ops
3903    /// planned under a slot universe this store no longer holds.
3904    /// Synchronously impossible today; structural for the pipeline split.
3905    #[cfg(not(target_arch = "wasm32"))]
3906    replay_generation_drops: u64,
3907    /// Cached render bundles for maximal consecutive retained stretches in
3908    /// the fused segment pass (`CRANPOSE_RETAINED_BUNDLES` kill switch).
3909    #[cfg(not(target_arch = "wasm32"))]
3910    retained_bundle_cache: RetainedBundleCache,
3911}
3912
3913/// Running totals for retained-slot patch uploads, the paint-bandwidth
3914/// instrument: recolors upload 16-byte paint records (plus gradient stop
3915/// spans), coalesced per slot between the lowest and highest patched
3916/// index, so `bytes` versus `ideal_bytes` (patched colors alone) is just
3917/// the untouched records inside each coalesced span.
3918#[cfg(not(target_arch = "wasm32"))]
3919#[derive(Default)]
3920struct ReplayUploadStats {
3921    calls: u64,
3922    patched_calls: u64,
3923    patches: u64,
3924    slots: u64,
3925    records: u64,
3926    bytes: u64,
3927    ideal_bytes: u64,
3928    max_frame_bytes: u64,
3929}
3930
3931#[cfg(not(target_arch = "wasm32"))]
3932impl ReplayUploadStats {
3933    /// One aggregate line roughly every few seconds: cheap enough to stay
3934    /// on unconditionally, which matters because the watch cannot take
3935    /// setprop-backed diag flags — its logcat is the only channel, and a
3936    /// measurement window must catch several lines. Counts every drain
3937    /// call (the drain runs several times per frame; only the first sees
3938    /// patches) so a target with zero paint traffic still reports an
3939    /// affirmative zero instead of silence, while the averages divide by
3940    /// PATCHED calls so they read as per-frame numbers.
3941    /// warn level: the platform loggers filter info on desktop.
3942    const REPORT_CALLS: u64 = 1024;
3943
3944    fn note_frame(&mut self, patches: u64, slots: u64, records: u64, bytes: u64, ideal: u64) {
3945        self.calls += 1;
3946        if patches > 0 {
3947            self.patched_calls += 1;
3948            self.patches += patches;
3949            self.slots += slots;
3950            self.records += records;
3951            self.bytes += bytes;
3952            self.ideal_bytes += ideal;
3953            self.max_frame_bytes = self.max_frame_bytes.max(bytes);
3954        }
3955        if self.calls >= Self::REPORT_CALLS {
3956            let patched = self.patched_calls.max(1);
3957            log::warn!(
3958                "[replay-upload] {} patched of {} drains: avg {:.1} KB/frame (max {:.1} KB), \
3959                 color-only would be {:.1} KB/frame; avg {} patches over {} records in {} slots",
3960                self.patched_calls,
3961                self.calls,
3962                self.bytes as f64 / patched as f64 / 1024.0,
3963                self.max_frame_bytes as f64 / 1024.0,
3964                self.ideal_bytes as f64 / patched as f64 / 1024.0,
3965                self.patches / patched,
3966                self.records / patched,
3967                self.slots / patched,
3968            );
3969            *self = Self::default();
3970        }
3971    }
3972}
3973
3974fn image_sampler_descriptor(sampling: ImageSampling) -> wgpu::SamplerDescriptor<'static> {
3975    let filter = match sampling {
3976        ImageSampling::Nearest => wgpu::FilterMode::Nearest,
3977        ImageSampling::Linear => wgpu::FilterMode::Linear,
3978    };
3979    wgpu::SamplerDescriptor {
3980        label: Some(match sampling {
3981            ImageSampling::Nearest => "Nearest Image Sampler",
3982            ImageSampling::Linear => "Linear Image Sampler",
3983        }),
3984        address_mode_u: wgpu::AddressMode::ClampToEdge,
3985        address_mode_v: wgpu::AddressMode::ClampToEdge,
3986        address_mode_w: wgpu::AddressMode::ClampToEdge,
3987        mag_filter: filter,
3988        min_filter: filter,
3989        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
3990        ..Default::default()
3991    }
3992}
3993
3994#[cfg(test)]
3995fn layer_raster_cache_candidate(
3996    layer: &LayerNode,
3997    root_scale: f32,
3998    has_backdrop_underlay: bool,
3999    allow_runtime_cache: bool,
4000) -> Option<(LayerRasterCacheKey, Rect)> {
4001    let mut layer_surface_requirements_cache = HashMap::new();
4002    let surface_requirements =
4003        layer_surface_requirements_cached(layer, &mut layer_surface_requirements_cache);
4004    let runtime_cache_is_safe = allow_runtime_cache
4005        && surface_requirements
4006            .surface_requirements
4007            .has_isolating_requirement()
4008        && !layer
4009            .effect()
4010            .is_some_and(RenderEffect::contains_runtime_shader);
4011    let cache_is_allowed = layer.cache_policy == CachePolicy::Auto
4012        || (allow_runtime_cache && surface_requirements.has_renderer_forced_surface())
4013        || runtime_cache_is_safe;
4014    if !cache_is_allowed {
4015        return None;
4016    }
4017    if layer_uses_external_backdrop_input(layer, has_backdrop_underlay) {
4018        return None;
4019    }
4020    if layer
4021        .effect()
4022        .is_some_and(RenderEffect::contains_runtime_shader)
4023    {
4024        return None;
4025    }
4026
4027    let logical_rect = estimate_layer_surface_rect(layer);
4028    let pixel_size = surface_target_size(logical_rect, root_scale, u32::MAX);
4029    Some((
4030        LayerRasterCacheKey::new(
4031            layer.node_id,
4032            layer.target_content_hash(),
4033            layer.effect_hash(),
4034            logical_rect,
4035            pixel_size,
4036            ScaleBucket::from_scale(root_scale),
4037        ),
4038        logical_rect,
4039    ))
4040}
4041
4042impl GpuRenderer {
4043    pub fn new(
4044        device: Arc<wgpu::Device>,
4045        queue: Arc<wgpu::Queue>,
4046        surface_format: wgpu::TextureFormat,
4047        adapter_backend: wgpu::Backend,
4048        text_fonts: SoftwareTextFontSet,
4049        renderer_epoch: u64,
4050        store_feed_generation: u64,
4051    ) -> Self {
4052        #[cfg(target_arch = "wasm32")]
4053        let _ = store_feed_generation;
4054        let shape_batch_limits = ShapeBatchLimits::for_device(&device);
4055        let uniform_bind_group_layout =
4056            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4057                label: Some("Uniform Bind Group Layout"),
4058                entries: &[wgpu::BindGroupLayoutEntry {
4059                    binding: 0,
4060                    visibility: wgpu::ShaderStages::VERTEX,
4061                    ty: wgpu::BindingType::Buffer {
4062                        ty: wgpu::BufferBindingType::Uniform,
4063                        has_dynamic_offset: false,
4064                        min_binding_size: None,
4065                    },
4066                    count: None,
4067                }],
4068            });
4069        #[cfg(not(target_arch = "wasm32"))]
4070        let retained_glyph_uniform_bind_group_layout =
4071            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4072                label: Some("Retained Glyph Dynamic Uniform Bind Group Layout"),
4073                entries: &[wgpu::BindGroupLayoutEntry {
4074                    binding: 0,
4075                    visibility: wgpu::ShaderStages::VERTEX,
4076                    ty: wgpu::BindingType::Buffer {
4077                        ty: wgpu::BufferBindingType::Uniform,
4078                        has_dynamic_offset: true,
4079                        min_binding_size: wgpu::BufferSize::new(
4080                            std::mem::size_of::<Uniforms>() as u64
4081                        ),
4082                    },
4083                    count: None,
4084                }],
4085            });
4086
4087        // Read-only storage bindings where the device has them (so a whole
4088        // scene fits one batch); uniform arrays on WebGL-class devices, which
4089        // have no storage buffers in fragment shaders. The shape array is
4090        // visible to the vertex stage as well: the pipeline has no vertex
4091        // buffer and `vs_main` pulls quad corners from ShapeData. (Storage
4092        // mode is gated on `max_storage_buffers_per_shader_stage`, which GL
4093        // backends report as the minimum across stages, so a device that
4094        // cannot read storage from the vertex stage falls back to uniforms.)
4095        let mut shape_bind_group_layout_entries = vec![
4096            wgpu::BindGroupLayoutEntry {
4097                binding: 0,
4098                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
4099                ty: wgpu::BindingType::Buffer {
4100                    ty: shape_batch_limits.data_binding_type(),
4101                    has_dynamic_offset: false,
4102                    min_binding_size: None,
4103                },
4104                count: None,
4105            },
4106            wgpu::BindGroupLayoutEntry {
4107                binding: 1,
4108                visibility: wgpu::ShaderStages::FRAGMENT,
4109                ty: wgpu::BindingType::Buffer {
4110                    ty: shape_batch_limits.data_binding_type(),
4111                    has_dynamic_offset: false,
4112                    min_binding_size: None,
4113                },
4114                count: None,
4115            },
4116            // The similarity transform rides a dynamic offset so
4117            // retained draws sharing one captured batch can each
4118            // apply their own transform; ordinary batches pass
4119            // offset 0 into the identity buffer.
4120            wgpu::BindGroupLayoutEntry {
4121                binding: 2,
4122                visibility: wgpu::ShaderStages::VERTEX,
4123                ty: wgpu::BindingType::Buffer {
4124                    ty: wgpu::BufferBindingType::Uniform,
4125                    has_dynamic_offset: true,
4126                    min_binding_size: wgpu::BufferSize::new(
4127                        std::mem::size_of::<SimilarityTransform>() as u64,
4128                    ),
4129                },
4130                count: None,
4131            },
4132        ];
4133        // Retained-slot paint colors, read by the vertex stage under
4134        // `paint_select` (see `shape_shader_source`). Storage mode only:
4135        // the uniform-variant shader never declares the array, and
4136        // uniform-mode devices never host retained slots, so their layout
4137        // stays exactly the three-entry one the uniform pipeline expects.
4138        if shape_batch_limits.storage {
4139            shape_bind_group_layout_entries.push(wgpu::BindGroupLayoutEntry {
4140                binding: 3,
4141                visibility: wgpu::ShaderStages::VERTEX,
4142                ty: wgpu::BindingType::Buffer {
4143                    ty: wgpu::BufferBindingType::Storage { read_only: true },
4144                    has_dynamic_offset: false,
4145                    min_binding_size: None,
4146                },
4147                count: None,
4148            });
4149        }
4150        let shape_bind_group_layout =
4151            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4152                label: Some("Shape Bind Group Layout"),
4153                entries: &shape_bind_group_layout_entries,
4154            });
4155
4156        let identity_similarity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4157            label: Some("Identity Similarity Buffer"),
4158            size: std::mem::size_of::<SimilarityTransform>() as u64,
4159            usage: wgpu::BufferUsages::UNIFORM,
4160            mapped_at_creation: true,
4161        });
4162        identity_similarity_buffer
4163            .slice(..)
4164            .get_mapped_range_mut()
4165            .copy_from_slice(bytemuck::bytes_of(&SimilarityTransform::IDENTITY));
4166        identity_similarity_buffer.unmap();
4167
4168        // Fresh-batch bind groups need a resource at the paint binding even
4169        // though their draws leave `paint_select` at 0.0 and never use the
4170        // value; one minimal buffer (a single never-read vec4) serves every
4171        // batch. Uniform-mode layouts have no paint entry, so none exists.
4172        let dummy_paint_buffer = shape_batch_limits.storage.then(|| {
4173            device.create_buffer(&wgpu::BufferDescriptor {
4174                label: Some("Dummy Paint Buffer"),
4175                size: std::mem::size_of::<[f32; 4]>() as u64,
4176                usage: wgpu::BufferUsages::STORAGE,
4177                mapped_at_creation: false,
4178            })
4179        });
4180        #[cfg(not(target_arch = "wasm32"))]
4181        let replay_slot_store = ReplaySlotStore::new(&device);
4182
4183        let pipeline = LazyGpuResource::new("shape/src-over");
4184        let pipeline_dst_out = LazyGpuResource::new("shape/dst-out");
4185        #[cfg(not(target_arch = "wasm32"))]
4186        let mesh_pipeline = LazyGpuResource::new("shape/mesh");
4187        // The instanced-quad selection is LATCHED here, once per renderer:
4188        // cached retained bundles encode whichever pipelines this resolves
4189        // to, so a per-draw env read could let a bundle replay a selection
4190        // the direct path no longer makes. Storage mode only — the
4191        // uniform/WebGL path keeps `vs_main` and its plain draws untouched.
4192        #[cfg(not(target_arch = "wasm32"))]
4193        let instanced_quads =
4194            (shape_batch_limits.storage && instanced_quads_enabled()).then(|| {
4195                let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4196                    label: Some("Instanced Quad Index Buffer"),
4197                    size: std::mem::size_of_val(&INSTANCED_QUAD_INDICES) as u64,
4198                    usage: wgpu::BufferUsages::INDEX,
4199                    mapped_at_creation: true,
4200                });
4201                index_buffer
4202                    .slice(..)
4203                    .get_mapped_range_mut()
4204                    .copy_from_slice(bytemuck::cast_slice(&INSTANCED_QUAD_INDICES));
4205                index_buffer.unmap();
4206                InstancedQuadPipelines {
4207                    pipeline: LazyGpuResource::new("shape/instanced-src-over"),
4208                    pipeline_dst_out: LazyGpuResource::new("shape/instanced-dst-out"),
4209                    index_buffer,
4210                }
4211            });
4212
4213        let image_bind_group_layout =
4214            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
4215                label: Some("Image Texture Bind Group Layout"),
4216                entries: &[
4217                    wgpu::BindGroupLayoutEntry {
4218                        binding: 0,
4219                        visibility: wgpu::ShaderStages::FRAGMENT,
4220                        ty: wgpu::BindingType::Texture {
4221                            multisampled: false,
4222                            view_dimension: wgpu::TextureViewDimension::D2,
4223                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
4224                        },
4225                        count: None,
4226                    },
4227                    wgpu::BindGroupLayoutEntry {
4228                        binding: 1,
4229                        visibility: wgpu::ShaderStages::FRAGMENT,
4230                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
4231                        count: None,
4232                    },
4233                ],
4234            });
4235
4236        let image_pipeline = LazyGpuResource::new("image/src-over");
4237        let image_pipeline_dst_out = LazyGpuResource::new("image/dst-out");
4238        let glyph_atlas_pipeline = LazyGpuResource::new("glyph/shared");
4239        #[cfg(not(target_arch = "wasm32"))]
4240        let retained_glyph_atlas_pipeline = LazyGpuResource::new("glyph/retained");
4241
4242        #[cfg(not(target_arch = "wasm32"))]
4243        let upload_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4244            label: Some("Frame Upload Buffer"),
4245            size: INITIAL_UPLOAD_BUFFER_BYTES,
4246            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
4247            mapped_at_creation: false,
4248        });
4249
4250        #[cfg(not(target_arch = "wasm32"))]
4251        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4252            label: Some("Uniform Buffer"),
4253            size: std::mem::size_of::<Uniforms>() as u64,
4254            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4255            mapped_at_creation: false,
4256        });
4257
4258        #[cfg(not(target_arch = "wasm32"))]
4259        let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
4260            label: Some("Uniform Bind Group"),
4261            layout: &uniform_bind_group_layout,
4262            entries: &[wgpu::BindGroupEntry {
4263                binding: 0,
4264                resource: uniform_buffer.as_entire_binding(),
4265            }],
4266        });
4267
4268        #[cfg(not(target_arch = "wasm32"))]
4269        let shape_buffers = ShapeBatchBuffers::new(
4270            &device,
4271            &shape_bind_group_layout,
4272            &identity_similarity_buffer,
4273            dummy_paint_buffer.as_ref(),
4274            shape_batch_limits,
4275        );
4276
4277        let image_nearest_sampler =
4278            device.create_sampler(&image_sampler_descriptor(ImageSampling::Nearest));
4279        let image_linear_sampler =
4280            device.create_sampler(&image_sampler_descriptor(ImageSampling::Linear));
4281        let text_glyph_atlas = TextGlyphAtlas::new(
4282            &device,
4283            &image_bind_group_layout,
4284            &image_nearest_sampler,
4285            TEXT_GLYPH_ATLAS_MIN_SIZE,
4286        );
4287
4288        #[cfg(not(target_arch = "wasm32"))]
4289        let image_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4290            label: Some("Image Vertex Buffer"),
4291            size: (std::mem::size_of::<Vertex>() * 4) as u64,
4292            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
4293            mapped_at_creation: false,
4294        });
4295
4296        #[cfg(not(target_arch = "wasm32"))]
4297        let image_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4298            label: Some("Image Index Buffer"),
4299            size: (std::mem::size_of::<u32>() * 6) as u64,
4300            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
4301            mapped_at_creation: false,
4302        });
4303        #[cfg(not(target_arch = "wasm32"))]
4304        let retained_glyph_uniform_stride = align_usize_to(
4305            std::mem::size_of::<Uniforms>(),
4306            (device.limits().min_uniform_buffer_offset_alignment as usize)
4307                .max(wgpu::COPY_BUFFER_ALIGNMENT as usize),
4308        ) as u64;
4309        #[cfg(not(target_arch = "wasm32"))]
4310        let retained_glyph_uniform_capacity = INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS;
4311        #[cfg(not(target_arch = "wasm32"))]
4312        let retained_glyph_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
4313            label: Some("Retained Glyph Uniform Buffer"),
4314            size: retained_glyph_uniform_stride * retained_glyph_uniform_capacity as u64,
4315            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
4316            mapped_at_creation: false,
4317        });
4318        #[cfg(not(target_arch = "wasm32"))]
4319        let retained_glyph_uniform_bind_group =
4320            device.create_bind_group(&wgpu::BindGroupDescriptor {
4321                label: Some("Retained Glyph Uniform Bind Group"),
4322                layout: &retained_glyph_uniform_bind_group_layout,
4323                entries: &[wgpu::BindGroupEntry {
4324                    binding: 0,
4325                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
4326                        buffer: &retained_glyph_uniform_buffer,
4327                        offset: 0,
4328                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
4329                    }),
4330                }],
4331            });
4332
4333        let effect_renderer = EffectRenderer::new(&device, surface_format, adapter_backend);
4334
4335        Self {
4336            device,
4337            queue,
4338            renderer_epoch,
4339            #[cfg(not(target_arch = "wasm32"))]
4340            store_feed_generation,
4341            surface_format,
4342            adapter_backend,
4343            shape_batch_limits,
4344            pipeline,
4345            pipeline_dst_out,
4346            #[cfg(not(target_arch = "wasm32"))]
4347            mesh_pipeline,
4348            #[cfg(not(target_arch = "wasm32"))]
4349            instanced_quads,
4350            uniform_bind_group_layout,
4351            shape_bind_group_layout,
4352            dummy_paint_buffer,
4353            identity_similarity_buffer,
4354            #[cfg(not(target_arch = "wasm32"))]
4355            replay_slots: replay_slot_store,
4356            image_pipeline,
4357            image_pipeline_dst_out,
4358            glyph_atlas_pipeline,
4359            #[cfg(not(target_arch = "wasm32"))]
4360            retained_glyph_atlas_pipeline,
4361            image_bind_group_layout,
4362            #[cfg(not(target_arch = "wasm32"))]
4363            retained_glyph_uniform_bind_group_layout,
4364            image_nearest_sampler,
4365            image_linear_sampler,
4366            text_fonts,
4367            #[cfg(not(target_arch = "wasm32"))]
4368            upload_buffer,
4369            #[cfg(not(target_arch = "wasm32"))]
4370            uniform_buffer,
4371            #[cfg(not(target_arch = "wasm32"))]
4372            uniform_bind_group,
4373            #[cfg(not(target_arch = "wasm32"))]
4374            shape_buffers,
4375            #[cfg(not(target_arch = "wasm32"))]
4376            image_vertex_buffer,
4377            #[cfg(not(target_arch = "wasm32"))]
4378            image_index_buffer,
4379            #[cfg(not(target_arch = "wasm32"))]
4380            retained_glyph_uniform_buffer,
4381            #[cfg(not(target_arch = "wasm32"))]
4382            retained_glyph_uniform_bind_group,
4383            #[cfg(not(target_arch = "wasm32"))]
4384            retained_glyph_uniform_stride,
4385            #[cfg(not(target_arch = "wasm32"))]
4386            retained_glyph_uniform_capacity,
4387            #[cfg(not(target_arch = "wasm32"))]
4388            retained_glyph_uniform_cursor: 0,
4389            #[cfg(target_arch = "wasm32")]
4390            wasm_uniform_batches: Vec::new(),
4391            #[cfg(target_arch = "wasm32")]
4392            wasm_uniform_batch_cursor: 0,
4393            #[cfg(target_arch = "wasm32")]
4394            wasm_shape_batches: Vec::new(),
4395            #[cfg(target_arch = "wasm32")]
4396            wasm_shape_batch_cursor: 0,
4397            #[cfg(target_arch = "wasm32")]
4398            wasm_image_batches: Vec::new(),
4399            #[cfg(target_arch = "wasm32")]
4400            wasm_image_batch_cursor: 0,
4401            image_texture_cache: BoundedLruCache::with_capacity_at_least_one(
4402                MAX_TEXTURE_CACHE_ITEMS,
4403            ),
4404            image_texture_cache_bytes: 0,
4405            text_image_cache: BoundedLruCache::with_capacity_at_least_one(
4406                MAX_TEXT_IMAGE_CACHE_ITEMS,
4407            ),
4408            text_glyph_atlas,
4409            text_glyph_run_cache: BoundedLruCache::with_capacity_at_least_one(
4410                MAX_TEXT_GLYPH_RUN_CACHE_ITEMS,
4411            ),
4412            #[cfg(not(target_arch = "wasm32"))]
4413            text_glyph_gpu_run_cache: BoundedLruCache::with_capacity_at_least_one(
4414                MAX_TEXT_GLYPH_GPU_RUN_CACHE_ITEMS,
4415            ),
4416            text_glyph_mask_cache: SoftwareGlyphRasterCache::with_capacity_at_least_one(
4417                MAX_TEXT_GLYPH_MASK_CACHE_ITEMS,
4418            ),
4419            text_line_index_cache: TextLineIndexCache::new(MAX_TEXT_LINE_INDEX_CACHE_ITEMS),
4420            scratch_shape_data: Vec::new(),
4421            scratch_gradients: Vec::new(),
4422            scratch_image_vertices: Vec::new(),
4423            scratch_image_indices: Vec::new(),
4424            scratch_image_cmds: Vec::new(),
4425            scratch_glyph_cmds: Vec::new(),
4426            scratch_text_glyph_run: Vec::new(),
4427            scratch_text_glyph_placements: Vec::new(),
4428            scratch_text_glyph_quads: Vec::new(),
4429            scratch_segment_items: Vec::new(),
4430            scratch_effect_ranges: Vec::new(),
4431            scratch_layer_events: Vec::new(),
4432            staged_uploads: StagedBufferUploads::default(),
4433            frame_graph_executor: WgpuFrameGraphExecutor::new(),
4434            deferred_offscreen_releases: Vec::new(),
4435            effect_renderer,
4436            layer_surface_cache: LayerSurfaceCache::new(),
4437            observed_scene_range_cache_misses: BoundedLruCache::with_capacity_at_least_one(
4438                MAX_OBSERVED_SCENE_RANGE_CACHE_MISSES,
4439            ),
4440            shadow_surface_cache: BoundedLruCache::with_capacity_at_least_one(
4441                MAX_SHADOW_SURFACE_CACHE_ITEMS,
4442            ),
4443            shadow_surface_cache_bytes: 0,
4444            frame_stats: gpu_stats::FrameStats::default(),
4445            last_frame_stats: None,
4446            pending_frame_warmup_frames: 0,
4447            frame_count: 0,
4448            gpu_stats_enabled: gpu_stats_enabled(),
4449            warning_state: RendererWarningState::default(),
4450            #[cfg(not(target_arch = "wasm32"))]
4451            replay_upload_stats: ReplayUploadStats::default(),
4452            #[cfg(not(target_arch = "wasm32"))]
4453            replay_color_patches: Vec::new(),
4454            #[cfg(not(target_arch = "wasm32"))]
4455            color_patch_scratch: Vec::new(),
4456            #[cfg(not(target_arch = "wasm32"))]
4457            replay_ack_confirmations: Vec::new(),
4458            #[cfg(not(target_arch = "wasm32"))]
4459            replay_generation_drops: 0,
4460            #[cfg(not(target_arch = "wasm32"))]
4461            retained_bundle_cache: RetainedBundleCache::new(),
4462        }
4463    }
4464
4465    fn shape_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
4466        let resource = match blend_mode {
4467            BlendMode::DstOut => &self.pipeline_dst_out,
4468            _ => &self.pipeline,
4469        };
4470        resource.get_or_init(self.adapter_backend, || {
4471            create_shape_pipeline(
4472                &self.device,
4473                self.surface_format,
4474                &self.uniform_bind_group_layout,
4475                &self.shape_bind_group_layout,
4476                blend_mode,
4477                self.shape_batch_limits,
4478            )
4479        })
4480    }
4481
4482    #[cfg(not(target_arch = "wasm32"))]
4483    fn mesh_pipeline(&self) -> &wgpu::RenderPipeline {
4484        self.mesh_pipeline.get_or_init(self.adapter_backend, || {
4485            create_mesh_shape_pipeline(
4486                &self.device,
4487                self.surface_format,
4488                &self.uniform_bind_group_layout,
4489                &self.shape_bind_group_layout,
4490                self.shape_batch_limits,
4491            )
4492        })
4493    }
4494
4495    #[cfg(not(target_arch = "wasm32"))]
4496    fn instanced_pipeline<'a>(
4497        &'a self,
4498        instanced: &'a InstancedQuadPipelines,
4499        blend_mode: BlendMode,
4500    ) -> &'a wgpu::RenderPipeline {
4501        let resource = match blend_mode {
4502            BlendMode::DstOut => &instanced.pipeline_dst_out,
4503            _ => &instanced.pipeline,
4504        };
4505        resource.get_or_init(self.adapter_backend, || {
4506            create_instanced_shape_pipeline(
4507                &self.device,
4508                self.surface_format,
4509                &self.uniform_bind_group_layout,
4510                &self.shape_bind_group_layout,
4511                blend_mode,
4512                self.shape_batch_limits,
4513            )
4514        })
4515    }
4516
4517    fn image_pipeline(&self, blend_mode: BlendMode) -> &wgpu::RenderPipeline {
4518        let resource = match blend_mode {
4519            BlendMode::DstOut => &self.image_pipeline_dst_out,
4520            _ => &self.image_pipeline,
4521        };
4522        resource.get_or_init(self.adapter_backend, || {
4523            create_image_pipeline(
4524                &self.device,
4525                self.surface_format,
4526                &self.uniform_bind_group_layout,
4527                &self.image_bind_group_layout,
4528                blend_mode,
4529            )
4530        })
4531    }
4532
4533    fn glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
4534        self.glyph_atlas_pipeline
4535            .get_or_init(self.adapter_backend, || {
4536                create_glyph_atlas_pipeline(
4537                    &self.device,
4538                    self.surface_format,
4539                    &self.uniform_bind_group_layout,
4540                    &self.image_bind_group_layout,
4541                )
4542            })
4543    }
4544
4545    #[cfg(not(target_arch = "wasm32"))]
4546    fn retained_glyph_atlas_pipeline(&self) -> &wgpu::RenderPipeline {
4547        self.retained_glyph_atlas_pipeline
4548            .get_or_init(self.adapter_backend, || {
4549                create_glyph_atlas_pipeline(
4550                    &self.device,
4551                    self.surface_format,
4552                    &self.retained_glyph_uniform_bind_group_layout,
4553                    &self.image_bind_group_layout,
4554                )
4555            })
4556    }
4557
4558    fn ensure_image_cached(&mut self, image: &ImageBitmap) -> Result<(), String> {
4559        if self.image_texture_cache.get(&image.id()).is_some() {
4560            return Ok(());
4561        }
4562
4563        let size = wgpu::Extent3d {
4564            width: image.width(),
4565            height: image.height(),
4566            depth_or_array_layers: 1,
4567        };
4568
4569        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
4570            label: Some("Image Texture"),
4571            size,
4572            mip_level_count: 1,
4573            sample_count: 1,
4574            dimension: wgpu::TextureDimension::D2,
4575            format: wgpu::TextureFormat::Rgba8Unorm,
4576            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
4577            view_formats: &[],
4578        });
4579
4580        let upload_stats = self.frame_graph_executor.upload_texture(
4581            &self.queue,
4582            wgpu::TexelCopyTextureInfo {
4583                texture: &texture,
4584                mip_level: 0,
4585                origin: wgpu::Origin3d::ZERO,
4586                aspect: wgpu::TextureAspect::All,
4587            },
4588            image.pixels(),
4589            wgpu::TexelCopyBufferLayout {
4590                offset: 0,
4591                bytes_per_row: Some(4 * image.width()),
4592                rows_per_image: Some(image.height()),
4593            },
4594            size,
4595        );
4596        self.frame_stats.record_command_stats(upload_stats);
4597
4598        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
4599        let nearest_bind_group = self.image_bind_group(&view, &self.image_nearest_sampler);
4600        let linear_bind_group = self.image_bind_group(&view, &self.image_linear_sampler);
4601
4602        let bytes = image.width() as usize * image.height() as usize * 4;
4603        if let Some(replaced) = self.image_texture_cache.put(
4604            image.id(),
4605            CachedImageTexture {
4606                _texture: texture,
4607                _view: view,
4608                nearest_bind_group,
4609                linear_bind_group,
4610                bytes,
4611            },
4612        ) {
4613            self.image_texture_cache_bytes = self
4614                .image_texture_cache_bytes
4615                .saturating_sub(replaced.bytes);
4616        }
4617        self.image_texture_cache_bytes += bytes;
4618        // Byte-bounded eviction on top of the count bound: never evict the
4619        // entry just inserted (this frame draws it).
4620        while self.image_texture_cache_bytes > MAX_IMAGE_TEXTURE_CACHE_BYTES
4621            && self.image_texture_cache.len() > 1
4622        {
4623            let Some((_, evicted)) = self.image_texture_cache.pop_lru() else {
4624                break;
4625            };
4626            self.image_texture_cache_bytes =
4627                self.image_texture_cache_bytes.saturating_sub(evicted.bytes);
4628        }
4629        Ok(())
4630    }
4631
4632    fn image_bind_group(
4633        &self,
4634        view: &wgpu::TextureView,
4635        sampler: &wgpu::Sampler,
4636    ) -> wgpu::BindGroup {
4637        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
4638            label: Some("Image Texture Bind Group"),
4639            layout: &self.image_bind_group_layout,
4640            entries: &[
4641                wgpu::BindGroupEntry {
4642                    binding: 0,
4643                    resource: wgpu::BindingResource::TextureView(view),
4644                },
4645                wgpu::BindGroupEntry {
4646                    binding: 1,
4647                    resource: wgpu::BindingResource::Sampler(sampler),
4648                },
4649            ],
4650        })
4651    }
4652
4653    /// Acquire an offscreen target from the pool with stats tracking.
4654    /// Uses split borrows to avoid conflicting borrows on self.
4655    fn max_texture_dim(&self) -> u32 {
4656        self.effect_renderer.max_texture_dim()
4657    }
4658
4659    fn acquire_offscreen(&mut self, width: u32, height: u32) -> OffscreenTarget {
4660        self.effect_renderer
4661            .acquire_offscreen(&self.device, width, height, Some(&self.frame_stats))
4662    }
4663
4664    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
4665        self.acquire_offscreen(width, height)
4666    }
4667
4668    fn transient_offscreen_descriptor(
4669        &self,
4670        label: &'static str,
4671        width: u32,
4672        height: u32,
4673    ) -> FrameTextureDescriptor {
4674        let max_texture_dim = self.max_texture_dim();
4675        FrameTextureDescriptor::render_attachment(
4676            label,
4677            width.min(max_texture_dim),
4678            height.min(max_texture_dim),
4679            self.surface_format,
4680        )
4681    }
4682
4683    fn defer_offscreen_release(&mut self, target: OffscreenTarget) {
4684        self.deferred_offscreen_releases.push(target);
4685    }
4686
4687    fn flush_deferred_offscreen_releases(&mut self) {
4688        for target in self.deferred_offscreen_releases.drain(..) {
4689            self.effect_renderer.release_offscreen(target);
4690        }
4691    }
4692
4693    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
4694        if let LayerSurfaceTexture::Owned(target) = target {
4695            self.defer_offscreen_release(target);
4696        }
4697    }
4698
4699    fn cached_layer_surface(
4700        &mut self,
4701        key: &LayerRasterCacheKey,
4702    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
4703        self.layer_surface_cache.get(key, &self.frame_stats)
4704    }
4705
4706    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
4707        admit_layer_surface_cache_miss_impl(key, &mut self.observed_scene_range_cache_misses)
4708    }
4709
4710    fn insert_cached_layer_surface(
4711        &mut self,
4712        key: LayerRasterCacheKey,
4713        target: OffscreenTarget,
4714        logical_rect: Rect,
4715    ) -> Rc<OffscreenTarget> {
4716        self.layer_surface_cache
4717            .insert(key, target, logical_rect, &self.frame_stats)
4718    }
4719
4720    fn cached_shadow_surface(
4721        &mut self,
4722        key: &ShadowSurfaceCacheKey,
4723    ) -> Option<Rc<OffscreenTarget>> {
4724        self.shadow_surface_cache
4725            .get(key)
4726            .map(|cached| cached.target.clone())
4727    }
4728
4729    fn cached_shape_shadow_composite(
4730        &mut self,
4731        shadow: &ShadowDraw,
4732        width: u32,
4733        height: u32,
4734        root_scale: f32,
4735    ) -> Option<CachedShadowComposite> {
4736        if shadow.blur_radius <= 0.0 || shadow.shapes.is_empty() || !shadow.texts.is_empty() {
4737            return None;
4738        }
4739
4740        let plan = shape_shadow_surface_plan(
4741            &shadow.shapes,
4742            shadow.clip,
4743            shadow.blur_radius,
4744            width,
4745            height,
4746            root_scale,
4747            self.max_texture_dim(),
4748        )?;
4749        let key = shape_shadow_surface_cache_key(
4750            &shadow.shapes,
4751            plan.source_device_bounds,
4752            plan.pixel_radius,
4753            root_scale,
4754        )?;
4755        let cached = self.cached_shadow_surface(&key)?;
4756        let viewport_offset = [plan.source_device_bounds.x, plan.source_device_bounds.y];
4757        self.frame_stats.record_shadow_shape_cache_hit(
4758            plan.source_device_bounds.width,
4759            plan.source_device_bounds.height,
4760        );
4761
4762        let clip_scissor = shadow
4763            .clip
4764            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
4765        let scissor = clip_scissor.or(plan.processing_scissor);
4766        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
4767            mask.rect[0] -= viewport_offset[0];
4768            mask.rect[1] -= viewport_offset[1];
4769            mask
4770        });
4771        let dest_viewport = Some((
4772            viewport_offset[0],
4773            viewport_offset[1],
4774            plan.source_device_bounds.width as f32,
4775            plan.source_device_bounds.height as f32,
4776        ));
4777
4778        Some(CachedShadowComposite {
4779            source: cached,
4780            scissor,
4781            rounded_mask,
4782            dest_viewport,
4783        })
4784    }
4785
4786    fn insert_cached_shadow_surface(
4787        &mut self,
4788        key: ShadowSurfaceCacheKey,
4789        target: OffscreenTarget,
4790    ) {
4791        let byte_size = offscreen_byte_size(target.width, target.height);
4792        while self.shadow_surface_cache_bytes + byte_size > MAX_SHADOW_SURFACE_CACHE_BYTES {
4793            let Some((_evicted_key, evicted_entry)) = self.shadow_surface_cache.pop_lru() else {
4794                break;
4795            };
4796            self.shadow_surface_cache_bytes = self
4797                .shadow_surface_cache_bytes
4798                .saturating_sub(evicted_entry.byte_size);
4799        }
4800
4801        let cached = CachedShadowSurface {
4802            target: Rc::new(target),
4803            byte_size,
4804        };
4805        if let Some((_replaced_key, replaced_entry)) = self.shadow_surface_cache.push(key, cached) {
4806            self.shadow_surface_cache_bytes = self
4807                .shadow_surface_cache_bytes
4808                .saturating_sub(replaced_entry.byte_size);
4809        }
4810        self.shadow_surface_cache_bytes = self.shadow_surface_cache_bytes.saturating_add(byte_size);
4811    }
4812
4813    fn supports_render_effect(&self, effect: &RenderEffect) -> bool {
4814        is_render_effect_supported(effect)
4815    }
4816}
4817
4818struct RecordingSurfaceBackend<'renderer, 'recorder, C: FrameCommandRecorder> {
4819    renderer: &'renderer mut GpuRenderer,
4820    recorder: &'recorder mut C,
4821}
4822
4823impl<C: FrameCommandRecorder> RecordingSurfaceBackend<'_, '_, C> {
4824    #[allow(clippy::too_many_arguments)]
4825    fn render_range_with_layer_events_to_target_recorded(
4826        &mut self,
4827        target: &OffscreenTarget,
4828        shapes: &[DrawShape],
4829        images: &[ImageDraw],
4830        texts: &[TextDraw],
4831        shadow_draws: &[ShadowDraw],
4832        draw_ops: &[DrawOp],
4833        effect_layers: &[EffectLayer],
4834        backdrop_layers: &[BackdropLayer],
4835        z_start: usize,
4836        z_end: usize,
4837        excluded_effect_layer: Option<usize>,
4838        width: u32,
4839        height: u32,
4840        root_scale: f32,
4841        backdrop_underlay: Option<&OffscreenTarget>,
4842        initial_load_op: wgpu::LoadOp<wgpu::Color>,
4843    ) -> Result<(), String> {
4844        if z_start >= z_end {
4845            if matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
4846                self.clear_target_view_with_load_op(&target.view, initial_load_op);
4847            }
4848            return Ok(());
4849        }
4850
4851        let mut effect_z_ranges = std::mem::take(&mut self.renderer.scratch_effect_ranges);
4852        collect_effect_ranges(
4853            effect_layers,
4854            z_start,
4855            z_end,
4856            excluded_effect_layer,
4857            &mut effect_z_ranges,
4858        );
4859        let mut events = std::mem::take(&mut self.renderer.scratch_layer_events);
4860        collect_layer_events(
4861            effect_layers,
4862            backdrop_layers,
4863            z_start,
4864            z_end,
4865            excluded_effect_layer,
4866            &mut events,
4867        );
4868
4869        let result = (|| -> Result<(), String> {
4870            let mut next_load_op = initial_load_op;
4871            let mut cursor_z = z_start;
4872            for event in &events {
4873                if event.z_index > cursor_z {
4874                    self.render_non_effect_segment(
4875                        &target.view,
4876                        shapes,
4877                        images,
4878                        texts,
4879                        shadow_draws,
4880                        // Windowed scenes never carry retained draws — see
4881                        // `build_scene_window`.
4882                        &[],
4883                        draw_ops,
4884                        cursor_z,
4885                        event.z_index,
4886                        &effect_z_ranges,
4887                        width,
4888                        height,
4889                        root_scale,
4890                        next_load_op,
4891                    )?;
4892                    next_load_op = wgpu::LoadOp::Load;
4893                    cursor_z = event.z_index;
4894                } else if event.z_index < cursor_z {
4895                    continue;
4896                }
4897
4898                if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
4899                    self.clear_target_view_with_load_op(&target.view, next_load_op);
4900                    next_load_op = wgpu::LoadOp::Load;
4901                }
4902
4903                match event.kind {
4904                    LayerEventKind::Backdrop(index) => {
4905                        let layer = &backdrop_layers[index];
4906                        let effective_backdrop_underlay = if backdrop_underlay.is_some()
4907                            && backdrop_underlay_is_covered_by_local_content(
4908                                shapes,
4909                                images,
4910                                shadow_draws,
4911                                draw_ops,
4912                                effect_layers,
4913                                backdrop_layers,
4914                                layer,
4915                            ) {
4916                            None
4917                        } else {
4918                            backdrop_underlay
4919                        };
4920                        execute_apply_backdrop_layer_to_target(
4921                            self,
4922                            target,
4923                            layer,
4924                            effective_backdrop_underlay,
4925                            width,
4926                            height,
4927                            root_scale,
4928                            None,
4929                        )?;
4930                    }
4931                    LayerEventKind::Effect(index) => {
4932                        let layer = &effect_layers[index];
4933                        if layer.z_start < cursor_z {
4934                            continue;
4935                        }
4936                        execute_render_effect_layer_to_target(
4937                            self,
4938                            target,
4939                            shapes,
4940                            images,
4941                            texts,
4942                            shadow_draws,
4943                            draw_ops,
4944                            effect_layers,
4945                            backdrop_layers,
4946                            index,
4947                            backdrop_underlay,
4948                            width,
4949                            height,
4950                            root_scale,
4951                        )?;
4952                        cursor_z = cursor_z.max(layer.z_end);
4953                    }
4954                }
4955            }
4956
4957            if cursor_z < z_end {
4958                self.render_non_effect_segment(
4959                    &target.view,
4960                    shapes,
4961                    images,
4962                    texts,
4963                    shadow_draws,
4964                    &[],
4965                    draw_ops,
4966                    cursor_z,
4967                    z_end,
4968                    &effect_z_ranges,
4969                    width,
4970                    height,
4971                    root_scale,
4972                    next_load_op,
4973                )?;
4974            } else if matches!(next_load_op, wgpu::LoadOp::Clear(_)) {
4975                self.clear_target_view_with_load_op(&target.view, next_load_op);
4976            }
4977
4978            Ok(())
4979        })();
4980
4981        self.renderer.scratch_effect_ranges = effect_z_ranges;
4982        self.renderer.scratch_layer_events = events;
4983        result
4984    }
4985
4986    #[allow(clippy::too_many_arguments)]
4987    fn record_shader_composite(
4988        &mut self,
4989        source: &OffscreenTarget,
4990        shader: &RuntimeShader,
4991        effect_rect: [f32; 4],
4992        dest_view: &wgpu::TextureView,
4993        alpha: f32,
4994        load_op: wgpu::LoadOp<wgpu::Color>,
4995        scissor: Option<(u32, u32, u32, u32)>,
4996        blend_mode: BlendMode,
4997        dest_viewport: Option<(f32, f32, f32, f32)>,
4998        sample_mode: CompositeSampleMode,
4999    ) {
5000        let device = self.renderer.device.clone();
5001        if let Some(viewport) = direct_shader_composite_viewport(
5002            alpha,
5003            blend_mode,
5004            dest_viewport,
5005            sample_mode,
5006            (source.width, source.height),
5007        ) {
5008            let shader_applied = self
5009                .renderer
5010                .effect_renderer
5011                .encode_shader_src_over_to_view(
5012                    self.recorder,
5013                    &device,
5014                    source,
5015                    dest_view,
5016                    shader,
5017                    effect_rect,
5018                    load_op,
5019                    scissor,
5020                    viewport,
5021                );
5022            if shader_applied {
5023                self.renderer
5024                    .effect_renderer
5025                    .debug_effects
5026                    .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5027                self.recorder.record_pass();
5028                self.renderer.effect_renderer.record_composite_pass();
5029                return;
5030            }
5031        }
5032        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5033            "Shader Effect Composite Scratch",
5034            source.width,
5035            source.height,
5036        );
5037        let scratch = self
5038            .recorder
5039            .acquire_transient_offscreen(&device, scratch_descriptor);
5040        let shader_applied = {
5041            self.renderer.effect_renderer.encode_shader(
5042                self.recorder,
5043                &device,
5044                source,
5045                &scratch.view,
5046                shader,
5047                effect_rect,
5048            )
5049        };
5050        let composite_source = if shader_applied {
5051            self.renderer
5052                .effect_renderer
5053                .debug_effects
5054                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5055            self.recorder.record_pass();
5056            &scratch
5057        } else {
5058            source
5059        };
5060        {
5061            self.renderer
5062                .effect_renderer
5063                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
5064                    self.recorder,
5065                    &device,
5066                    composite_source,
5067                    dest_view,
5068                    alpha,
5069                    load_op,
5070                    scissor,
5071                    None,
5072                    supported_blend_mode(blend_mode),
5073                    dest_viewport,
5074                    sample_mode,
5075                );
5076        }
5077        self.recorder.record_pass();
5078        self.renderer.effect_renderer.record_composite_pass();
5079        self.recorder
5080            .release_transient_offscreen(scratch_descriptor, scratch);
5081    }
5082
5083    #[allow(clippy::too_many_arguments)]
5084    fn record_shader_projective_composite(
5085        &mut self,
5086        source: &OffscreenTarget,
5087        shader: &RuntimeShader,
5088        effect_rect: [f32; 4],
5089        dest_view: &wgpu::TextureView,
5090        viewport: (u32, u32),
5091        source_size: (f32, f32),
5092        inverse_matrix: [[f32; 3]; 3],
5093        dest_bounds: [[f32; 2]; 4],
5094        alpha: f32,
5095        load_op: wgpu::LoadOp<wgpu::Color>,
5096        scissor: Option<(u32, u32, u32, u32)>,
5097        blend_mode: BlendMode,
5098        sample_mode: CompositeSampleMode,
5099    ) {
5100        if projective_dest_bounds_rect(dest_bounds).is_none() {
5101            return;
5102        }
5103        let device = self.renderer.device.clone();
5104        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5105            "Shader Projective Composite Scratch",
5106            source.width,
5107            source.height,
5108        );
5109        let scratch = self
5110            .recorder
5111            .acquire_transient_offscreen(&device, scratch_descriptor);
5112        let shader_applied = {
5113            self.renderer.effect_renderer.encode_shader(
5114                self.recorder,
5115                &device,
5116                source,
5117                &scratch.view,
5118                shader,
5119                effect_rect,
5120            )
5121        };
5122        let composite_source = if shader_applied {
5123            self.renderer
5124                .effect_renderer
5125                .debug_effects
5126                .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5127            self.recorder.record_pass();
5128            &scratch
5129        } else {
5130            source
5131        };
5132        let composited = {
5133            self.renderer
5134                .effect_renderer
5135                .encode_composite_to_view_projective(
5136                    self.recorder,
5137                    &device,
5138                    composite_source,
5139                    dest_view,
5140                    viewport,
5141                    source_size,
5142                    inverse_matrix,
5143                    dest_bounds,
5144                    alpha,
5145                    load_op,
5146                    scissor,
5147                    supported_blend_mode(blend_mode),
5148                    sample_mode,
5149                )
5150        };
5151        if composited {
5152            self.recorder.record_pass();
5153            self.renderer.effect_renderer.record_composite_pass();
5154        }
5155        self.recorder
5156            .release_transient_offscreen(scratch_descriptor, scratch);
5157    }
5158
5159    #[allow(clippy::too_many_arguments)]
5160    fn record_effect_with_direct_shader_tail_composite(
5161        &mut self,
5162        source: &OffscreenTarget,
5163        first_effect: &RenderEffect,
5164        shader: &RuntimeShader,
5165        effect_rect: [f32; 4],
5166        dest_view: &wgpu::TextureView,
5167        load_op: wgpu::LoadOp<wgpu::Color>,
5168        scissor: Option<(u32, u32, u32, u32)>,
5169        dest_viewport: (f32, f32, f32, f32),
5170    ) -> Result<bool, String> {
5171        let device = self.renderer.device.clone();
5172        let intermediate_descriptor = self.renderer.transient_offscreen_descriptor(
5173            "Render Effect Direct Shader Tail Intermediate",
5174            source.width,
5175            source.height,
5176        );
5177        let intermediate = self
5178            .recorder
5179            .acquire_transient_offscreen(&device, intermediate_descriptor);
5180        let effect_scratch_targets = self
5181            .renderer
5182            .effect_renderer
5183            .acquire_recorded_effect_scratch_targets(
5184                self.recorder,
5185                &device,
5186                first_effect,
5187                source.width,
5188                source.height,
5189                self.renderer.surface_format,
5190            );
5191        let first_passes = {
5192            let mut effect_scratch_refs = effect_scratch_targets.refs();
5193            let pass_count = self.renderer.effect_renderer.encode_effect(
5194                self.recorder,
5195                &device,
5196                source,
5197                &intermediate.view,
5198                first_effect,
5199                effect_rect,
5200                &mut effect_scratch_refs,
5201            );
5202            match pass_count {
5203                Ok(pass_count) => effect_scratch_refs.assert_consumed().map(|()| pass_count),
5204                Err(error) => Err(error),
5205            }
5206        };
5207        let first_passes = match first_passes {
5208            Ok(pass_count) => pass_count,
5209            Err(error) => {
5210                effect_scratch_targets.release_into(self.recorder);
5211                self.recorder
5212                    .release_transient_offscreen(intermediate_descriptor, intermediate);
5213                return Err(error);
5214            }
5215        };
5216        let shader_applied = self
5217            .renderer
5218            .effect_renderer
5219            .encode_shader_src_over_to_view(
5220                self.recorder,
5221                &device,
5222                &intermediate,
5223                dest_view,
5224                shader,
5225                effect_rect,
5226                load_op,
5227                scissor,
5228                dest_viewport,
5229            );
5230        self.recorder
5231            .record_passes(first_passes.saturating_add(u32::from(shader_applied)));
5232        effect_scratch_targets.release_into(self.recorder);
5233        self.recorder
5234            .release_transient_offscreen(intermediate_descriptor, intermediate);
5235        if !shader_applied {
5236            return Ok(false);
5237        }
5238        self.renderer
5239            .effect_renderer
5240            .debug_effects
5241            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5242        self.renderer.effect_renderer.record_composite_pass();
5243        Ok(true)
5244    }
5245
5246    #[allow(clippy::too_many_arguments)]
5247    fn record_effect_composite(
5248        &mut self,
5249        source: &OffscreenTarget,
5250        effect: &RenderEffect,
5251        effect_rect: [f32; 4],
5252        dest_view: &wgpu::TextureView,
5253        alpha: f32,
5254        load_op: wgpu::LoadOp<wgpu::Color>,
5255        scissor: Option<(u32, u32, u32, u32)>,
5256        blend_mode: BlendMode,
5257        dest_viewport: Option<(f32, f32, f32, f32)>,
5258        sample_mode: CompositeSampleMode,
5259    ) -> Result<(), String> {
5260        if let (
5261            RenderEffect::Chain { first, second },
5262            Some(viewport),
5263            BlendMode::SrcOver,
5264            CompositeSampleMode::Linear,
5265        ) = (
5266            effect,
5267            dest_viewport,
5268            supported_blend_mode(blend_mode),
5269            sample_mode,
5270        ) {
5271            if let (
5272                RenderEffect::Blur {
5273                    radius_x,
5274                    radius_y,
5275                    edge_treatment,
5276                },
5277                RenderEffect::Shader { shader },
5278            ) = (first.as_ref(), second.as_ref())
5279            {
5280                if *radius_x > 0.0 || *radius_y > 0.0 {
5281                    let device = self.renderer.device.clone();
5282                    let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5283                        "Blur Rounded Mask Scratch",
5284                        source.width,
5285                        source.height,
5286                    );
5287                    let scratch = self
5288                        .recorder
5289                        .acquire_transient_offscreen(&device, scratch_descriptor);
5290                    let fused = self
5291                        .renderer
5292                        .effect_renderer
5293                        .encode_blur_then_rounded_mask_src_over_to_view(
5294                            self.recorder,
5295                            &device,
5296                            source,
5297                            &scratch,
5298                            dest_view,
5299                            *radius_x,
5300                            *radius_y,
5301                            *edge_treatment,
5302                            shader,
5303                            effect_rect,
5304                            load_op,
5305                            scissor,
5306                            viewport,
5307                        );
5308                    if fused {
5309                        self.recorder.record_passes(2);
5310                        self.renderer.effect_renderer.record_blur_pass();
5311                        self.renderer
5312                            .effect_renderer
5313                            .debug_effects
5314                            .set(self.renderer.effect_renderer.debug_effects.get() + 1);
5315                        self.renderer.effect_renderer.record_composite_pass();
5316                        self.recorder
5317                            .release_transient_offscreen(scratch_descriptor, scratch);
5318                        return Ok(());
5319                    }
5320                    self.recorder
5321                        .release_transient_offscreen(scratch_descriptor, scratch);
5322                }
5323            }
5324        }
5325        if let Some((first_effect, shader, viewport)) = direct_shader_tail_composite(
5326            effect,
5327            alpha,
5328            blend_mode,
5329            dest_viewport,
5330            sample_mode,
5331            (source.width, source.height),
5332        ) {
5333            if self.record_effect_with_direct_shader_tail_composite(
5334                source,
5335                first_effect,
5336                shader,
5337                effect_rect,
5338                dest_view,
5339                load_op,
5340                scissor,
5341                viewport,
5342            )? {
5343                return Ok(());
5344            }
5345        }
5346        let device = self.renderer.device.clone();
5347        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5348            "Render Effect Composite Scratch",
5349            source.width,
5350            source.height,
5351        );
5352        let scratch = self
5353            .recorder
5354            .acquire_transient_offscreen(&device, scratch_descriptor);
5355        let effect_scratch_targets = self
5356            .renderer
5357            .effect_renderer
5358            .acquire_recorded_effect_scratch_targets(
5359                self.recorder,
5360                &device,
5361                effect,
5362                source.width,
5363                source.height,
5364                self.renderer.surface_format,
5365            );
5366        let effect_passes = {
5367            let mut effect_scratch_refs = effect_scratch_targets.refs();
5368            let pass_count = self.renderer.effect_renderer.encode_effect(
5369                self.recorder,
5370                &device,
5371                source,
5372                &scratch.view,
5373                effect,
5374                effect_rect,
5375                &mut effect_scratch_refs,
5376            )?;
5377            effect_scratch_refs.assert_consumed()?;
5378            Ok(pass_count)
5379        };
5380        let effect_passes = match effect_passes {
5381            Ok(pass_count) => pass_count,
5382            Err(error) => {
5383                effect_scratch_targets.release_into(self.recorder);
5384                self.recorder
5385                    .release_transient_offscreen(scratch_descriptor, scratch);
5386                return Err(error);
5387            }
5388        };
5389        {
5390            self.renderer
5391                .effect_renderer
5392                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
5393                    self.recorder,
5394                    &device,
5395                    &scratch,
5396                    dest_view,
5397                    alpha,
5398                    load_op,
5399                    scissor,
5400                    None,
5401                    supported_blend_mode(blend_mode),
5402                    dest_viewport,
5403                    sample_mode,
5404                );
5405        }
5406        self.recorder.record_passes(effect_passes.saturating_add(1));
5407        self.renderer.effect_renderer.record_composite_pass();
5408        effect_scratch_targets.release_into(self.recorder);
5409        self.recorder
5410            .release_transient_offscreen(scratch_descriptor, scratch);
5411        Ok(())
5412    }
5413
5414    #[allow(clippy::too_many_arguments)]
5415    fn record_effect_projective_composite(
5416        &mut self,
5417        source: &OffscreenTarget,
5418        effect: &RenderEffect,
5419        effect_rect: [f32; 4],
5420        dest_view: &wgpu::TextureView,
5421        viewport: (u32, u32),
5422        source_size: (f32, f32),
5423        inverse_matrix: [[f32; 3]; 3],
5424        dest_bounds: [[f32; 2]; 4],
5425        alpha: f32,
5426        load_op: wgpu::LoadOp<wgpu::Color>,
5427        scissor: Option<(u32, u32, u32, u32)>,
5428        blend_mode: BlendMode,
5429        sample_mode: CompositeSampleMode,
5430    ) -> Result<(), String> {
5431        if projective_dest_bounds_rect(dest_bounds).is_none() {
5432            return Ok(());
5433        }
5434        let device = self.renderer.device.clone();
5435        let scratch_descriptor = self.renderer.transient_offscreen_descriptor(
5436            "Render Effect Projective Composite Scratch",
5437            source.width,
5438            source.height,
5439        );
5440        let scratch = self
5441            .recorder
5442            .acquire_transient_offscreen(&device, scratch_descriptor);
5443        let effect_scratch_targets = self
5444            .renderer
5445            .effect_renderer
5446            .acquire_recorded_effect_scratch_targets(
5447                self.recorder,
5448                &device,
5449                effect,
5450                source.width,
5451                source.height,
5452                self.renderer.surface_format,
5453            );
5454        let effect_passes = {
5455            let mut effect_scratch_refs = effect_scratch_targets.refs();
5456            let pass_count = self.renderer.effect_renderer.encode_effect(
5457                self.recorder,
5458                &device,
5459                source,
5460                &scratch.view,
5461                effect,
5462                effect_rect,
5463                &mut effect_scratch_refs,
5464            )?;
5465            effect_scratch_refs.assert_consumed()?;
5466            Ok(pass_count)
5467        };
5468        let effect_passes = match effect_passes {
5469            Ok(pass_count) => pass_count,
5470            Err(error) => {
5471                effect_scratch_targets.release_into(self.recorder);
5472                self.recorder
5473                    .release_transient_offscreen(scratch_descriptor, scratch);
5474                return Err(error);
5475            }
5476        };
5477        let composited = {
5478            self.renderer
5479                .effect_renderer
5480                .encode_composite_to_view_projective(
5481                    self.recorder,
5482                    &device,
5483                    &scratch,
5484                    dest_view,
5485                    viewport,
5486                    source_size,
5487                    inverse_matrix,
5488                    dest_bounds,
5489                    alpha,
5490                    load_op,
5491                    scissor,
5492                    supported_blend_mode(blend_mode),
5493                    sample_mode,
5494                )
5495        };
5496        if composited {
5497            self.recorder.record_passes(effect_passes.saturating_add(1));
5498            self.renderer.effect_renderer.record_composite_pass();
5499        } else {
5500            self.recorder.record_passes(effect_passes);
5501        }
5502        effect_scratch_targets.release_into(self.recorder);
5503        self.recorder
5504            .release_transient_offscreen(scratch_descriptor, scratch);
5505        Ok(())
5506    }
5507}
5508
5509impl<C: FrameCommandRecorder> SurfaceExecutionBackend for RecordingSurfaceBackend<'_, '_, C> {
5510    fn max_texture_dim(&self) -> u32 {
5511        self.renderer.max_texture_dim()
5512    }
5513
5514    fn acquire_retained_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
5515        self.renderer.acquire_retained_surface(width, height)
5516    }
5517
5518    fn acquire_frame_surface(&mut self, width: u32, height: u32) -> OffscreenTarget {
5519        let descriptor =
5520            self.renderer
5521                .transient_offscreen_descriptor("Frame Surface", width, height);
5522        self.recorder
5523            .acquire_transient_offscreen(&self.renderer.device, descriptor)
5524    }
5525
5526    fn release_frame_surface(&mut self, target: OffscreenTarget) {
5527        let descriptor = self.renderer.transient_offscreen_descriptor(
5528            "Frame Surface",
5529            target.width,
5530            target.height,
5531        );
5532        self.recorder
5533            .release_transient_offscreen(descriptor, target);
5534    }
5535
5536    fn release_layer_surface_target(&mut self, target: LayerSurfaceTexture) {
5537        self.renderer.release_layer_surface_target(target);
5538    }
5539
5540    fn cached_layer_surface(
5541        &mut self,
5542        key: &LayerRasterCacheKey,
5543    ) -> Option<(Rc<OffscreenTarget>, Rect)> {
5544        self.renderer.cached_layer_surface(key)
5545    }
5546
5547    fn admit_layer_surface_cache_miss(&mut self, key: &LayerRasterCacheKey) -> bool {
5548        self.renderer.admit_layer_surface_cache_miss(key)
5549    }
5550
5551    fn insert_cached_layer_surface(
5552        &mut self,
5553        key: LayerRasterCacheKey,
5554        target: OffscreenTarget,
5555        logical_rect: Rect,
5556    ) -> Rc<OffscreenTarget> {
5557        self.renderer
5558            .insert_cached_layer_surface(key, target, logical_rect)
5559    }
5560
5561    fn clear_target_view_with_load_op(
5562        &mut self,
5563        target_view: &wgpu::TextureView,
5564        load_op: wgpu::LoadOp<wgpu::Color>,
5565    ) {
5566        {
5567            let _clear = self
5568                .recorder
5569                .encoder()
5570                .begin_render_pass(&wgpu::RenderPassDescriptor {
5571                    label: Some("Layer Event Clear Pass"),
5572                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
5573                        view: target_view,
5574                        resolve_target: None,
5575                        depth_slice: None,
5576                        ops: wgpu::Operations {
5577                            load: load_op,
5578                            store: wgpu::StoreOp::Store,
5579                        },
5580                    })],
5581                    depth_stencil_attachment: None,
5582                    timestamp_writes: None,
5583                    occlusion_query_set: None,
5584                    multiview_mask: None,
5585                });
5586        }
5587        self.recorder.record_pass();
5588    }
5589
5590    #[allow(clippy::too_many_arguments)]
5591    fn render_non_effect_segment(
5592        &mut self,
5593        target_view: &wgpu::TextureView,
5594        shapes: &[DrawShape],
5595        images: &[ImageDraw],
5596        texts: &[TextDraw],
5597        shadow_draws: &[ShadowDraw],
5598        retained_draws: &[RetainedDraw],
5599        draw_ops: &[DrawOp],
5600        z_start: usize,
5601        z_end: usize,
5602        effect_z_ranges: &[Range<usize>],
5603        width: u32,
5604        height: u32,
5605        root_scale: f32,
5606        initial_load_op: wgpu::LoadOp<wgpu::Color>,
5607    ) -> Result<(), String> {
5608        self.render_non_effect_segment_with_composites(
5609            target_view,
5610            shapes,
5611            images,
5612            texts,
5613            shadow_draws,
5614            retained_draws,
5615            draw_ops,
5616            z_start,
5617            z_end,
5618            effect_z_ranges,
5619            &[],
5620            &[],
5621            width,
5622            height,
5623            root_scale,
5624            initial_load_op,
5625        )
5626    }
5627
5628    #[allow(clippy::too_many_arguments)]
5629    fn render_non_effect_segment_with_composites(
5630        &mut self,
5631        target_view: &wgpu::TextureView,
5632        shapes: &[DrawShape],
5633        images: &[ImageDraw],
5634        texts: &[TextDraw],
5635        shadow_draws: &[ShadowDraw],
5636        retained_draws: &[RetainedDraw],
5637        draw_ops: &[DrawOp],
5638        z_start: usize,
5639        z_end: usize,
5640        effect_z_ranges: &[Range<usize>],
5641        composites: &[(usize, CompositeBatchItem<'_>)],
5642        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
5643        width: u32,
5644        height: u32,
5645        root_scale: f32,
5646        initial_load_op: wgpu::LoadOp<wgpu::Color>,
5647    ) -> Result<(), String> {
5648        let mut ordered_items = std::mem::take(&mut self.renderer.scratch_segment_items);
5649        collect_non_effect_segment_items(
5650            shapes,
5651            images,
5652            texts,
5653            shadow_draws,
5654            draw_ops,
5655            z_start,
5656            z_end,
5657            effect_z_ranges,
5658            width,
5659            height,
5660            root_scale,
5661            &mut ordered_items,
5662        );
5663        #[cfg(not(target_arch = "wasm32"))]
5664        let raw_shadow_items = ordered_items
5665            .iter()
5666            .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
5667            .count();
5668        let culled_shadow_items = retain_renderable_shadow_items(
5669            &mut ordered_items,
5670            shadow_draws,
5671            width,
5672            height,
5673            root_scale,
5674            self.renderer.max_texture_dim(),
5675        );
5676        #[cfg(target_arch = "wasm32")]
5677        let _ = culled_shadow_items;
5678        let mut cached_shadow_composites: Vec<(usize, CachedShadowComposite)> = Vec::new();
5679        ordered_items.extend(
5680            composites
5681                .iter()
5682                .enumerate()
5683                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::Composite(index))),
5684        );
5685        ordered_items.extend(
5686            shader_composites
5687                .iter()
5688                .enumerate()
5689                .map(|(index, (z_index, _))| (*z_index, SegmentDrawItem::ShaderComposite(index))),
5690        );
5691        for (z_index, item) in &mut ordered_items {
5692            let SegmentDrawItem::Shadow(shadow_index) = *item else {
5693                continue;
5694            };
5695            let Some(composite) = self.renderer.cached_shape_shadow_composite(
5696                &shadow_draws[shadow_index],
5697                width,
5698                height,
5699                root_scale,
5700            ) else {
5701                continue;
5702            };
5703            let composite_index = composites.len() + cached_shadow_composites.len();
5704            cached_shadow_composites.push((*z_index, composite));
5705            *item = SegmentDrawItem::Composite(composite_index);
5706        }
5707        let mut merged_composites = Vec::with_capacity(
5708            composites
5709                .len()
5710                .saturating_add(cached_shadow_composites.len()),
5711        );
5712        merged_composites.extend(composites.iter().copied());
5713        merged_composites.extend(
5714            cached_shadow_composites
5715                .iter()
5716                .map(|(z_index, composite)| (*z_index, composite.batch_item())),
5717        );
5718        // Z indices are unique — the scene hands every op its own `next_z` — so an
5719        // unstable sort cannot reorder anything a stable one wouldn't, and it skips
5720        // the stable sort's scratch allocation, paid here once per segment per frame.
5721        ordered_items.sort_unstable_by_key(|(z_index, _)| *z_index);
5722        #[cfg(not(target_arch = "wasm32"))]
5723        maybe_print_segment_diag(
5724            z_start..z_end,
5725            &ordered_items,
5726            shapes,
5727            images,
5728            SegmentDiagCounts {
5729                raw_shadow_items,
5730                culled_shadow_items,
5731                cached_shadow_composites: cached_shadow_composites.len(),
5732                composite_items: merged_composites.len(),
5733                shader_composite_items: shader_composites.len(),
5734            },
5735            self.renderer.shape_batch_limits,
5736        );
5737        let result = if ordered_items.is_empty() {
5738            Ok(SegmentCommandEncodeOutcome { first_batch: true })
5739        } else {
5740            self.renderer.encode_non_effect_segment_commands(
5741                self.recorder,
5742                target_view,
5743                &ordered_items,
5744                &merged_composites,
5745                shader_composites,
5746                shapes,
5747                images,
5748                texts,
5749                shadow_draws,
5750                retained_draws,
5751                initial_load_op,
5752                width,
5753                height,
5754                root_scale,
5755            )
5756        };
5757        self.renderer.scratch_segment_items = ordered_items;
5758        let outcome = result?;
5759        if outcome.first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
5760            self.clear_target_view_with_load_op(target_view, initial_load_op);
5761        }
5762        Ok(())
5763    }
5764
5765    fn render_range_with_layer_events_to_target(
5766        &mut self,
5767        target: &OffscreenTarget,
5768        shapes: &[DrawShape],
5769        images: &[ImageDraw],
5770        texts: &[TextDraw],
5771        shadow_draws: &[ShadowDraw],
5772        draw_ops: &[DrawOp],
5773        effect_layers: &[EffectLayer],
5774        backdrop_layers: &[BackdropLayer],
5775        z_start: usize,
5776        z_end: usize,
5777        excluded_effect_layer: Option<usize>,
5778        width: u32,
5779        height: u32,
5780        root_scale: f32,
5781        backdrop_underlay: Option<&OffscreenTarget>,
5782        initial_load_op: wgpu::LoadOp<wgpu::Color>,
5783    ) -> Result<(), String> {
5784        self.render_range_with_layer_events_to_target_recorded(
5785            target,
5786            shapes,
5787            images,
5788            texts,
5789            shadow_draws,
5790            draw_ops,
5791            effect_layers,
5792            backdrop_layers,
5793            z_start,
5794            z_end,
5795            excluded_effect_layer,
5796            width,
5797            height,
5798            root_scale,
5799            backdrop_underlay,
5800            initial_load_op,
5801        )
5802    }
5803
5804    fn render_shadow_draw(
5805        &mut self,
5806        target_view: &wgpu::TextureView,
5807        shadow: &ShadowDraw,
5808        width: u32,
5809        height: u32,
5810        root_scale: f32,
5811    ) {
5812        self.renderer.encode_shadow_draw(
5813            self.recorder,
5814            target_view,
5815            shadow,
5816            width,
5817            height,
5818            root_scale,
5819        );
5820    }
5821
5822    fn composite_to_view_projective(
5823        &mut self,
5824        source: &OffscreenTarget,
5825        dest_view: &wgpu::TextureView,
5826        viewport: (u32, u32),
5827        source_size: (f32, f32),
5828        inverse_matrix: [[f32; 3]; 3],
5829        dest_bounds: [[f32; 2]; 4],
5830        alpha: f32,
5831        load_op: wgpu::LoadOp<wgpu::Color>,
5832        scissor: Option<(u32, u32, u32, u32)>,
5833        blend_mode: BlendMode,
5834        sample_mode: CompositeSampleMode,
5835    ) {
5836        let device = self.renderer.device.clone();
5837        let composited = {
5838            self.renderer
5839                .effect_renderer
5840                .encode_composite_to_view_projective(
5841                    self.recorder,
5842                    &device,
5843                    source,
5844                    dest_view,
5845                    viewport,
5846                    source_size,
5847                    inverse_matrix,
5848                    dest_bounds,
5849                    alpha,
5850                    load_op,
5851                    scissor,
5852                    supported_blend_mode(blend_mode),
5853                    sample_mode,
5854                )
5855        };
5856        if composited {
5857            self.recorder.record_pass();
5858            self.renderer.effect_renderer.record_composite_pass();
5859        }
5860    }
5861
5862    fn composite_projective_surfaces_to_view(
5863        &mut self,
5864        dest_view: &wgpu::TextureView,
5865        viewport: (u32, u32),
5866        composites: &[ProjectiveSurfaceComposite<'_>],
5867    ) {
5868        let device = self.renderer.device.clone();
5869        let mut composite_count = 0_u32;
5870        for composite in composites
5871            .iter()
5872            .copied()
5873            .filter(|composite| projective_dest_bounds_rect(composite.dest_bounds).is_some())
5874        {
5875            let composited = {
5876                self.renderer
5877                    .effect_renderer
5878                    .encode_composite_to_view_projective(
5879                        self.recorder,
5880                        &device,
5881                        composite.source,
5882                        dest_view,
5883                        viewport,
5884                        composite.source_size,
5885                        composite.inverse_matrix,
5886                        composite.dest_bounds,
5887                        composite.alpha,
5888                        composite.load_op,
5889                        composite.scissor,
5890                        supported_blend_mode(composite.blend_mode),
5891                        composite.sample_mode,
5892                    )
5893            };
5894            if composited {
5895                composite_count = composite_count.saturating_add(1);
5896            }
5897        }
5898        if composite_count > 0 {
5899            self.recorder.record_passes(composite_count);
5900            self.renderer
5901                .effect_renderer
5902                .debug_composites
5903                .set(self.renderer.effect_renderer.debug_composites.get() + composite_count);
5904        }
5905    }
5906
5907    fn composite_surface_batch_to_view(
5908        &mut self,
5909        dest_view: &wgpu::TextureView,
5910        viewport: (u32, u32),
5911        load_op: wgpu::LoadOp<wgpu::Color>,
5912        composites: &[CompositeBatchItem<'_>],
5913    ) {
5914        if composites.is_empty() {
5915            return;
5916        }
5917        let device = self.renderer.device.clone();
5918        self.renderer
5919            .effect_renderer
5920            .encode_composite_batch_to_view_pass(
5921                self.recorder,
5922                &device,
5923                dest_view,
5924                viewport,
5925                load_op,
5926                composites,
5927            );
5928        self.recorder.record_pass();
5929        self.renderer.effect_renderer.record_composite_pass();
5930    }
5931
5932    fn copy_texture_region_to_target(
5933        &mut self,
5934        source: &OffscreenTarget,
5935        source_origin: (u32, u32),
5936        target: &OffscreenTarget,
5937        size: (u32, u32),
5938    ) -> bool {
5939        let (width, height) = size;
5940        if width == 0 || height == 0 || width > target.width || height > target.height {
5941            return false;
5942        }
5943        let Some(source_right) = source_origin.0.checked_add(width) else {
5944            return false;
5945        };
5946        let Some(source_bottom) = source_origin.1.checked_add(height) else {
5947            return false;
5948        };
5949        if source_right > source.width || source_bottom > source.height {
5950            return false;
5951        }
5952
5953        self.recorder.encoder().copy_texture_to_texture(
5954            wgpu::TexelCopyTextureInfo {
5955                texture: source.texture(),
5956                mip_level: 0,
5957                origin: wgpu::Origin3d {
5958                    x: source_origin.0,
5959                    y: source_origin.1,
5960                    z: 0,
5961                },
5962                aspect: wgpu::TextureAspect::All,
5963            },
5964            wgpu::TexelCopyTextureInfo {
5965                texture: target.texture(),
5966                mip_level: 0,
5967                origin: wgpu::Origin3d::ZERO,
5968                aspect: wgpu::TextureAspect::All,
5969            },
5970            wgpu::Extent3d {
5971                width,
5972                height,
5973                depth_or_array_layers: 1,
5974            },
5975        );
5976        true
5977    }
5978
5979    fn shader_composite_batch_to_view(
5980        &mut self,
5981        dest_view: &wgpu::TextureView,
5982        viewport: (u32, u32),
5983        load_op: wgpu::LoadOp<wgpu::Color>,
5984        composites: &[ShaderCompositeBatchItem<'_>],
5985    ) -> bool {
5986        if composites.is_empty() {
5987            return true;
5988        }
5989        let device = self.renderer.device.clone();
5990        let encoded = self
5991            .renderer
5992            .effect_renderer
5993            .encode_shader_batch_src_over_to_view(
5994                self.recorder,
5995                &device,
5996                dest_view,
5997                viewport,
5998                load_op,
5999                composites,
6000            );
6001        if encoded {
6002            self.recorder.record_pass();
6003            self.renderer.effect_renderer.record_composite_pass();
6004            self.renderer
6005                .effect_renderer
6006                .debug_effects
6007                .set(self.renderer.effect_renderer.debug_effects.get() + composites.len() as u32);
6008        }
6009        encoded
6010    }
6011
6012    fn composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
6013        &mut self,
6014        source: &OffscreenTarget,
6015        dest_view: &wgpu::TextureView,
6016        alpha: f32,
6017        load_op: wgpu::LoadOp<wgpu::Color>,
6018        scissor: Option<(u32, u32, u32, u32)>,
6019        rounded_mask: Option<RoundedCompositeMask>,
6020        blend_mode: BlendMode,
6021        dest_viewport: Option<(f32, f32, f32, f32)>,
6022        sample_mode: CompositeSampleMode,
6023    ) {
6024        let device = self.renderer.device.clone();
6025        {
6026            self.renderer
6027                .effect_renderer
6028                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
6029                    self.recorder,
6030                    &device,
6031                    source,
6032                    dest_view,
6033                    alpha,
6034                    load_op,
6035                    scissor,
6036                    rounded_mask,
6037                    supported_blend_mode(blend_mode),
6038                    dest_viewport,
6039                    sample_mode,
6040                );
6041        }
6042        self.recorder.record_pass();
6043        self.renderer.effect_renderer.record_composite_pass();
6044    }
6045
6046    fn apply_effect_and_composite_to_view(
6047        &mut self,
6048        source: &OffscreenTarget,
6049        effect: &RenderEffect,
6050        effect_rect: [f32; 4],
6051        dest_view: &wgpu::TextureView,
6052        alpha: f32,
6053        load_op: wgpu::LoadOp<wgpu::Color>,
6054        scissor: Option<(u32, u32, u32, u32)>,
6055        blend_mode: BlendMode,
6056        dest_viewport: Option<(f32, f32, f32, f32)>,
6057        sample_mode: CompositeSampleMode,
6058    ) -> Result<(), String> {
6059        self.record_effect_composite(
6060            source,
6061            effect,
6062            effect_rect,
6063            dest_view,
6064            alpha,
6065            load_op,
6066            scissor,
6067            blend_mode,
6068            dest_viewport,
6069            sample_mode,
6070        )
6071    }
6072
6073    fn apply_shader_and_composite_to_view(
6074        &mut self,
6075        source: &OffscreenTarget,
6076        shader: &RuntimeShader,
6077        effect_rect: [f32; 4],
6078        dest_view: &wgpu::TextureView,
6079        alpha: f32,
6080        load_op: wgpu::LoadOp<wgpu::Color>,
6081        scissor: Option<(u32, u32, u32, u32)>,
6082        blend_mode: BlendMode,
6083        dest_viewport: Option<(f32, f32, f32, f32)>,
6084        sample_mode: CompositeSampleMode,
6085    ) {
6086        self.record_shader_composite(
6087            source,
6088            shader,
6089            effect_rect,
6090            dest_view,
6091            alpha,
6092            load_op,
6093            scissor,
6094            blend_mode,
6095            dest_viewport,
6096            sample_mode,
6097        );
6098    }
6099
6100    fn apply_shader_and_composite_to_view_projective(
6101        &mut self,
6102        source: &OffscreenTarget,
6103        shader: &RuntimeShader,
6104        effect_rect: [f32; 4],
6105        dest_view: &wgpu::TextureView,
6106        viewport: (u32, u32),
6107        source_size: (f32, f32),
6108        inverse_matrix: [[f32; 3]; 3],
6109        dest_bounds: [[f32; 2]; 4],
6110        alpha: f32,
6111        load_op: wgpu::LoadOp<wgpu::Color>,
6112        scissor: Option<(u32, u32, u32, u32)>,
6113        blend_mode: BlendMode,
6114        sample_mode: CompositeSampleMode,
6115    ) {
6116        self.record_shader_projective_composite(
6117            source,
6118            shader,
6119            effect_rect,
6120            dest_view,
6121            viewport,
6122            source_size,
6123            inverse_matrix,
6124            dest_bounds,
6125            alpha,
6126            load_op,
6127            scissor,
6128            blend_mode,
6129            sample_mode,
6130        );
6131    }
6132
6133    fn apply_effect_and_composite_to_view_projective(
6134        &mut self,
6135        source: &OffscreenTarget,
6136        effect: &RenderEffect,
6137        effect_rect: [f32; 4],
6138        dest_view: &wgpu::TextureView,
6139        viewport: (u32, u32),
6140        source_size: (f32, f32),
6141        inverse_matrix: [[f32; 3]; 3],
6142        dest_bounds: [[f32; 2]; 4],
6143        alpha: f32,
6144        load_op: wgpu::LoadOp<wgpu::Color>,
6145        scissor: Option<(u32, u32, u32, u32)>,
6146        blend_mode: BlendMode,
6147        sample_mode: CompositeSampleMode,
6148    ) -> Result<(), String> {
6149        self.record_effect_projective_composite(
6150            source,
6151            effect,
6152            effect_rect,
6153            dest_view,
6154            viewport,
6155            source_size,
6156            inverse_matrix,
6157            dest_bounds,
6158            alpha,
6159            load_op,
6160            scissor,
6161            blend_mode,
6162            sample_mode,
6163        )
6164    }
6165
6166    fn is_render_effect_supported(&self, effect: &RenderEffect) -> bool {
6167        self.renderer.supports_render_effect(effect)
6168    }
6169
6170    fn warn_unsupported_effect_once(&self) {
6171        self.renderer.warning_state.warn_unsupported_effect_once();
6172    }
6173
6174    fn record_layer_cache_miss(&self, width: u32, height: u32) {
6175        self.renderer
6176            .frame_stats
6177            .record_layer_cache_miss(width, height);
6178    }
6179
6180    fn record_isolated_layer_render(
6181        &self,
6182        width: u32,
6183        height: u32,
6184        node_id: Option<NodeId>,
6185        logical_rect: Rect,
6186        requirements: SurfaceRequirementSet,
6187    ) {
6188        self.renderer.frame_stats.record_isolated_layer_render(
6189            width,
6190            height,
6191            node_id,
6192            logical_rect,
6193            requirements.into(),
6194        );
6195    }
6196}
6197
6198impl GpuRenderer {
6199    pub fn render(
6200        &mut self,
6201        view: &wgpu::TextureView,
6202        width: u32,
6203        height: u32,
6204        packet: FramePacket,
6205        surface_epoch: u64,
6206        returns: &mut RenderReturns,
6207    ) -> Result<(), String> {
6208        // Packet validity gate — BEFORE consume_replay_ops and any
6209        // encoding. A packet built against another renderer instance,
6210        // another surface configuration, or another viewport is cancelled
6211        // whole: its buffers travel back through `returns` for re-queue
6212        // and recycling, and nothing of it reaches the GPU.
6213        let cancel_reason = if packet.renderer_epoch != self.renderer_epoch {
6214            Some(CancelReason::RendererEpoch)
6215        } else if packet.surface_epoch != surface_epoch {
6216            Some(CancelReason::SurfaceEpoch)
6217        } else if packet.viewport != (width, height) {
6218            Some(CancelReason::Viewport)
6219        } else {
6220            None
6221        };
6222        if let Some(reason) = cancel_reason {
6223            return Self::cancel_packet(packet, reason, returns);
6224        }
6225        returns.frame_id = packet.frame_id;
6226        log::trace!("🎨 Rendering graph to {}x{}", width, height);
6227        let render_start = Instant::now();
6228
6229        #[cfg(target_arch = "wasm32")]
6230        {
6231            self.wasm_uniform_batch_cursor = 0;
6232            self.wasm_shape_batch_cursor = 0;
6233            self.wasm_image_batch_cursor = 0;
6234        }
6235        #[cfg(not(target_arch = "wasm32"))]
6236        {
6237            self.retained_glyph_uniform_cursor = 0;
6238        }
6239
6240        // Producer-side text layout cache size, carried by the packet — the
6241        // present call tree holds no text layout state, and no layout runs
6242        // between packet build and the stats block below.
6243        let text_cache_len = packet.text_cache_len;
6244        let result = self.render_graph(view, packet, returns);
6245        let after_graph = Instant::now();
6246        self.flush_deferred_offscreen_releases();
6247
6248        #[cfg(target_arch = "wasm32")]
6249        {
6250            const WASM_BATCH_POOL_MARGIN: usize = 4;
6251            self.wasm_uniform_batches.truncate(
6252                self.wasm_uniform_batch_cursor
6253                    .saturating_add(WASM_BATCH_POOL_MARGIN),
6254            );
6255            self.wasm_shape_batches.truncate(
6256                self.wasm_shape_batch_cursor
6257                    .saturating_add(WASM_BATCH_POOL_MARGIN),
6258            );
6259            self.wasm_image_batches.truncate(
6260                self.wasm_image_batch_cursor
6261                    .saturating_add(WASM_BATCH_POOL_MARGIN),
6262            );
6263        }
6264        self.staged_uploads
6265            .shrink_retained_capacity(RETAINED_STAGED_UPLOAD_BYTES, RETAINED_STAGED_UPLOAD_COPIES);
6266
6267        self.layer_surface_cache.finish_frame(&self.frame_stats);
6268        #[cfg(not(target_arch = "wasm32"))]
6269        self.retained_bundle_cache.end_frame();
6270
6271        self.frame_stats.offscreen_pool_size.set(
6272            self.effect_renderer
6273                .retained_offscreen_count()
6274                .saturating_add(self.frame_graph_executor.retained_texture_count())
6275                as u32,
6276        );
6277        self.frame_stats.offscreen_pool_bytes.set(
6278            (self.effect_renderer.retained_offscreen_bytes() as u64)
6279                .saturating_add(self.frame_graph_executor.retained_texture_bytes()),
6280        );
6281        self.frame_stats
6282            .text_pool_size
6283            .set(self.text_image_cache.len() as u32);
6284        self.frame_stats
6285            .image_cache_size
6286            .set(self.image_texture_cache.len() as u32);
6287        self.frame_stats.text_cache_size.set(text_cache_len as u32);
6288        self.effect_renderer
6289            .merge_and_reset_debug_counters(&self.frame_stats);
6290        self.frame_graph_executor.reset_upload_allocators();
6291        let snapshot = self.frame_stats.snapshot();
6292        self.last_frame_stats = Some(snapshot);
6293        PRESENTED_FRAMES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6294        update_frame_warmup_budget(&mut self.pending_frame_warmup_frames, &snapshot);
6295        self.frame_stats.maybe_print_snapshot(
6296            snapshot,
6297            &mut self.frame_count,
6298            self.gpu_stats_enabled,
6299        );
6300        if self.gpu_stats_enabled && self.frame_count.is_multiple_of(60) {
6301            gpu_stats::print_gpu_memory_report(&self.device, self.frame_count);
6302        }
6303        self.frame_stats.reset();
6304        let after_stats = Instant::now();
6305        if let Some(total_ms) = should_log_wgpu_render_stage(render_start, after_stats) {
6306            log::warn!(
6307                "[wgpu-render-stage:render] total_ms={total_ms:.2} graph_ms={:.2} cleanup_stats_ms={:.2}",
6308                instant_ms(render_start, after_graph),
6309                instant_ms(after_graph, after_stats),
6310            );
6311        }
6312        if result.is_ok() {
6313            // Only a draw that actually ran may report `Presented`; an
6314            // errored draw leaves the default `NotRun`.
6315            returns.outcome = PresentOutcome::Presented;
6316        }
6317        result
6318    }
6319
6320    /// Refuses a packet whole, before any encoding: every buffer it
6321    /// carries travels back through `returns` — the direct scene for the
6322    /// producer pool, the unconsumed replay plan for the planner to
6323    /// re-queue (its releases name still-live store slots; dropping them
6324    /// would leak pool ids forever). A cancel is a protocol outcome, not a
6325    /// draw error, so the render call returns `Ok(())`.
6326    fn cancel_packet(
6327        packet: FramePacket,
6328        reason: CancelReason,
6329        returns: &mut RenderReturns,
6330    ) -> Result<(), String> {
6331        let FramePacket {
6332            frame_id,
6333            viewport: _,
6334            renderer_epoch: _,
6335            surface_epoch: _,
6336            root_scale: _,
6337            root,
6338            overlay: _,
6339            replay,
6340            text_cache_len: _,
6341        } = packet;
6342        match root {
6343            PacketRoot::Direct(root) => {
6344                // Destructure: the scene buffers return to the producer
6345                // pool; the rest of the collected layer drops. A Direct
6346                // packet's replay plan came from the planner and must go
6347                // back to it unconsumed — a Surface packet only ever
6348                // carries the empty default plan, which has nothing to
6349                // reclaim.
6350                returns.scene = Some(root.scene);
6351                #[cfg(not(target_arch = "wasm32"))]
6352                {
6353                    returns.cancelled_replay = Some(replay);
6354                }
6355            }
6356            PacketRoot::Surface(_) => {}
6357        }
6358        #[cfg(target_arch = "wasm32")]
6359        let _ = replay;
6360        returns.ack = None;
6361        returns.frame_id = frame_id;
6362        returns.outcome = PresentOutcome::Cancelled(reason);
6363        Ok(())
6364    }
6365
6366    pub fn last_frame_stats(&self) -> Option<gpu_stats::FrameStatsSnapshot> {
6367        self.last_frame_stats
6368    }
6369
6370    pub fn needs_frame_warmup(&self) -> bool {
6371        self.pending_frame_warmup_frames > 0
6372    }
6373
6374    pub fn debug_cpu_allocation_stats(&self) -> DebugCpuAllocationStats {
6375        let layer_surface_cache_stats = self.layer_surface_cache.debug_stats();
6376        DebugCpuAllocationStats {
6377            scene_graph_node_count: 0,
6378            scene_graph_heap_bytes: 0,
6379            scene_hits_len: 0,
6380            scene_hits_cap: 0,
6381            scene_node_index_len: 0,
6382            scene_node_index_cap: 0,
6383            text_renderer_pool_len: self.text_image_cache.len(),
6384            text_renderer_pool_cap: self.text_image_cache.cap().get(),
6385            swash_image_cache_len: 0,
6386            swash_image_cache_cap: 0,
6387            swash_outline_cache_len: 0,
6388            swash_outline_cache_cap: 0,
6389            image_texture_cache_len: self.image_texture_cache.len(),
6390            image_texture_cache_cap: self.image_texture_cache.cap().get(),
6391            scratch_shape_data_cap: self.scratch_shape_data.capacity(),
6392            scratch_gradients_cap: self.scratch_gradients.capacity(),
6393            scratch_image_vertices_cap: self.scratch_image_vertices.capacity(),
6394            scratch_image_indices_cap: self.scratch_image_indices.capacity(),
6395            scratch_image_cmds_cap: self.scratch_image_cmds.capacity(),
6396            scratch_segment_items_cap: self.scratch_segment_items.capacity(),
6397            scratch_effect_ranges_cap: self.scratch_effect_ranges.capacity(),
6398            scratch_layer_events_cap: self.scratch_layer_events.capacity(),
6399            staged_upload_bytes_cap: self.staged_uploads.bytes.capacity(),
6400            staged_upload_copies_cap: self.staged_uploads.copies.capacity(),
6401            layer_surface_cache_len: layer_surface_cache_stats.entries_len,
6402            layer_surface_cache_cap: layer_surface_cache_stats.entries_cap,
6403            layer_surface_cache_identity_len: layer_surface_cache_stats.identity_len,
6404            layer_surface_cache_identity_cap: layer_surface_cache_stats.identity_cap,
6405            // The producer frontend owns the only lowering-memo pair since
6406            // step 6b; the present backend contributes nothing.
6407            layer_surface_rect_cache_len: 0,
6408            layer_surface_rect_cache_cap: 0,
6409            layer_surface_requirements_cache_len: 0,
6410            layer_surface_requirements_cache_cap: 0,
6411            layer_cache_seen_this_frame_len: layer_surface_cache_stats.seen_this_frame_len,
6412            layer_cache_seen_this_frame_cap: layer_surface_cache_stats.seen_this_frame_cap,
6413        }
6414    }
6415
6416    pub fn render_to_rgba_pixels(
6417        &mut self,
6418        width: u32,
6419        height: u32,
6420        packet: FramePacket,
6421        surface_epoch: u64,
6422        returns: &mut RenderReturns,
6423    ) -> Result<Vec<u8>, String> {
6424        if width == 0 || height == 0 {
6425            return Err("Screenshot size must be non-zero".to_string());
6426        }
6427
6428        let output_texture = self.device.create_texture(&wgpu::TextureDescriptor {
6429            label: Some("Screenshot Output Texture"),
6430            size: wgpu::Extent3d {
6431                width,
6432                height,
6433                depth_or_array_layers: 1,
6434            },
6435            mip_level_count: 1,
6436            sample_count: 1,
6437            dimension: wgpu::TextureDimension::D2,
6438            format: self.surface_format,
6439            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
6440            view_formats: &[],
6441        });
6442        let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
6443
6444        self.render(&output_view, width, height, packet, surface_epoch, returns)?;
6445
6446        let bytes_per_pixel = 4u32;
6447        let unpadded_bytes_per_row = width
6448            .checked_mul(bytes_per_pixel)
6449            .ok_or_else(|| "Screenshot row byte size overflow".to_string())?;
6450        let padded_bytes_per_row =
6451            align_to(unpadded_bytes_per_row, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
6452        let output_buffer_size = padded_bytes_per_row as u64 * height as u64;
6453
6454        let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
6455            label: Some("Screenshot Readback Buffer"),
6456            size: output_buffer_size,
6457            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
6458            mapped_at_creation: false,
6459        });
6460
6461        let device = self.device.clone();
6462        let queue = self.queue.clone();
6463        let mut graph = WgpuFrameGraph::new(Some("Screenshot Copy Encoder"));
6464        let source = graph.import_surface("screenshot-copy-source");
6465        graph.add_fallible_command_pass(Some("Screenshot Copy Pass"), &[source], &[], |context| {
6466            context.encoder.copy_texture_to_buffer(
6467                wgpu::TexelCopyTextureInfo {
6468                    texture: &output_texture,
6469                    mip_level: 0,
6470                    origin: wgpu::Origin3d::ZERO,
6471                    aspect: wgpu::TextureAspect::All,
6472                },
6473                wgpu::TexelCopyBufferInfo {
6474                    buffer: &output_buffer,
6475                    layout: wgpu::TexelCopyBufferLayout {
6476                        offset: 0,
6477                        bytes_per_row: Some(padded_bytes_per_row),
6478                        rows_per_image: Some(height),
6479                    },
6480                },
6481                wgpu::Extent3d {
6482                    width,
6483                    height,
6484                    depth_or_array_layers: 1,
6485                },
6486            );
6487            Ok(())
6488        });
6489        let mut executor = std::mem::take(&mut self.frame_graph_executor);
6490        let execution = executor.execute_recorded_graph(&device, &queue, graph);
6491        self.frame_graph_executor = executor;
6492        let execution = execution.map_err(|error| error.to_string())?;
6493        let submission_index = execution.submission;
6494        let copy_stats = execution.stats;
6495        self.last_frame_stats = self
6496            .last_frame_stats
6497            .map(|snapshot| snapshot.with_command_stats_added(copy_stats));
6498
6499        let buffer_slice = output_buffer.slice(..);
6500        let (tx, rx) = mpsc::channel();
6501        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
6502            let _ = tx.send(result);
6503        });
6504        let _ = self.device.poll(wgpu::PollType::Wait {
6505            submission_index: Some(submission_index),
6506            timeout: None,
6507        });
6508
6509        match rx.recv_timeout(Duration::from_secs(3)) {
6510            Ok(Ok(())) => {}
6511            Ok(Err(err)) => return Err(format!("Screenshot map_async failed: {err:?}")),
6512            Err(err) => return Err(format!("Screenshot readback timed out: {err}")),
6513        }
6514
6515        let mapped = buffer_slice.get_mapped_range();
6516        let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4];
6517
6518        let src_row_len = padded_bytes_per_row as usize;
6519        let dst_row_len = unpadded_bytes_per_row as usize;
6520        for row in 0..height as usize {
6521            let src_offset = row * src_row_len;
6522            let dst_offset = row * dst_row_len;
6523            pixels[dst_offset..dst_offset + dst_row_len]
6524                .copy_from_slice(&mapped[src_offset..src_offset + dst_row_len]);
6525        }
6526        drop(mapped);
6527        output_buffer.unmap();
6528
6529        self.convert_surface_pixels_to_rgba(&mut pixels)?;
6530        Ok(pixels)
6531    }
6532
6533    fn render_graph(
6534        &mut self,
6535        surface_view: &wgpu::TextureView,
6536        packet: FramePacket,
6537        returns: &mut RenderReturns,
6538    ) -> Result<(), String> {
6539        let device = self.device.clone();
6540        let queue = self.queue.clone();
6541        let graph_start = Instant::now();
6542
6543        #[cfg(not(target_arch = "wasm32"))]
6544        {
6545            let mut executor = std::mem::take(&mut self.frame_graph_executor);
6546            let mut frame_graph = WgpuFrameGraph::new(Some("Renderer Frame Graph"));
6547            let surface = frame_graph.import_surface("renderer-surface");
6548            frame_graph.add_fallible_recorded_command_pass(
6549                Some("Renderer Frame Pass"),
6550                &[],
6551                &[surface],
6552                |frame_encoder| {
6553                    self.render_graph_recorded(surface_view, packet, returns, frame_encoder)
6554                },
6555            );
6556            let after_build = Instant::now();
6557            let execution = executor.execute_recorded_graph(&device, &queue, frame_graph);
6558            let after_execute = Instant::now();
6559            self.frame_graph_executor = executor;
6560            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
6561                log::warn!(
6562                    "[wgpu-render-stage:graph] total_ms={total_ms:.2} build_ms={:.2} execute_ms={:.2}",
6563                    instant_ms(graph_start, after_build),
6564                    instant_ms(after_build, after_execute),
6565                );
6566            }
6567
6568            match execution {
6569                Ok(execution) => {
6570                    if execution.stats.pass_count > 0 {
6571                        self.frame_stats.record_command_stats(execution.stats);
6572                    }
6573                    Ok(())
6574                }
6575                Err(crate::frame_graph::FrameGraphError::NoDeclaredPasses) => Ok(()),
6576                Err(error) => Err(error.to_string()),
6577            }
6578        }
6579
6580        #[cfg(target_arch = "wasm32")]
6581        {
6582            let mut executor = std::mem::take(&mut self.frame_graph_executor);
6583            let (result, execution) = {
6584                let mut frame_encoder =
6585                    executor.begin(&device, &queue, Some("Renderer Frame Encoder"));
6586                let initial_pass_count = frame_encoder.recorded_pass_count();
6587                let result =
6588                    self.render_graph_recorded(surface_view, packet, returns, &mut frame_encoder);
6589                let execution =
6590                    if result.is_ok() && frame_encoder.recorded_pass_count() > initial_pass_count {
6591                        Some(frame_encoder.finish())
6592                    } else {
6593                        None
6594                    };
6595                (result, execution)
6596            };
6597            let after_execute = Instant::now();
6598            self.frame_graph_executor = executor;
6599            if let Some(total_ms) = should_log_wgpu_render_stage(graph_start, after_execute) {
6600                log::warn!("[wgpu-render-stage:graph] total_ms={total_ms:.2}",);
6601            }
6602            if let Some(execution) = execution {
6603                self.frame_stats.record_command_stats(execution.stats);
6604            }
6605            result
6606        }
6607    }
6608
6609    fn render_graph_recorded<C: FrameCommandRecorder>(
6610        &mut self,
6611        surface_view: &wgpu::TextureView,
6612        packet: FramePacket,
6613        returns: &mut RenderReturns,
6614        frame_encoder: &mut C,
6615    ) -> Result<(), String> {
6616        let recorded_start = Instant::now();
6617
6618        // Present-side consumption of the packet's replay plan, adjacent to
6619        // packet consumption: the store honors the ops just before the
6620        // packet renders. Gated on a Direct root — a Surface packet never
6621        // touched the planner and carries the empty default plan
6622        // (generation 0), which the store must not consume: it would count
6623        // a false generation drop. The ack travels back through `returns`
6624        // and the producer applies it right after this render call —
6625        // equivalent to the in-store drain this replaces, because both
6626        // application points sit after this frame's graph build and before
6627        // the next collect, which is where the bypass gate and `feed_slots`
6628        // are read.
6629        #[cfg(not(target_arch = "wasm32"))]
6630        let mut packet = packet;
6631        #[cfg(not(target_arch = "wasm32"))]
6632        if let PacketRoot::Direct(root) = &packet.root {
6633            let ops = std::mem::take(&mut packet.replay);
6634            let (ack, recycled) =
6635                self.consume_replay_ops(ops, &root.scene.shapes, packet.root_scale);
6636            returns.ack = Some((ack, recycled));
6637        }
6638
6639        let FramePacket {
6640            frame_id,
6641            viewport: (width, height),
6642            renderer_epoch: _,
6643            surface_epoch: _,
6644            root_scale,
6645            root,
6646            overlay,
6647            replay: _,
6648            text_cache_len: _,
6649        } = packet;
6650
6651        let mut backend = RecordingSurfaceBackend {
6652            renderer: self,
6653            recorder: frame_encoder,
6654        };
6655
6656        let surface_packet = match root {
6657            PacketRoot::Direct(root) => {
6658                let direct_render_start = Instant::now();
6659                let result = match execute_render_root_direct(
6660                    &mut backend,
6661                    surface_view,
6662                    *root,
6663                    width,
6664                    height,
6665                    root_scale,
6666                    wgpu::LoadOp::Clear(CLEAR_COLOR),
6667                ) {
6668                    // Return the packet's scene buffers to the producer pool
6669                    // in BOTH arms — for a heavy animated frame they are
6670                    // megabytes of Vec, and an errored draw must not leak
6671                    // them.
6672                    Ok(scene) => {
6673                        returns.scene = Some(scene);
6674                        Ok(())
6675                    }
6676                    Err((error, scene)) => {
6677                        returns.scene = Some(scene);
6678                        Err(error)
6679                    }
6680                };
6681                if result.is_ok() {
6682                    if let Some(overlay) = overlay {
6683                        Self::render_overlay_packet(
6684                            &mut backend,
6685                            surface_view,
6686                            overlay,
6687                            width,
6688                            height,
6689                            root_scale,
6690                        )?;
6691                    }
6692                }
6693                let after_direct_render = Instant::now();
6694                if let Some(total_ms) =
6695                    should_log_wgpu_render_stage(recorded_start, after_direct_render)
6696                {
6697                    log::warn!(
6698                        "[wgpu-render-stage:recorded-direct-root] frame={frame_id} total_ms={total_ms:.2} render_ms={:.2}",
6699                        instant_ms(direct_render_start, after_direct_render),
6700                    );
6701                }
6702                return result;
6703            }
6704            PacketRoot::Surface(surface_packet) => surface_packet,
6705        };
6706        let after_root_collect = Instant::now();
6707
6708        let RootSurfacePacket {
6709            lowered,
6710            source,
6711            transform_to_parent,
6712            node_id,
6713            backdrop,
6714            graphics_layer,
6715            local_bounds,
6716            clip_rect,
6717            shadow_clip,
6718        } = *surface_packet;
6719        let mut lowered = lowered;
6720        lowered.source = source;
6721
6722        // The root layer's visible area is always the viewport — content
6723        // outside the screen is invisible regardless of scroll offsets or
6724        // inflated scene bounds.  Pass the viewport rect as an explicit
6725        // surface rect to prevent offscreen inflation on constrained GPUs.
6726        let viewport_rect = Rect {
6727            x: 0.0,
6728            y: 0.0,
6729            width: width as f32 / root_scale,
6730            height: height as f32 / root_scale,
6731        };
6732        let root_surface = execute_render_layer_surface(
6733            &mut backend,
6734            &mut lowered,
6735            LayerSurfaceRequest {
6736                root_scale,
6737                backdrop_underlay: None,
6738                allow_runtime_cache: false,
6739                logical_rect_override: Some(viewport_rect),
6740                capture_clip_override: None,
6741                activates_nested_capture: false,
6742                translation_context: TranslationRenderContext::default(),
6743            },
6744        )?;
6745        let root_quad = transform_to_parent.map_rect(root_surface.logical_rect);
6746        let root_dest_quad = scaled_quad(root_quad, root_scale);
6747
6748        let needs_root_composite_target =
6749            backdrop.is_some() || graphics_layer.shadow_elevation > 0.0;
6750
6751        if needs_root_composite_target {
6752            let composite_target = backend.acquire_frame_surface(width, height);
6753            backend.clear_target_view_with_load_op(
6754                &composite_target.view,
6755                wgpu::LoadOp::Clear(CLEAR_COLOR),
6756            );
6757
6758            if let Some(backdrop) = &backdrop {
6759                execute_apply_backdrop_layer_to_target(
6760                    &mut backend,
6761                    &composite_target,
6762                    &BackdropLayer {
6763                        node_id,
6764                        rect: quad_bounds(transform_to_parent.map_rect(local_bounds)),
6765                        clip: clip_rect.map(|clip| quad_bounds(transform_to_parent.map_rect(clip))),
6766                        snap_anchor: None,
6767                        effect: backdrop.clone(),
6768                        z_index: 0,
6769                    },
6770                    None,
6771                    width,
6772                    height,
6773                    root_scale,
6774                    None,
6775                )?;
6776            }
6777
6778            let mut root_shadow_scene = CompositorScene::new();
6779            let root_shadow_clip =
6780                shadow_clip.map(|clip| quad_bounds(transform_to_parent.map_rect(clip)));
6781            push_layer_shadow(
6782                &mut root_shadow_scene,
6783                &graphics_layer,
6784                local_bounds,
6785                quad_bounds(transform_to_parent.map_rect(local_bounds)),
6786                root_shadow_clip,
6787            );
6788            for shadow in &root_shadow_scene.shadow_draws {
6789                backend.render_shadow_draw(
6790                    &composite_target.view,
6791                    shadow,
6792                    width,
6793                    height,
6794                    root_scale,
6795                );
6796            }
6797
6798            let composite_dest_quad =
6799                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
6800            execute_composite_surface_to_view(
6801                &mut backend,
6802                root_surface.target.target(),
6803                &composite_target.view,
6804                (width, height),
6805                composite_dest_quad,
6806                root_surface.composite_alpha,
6807                wgpu::LoadOp::Load,
6808                None,
6809                root_surface.blend_mode,
6810                root_surface.sample_mode,
6811            )?;
6812            backend.composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
6813                &composite_target,
6814                surface_view,
6815                1.0,
6816                wgpu::LoadOp::Clear(CLEAR_COLOR),
6817                None,
6818                None,
6819                BlendMode::SrcOver,
6820                None,
6821                CompositeSampleMode::Linear,
6822            );
6823            backend.release_frame_surface(composite_target);
6824        } else {
6825            let composite_dest_quad =
6826                snap_motion_stable_dest_quad(root_dest_quad, root_surface.sample_mode);
6827            execute_composite_surface_to_view(
6828                &mut backend,
6829                root_surface.target.target(),
6830                surface_view,
6831                (width, height),
6832                composite_dest_quad,
6833                root_surface.composite_alpha,
6834                wgpu::LoadOp::Clear(CLEAR_COLOR),
6835                None,
6836                root_surface.blend_mode,
6837                root_surface.sample_mode,
6838            )?;
6839        }
6840        backend.release_layer_surface_target(root_surface.target);
6841        if let Some(overlay) = overlay {
6842            Self::render_overlay_packet(
6843                &mut backend,
6844                surface_view,
6845                overlay,
6846                width,
6847                height,
6848                root_scale,
6849            )?;
6850        }
6851        let after_layer_render = Instant::now();
6852        if let Some(total_ms) = should_log_wgpu_render_stage(recorded_start, after_layer_render) {
6853            log::warn!(
6854                "[wgpu-render-stage:recorded-layer-root] total_ms={total_ms:.2} collect_ms={:.2} render_ms={:.2}",
6855                instant_ms(recorded_start, after_root_collect),
6856                instant_ms(after_root_collect, after_layer_render),
6857            );
6858        }
6859        Ok(())
6860    }
6861
6862    /// Renders the producer-lowered dev overlay on top of the frame. The
6863    /// packet carries the collected overlay; the backend only validates
6864    /// that it stayed directly renderable and draws it.
6865    fn render_overlay_packet<C: FrameCommandRecorder>(
6866        backend: &mut RecordingSurfaceBackend<'_, '_, C>,
6867        surface_view: &wgpu::TextureView,
6868        overlay: CollectedLayer,
6869        width: u32,
6870        height: u32,
6871        root_scale: f32,
6872    ) -> Result<(), String> {
6873        if !overlay.child_layers.is_empty()
6874            || !root_direct_scene_events_are_supported(&overlay.scene)
6875            || !direct_root_child_underlays_are_supported(&overlay)
6876        {
6877            return Err("dev overlay graph must stay directly renderable".to_string());
6878        }
6879        execute_render_root_direct(
6880            backend,
6881            surface_view,
6882            overlay,
6883            width,
6884            height,
6885            root_scale,
6886            wgpu::LoadOp::Load,
6887        )
6888        .map(|_overlay_scene| ())
6889        .map_err(|(error, _overlay_scene)| error)
6890    }
6891
6892    #[allow(clippy::too_many_arguments)]
6893    fn encode_non_effect_segment_commands<C: FrameCommandRecorder>(
6894        &mut self,
6895        frame_encoder: &mut C,
6896        target_view: &wgpu::TextureView,
6897        ordered_items: &[(usize, SegmentDrawItem)],
6898        composites: &[(usize, CompositeBatchItem<'_>)],
6899        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
6900        shapes: &[DrawShape],
6901        images: &[ImageDraw],
6902        texts: &[TextDraw],
6903        shadow_draws: &[ShadowDraw],
6904        retained_draws: &[RetainedDraw],
6905        initial_load_op: wgpu::LoadOp<wgpu::Color>,
6906        width: u32,
6907        height: u32,
6908        root_scale: f32,
6909    ) -> Result<SegmentCommandEncodeOutcome, String> {
6910        let mut first_batch = true;
6911        for command in
6912            SegmentCommandIter::new(ordered_items, shapes, images, self.shape_batch_limits)
6913        {
6914            match command {
6915                SegmentRenderCommand::DrawChunk(chunk) => {
6916                    let load_op = if first_batch {
6917                        initial_load_op
6918                    } else {
6919                        wgpu::LoadOp::Load
6920                    };
6921                    let outcome = self.render_segment_draw_chunk(
6922                        frame_encoder,
6923                        target_view,
6924                        ordered_items,
6925                        composites,
6926                        shader_composites,
6927                        shapes,
6928                        images,
6929                        texts,
6930                        retained_draws,
6931                        chunk,
6932                        width,
6933                        height,
6934                        root_scale,
6935                        load_op,
6936                    )?;
6937                    if outcome.rendered_any {
6938                        frame_encoder.record_passes(outcome.pass_count);
6939                        first_batch = false;
6940                    }
6941                }
6942                SegmentRenderCommand::Shadow(index) => {
6943                    if first_batch && matches!(initial_load_op, wgpu::LoadOp::Clear(_)) {
6944                        {
6945                            let _clear = frame_encoder.encoder().begin_render_pass(
6946                                &wgpu::RenderPassDescriptor {
6947                                    label: Some("Shadow Pre-Clear"),
6948                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
6949                                        view: target_view,
6950                                        resolve_target: None,
6951                                        depth_slice: None,
6952                                        ops: wgpu::Operations {
6953                                            load: initial_load_op,
6954                                            store: wgpu::StoreOp::Store,
6955                                        },
6956                                    })],
6957                                    depth_stencil_attachment: None,
6958                                    timestamp_writes: None,
6959                                    occlusion_query_set: None,
6960                                    multiview_mask: None,
6961                                },
6962                            );
6963                        }
6964                        frame_encoder.record_pass();
6965                        first_batch = false;
6966                    }
6967                    let pass_count_before = frame_encoder.recorded_pass_count();
6968                    self.encode_shadow_draw(
6969                        frame_encoder,
6970                        target_view,
6971                        &shadow_draws[index],
6972                        width,
6973                        height,
6974                        root_scale,
6975                    );
6976                    if frame_encoder.recorded_pass_count() > pass_count_before {
6977                        first_batch = false;
6978                    }
6979                }
6980            }
6981        }
6982        Ok(SegmentCommandEncodeOutcome { first_batch })
6983    }
6984
6985    #[cfg(not(target_arch = "wasm32"))]
6986    #[allow(clippy::too_many_arguments)]
6987    fn render_segment_draw_chunk_fused_native<C: FrameCommandRecorder>(
6988        &mut self,
6989        frame_encoder: &mut C,
6990        target_view: &wgpu::TextureView,
6991        ordered_items: &[(usize, SegmentDrawItem)],
6992        composites: &[(usize, CompositeBatchItem<'_>)],
6993        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
6994        shapes: &[DrawShape],
6995        images: &[ImageDraw],
6996        texts: &[TextDraw],
6997        retained_draws: &[RetainedDraw],
6998        chunk: &SegmentDrawChunkPlan,
6999        width: u32,
7000        height: u32,
7001        root_scale: f32,
7002        load_op: wgpu::LoadOp<wgpu::Color>,
7003    ) -> Result<Option<SegmentRenderOutcome>, String> {
7004        let Some(partitions) = native_segment_fusion_partitions(
7005            ordered_items,
7006            shapes,
7007            chunk,
7008            self.shape_batch_limits,
7009        )?
7010        else {
7011            return Ok(None);
7012        };
7013
7014        let mut rendered_any = false;
7015        let mut pass_count = 0_u32;
7016        let mut next_load_op = load_op;
7017        for partition in partitions {
7018            let outcome = self.render_segment_draw_chunk_fused_native_partition(
7019                frame_encoder,
7020                target_view,
7021                ordered_items,
7022                composites,
7023                shader_composites,
7024                shapes,
7025                images,
7026                texts,
7027                retained_draws,
7028                &partition.chunk,
7029                partition.budget,
7030                width,
7031                height,
7032                root_scale,
7033                next_load_op,
7034            )?;
7035            if outcome.rendered_any {
7036                rendered_any = true;
7037                pass_count = pass_count.saturating_add(outcome.pass_count);
7038                next_load_op = wgpu::LoadOp::Load;
7039            }
7040        }
7041
7042        Ok(Some(SegmentRenderOutcome {
7043            rendered_any,
7044            pass_count,
7045        }))
7046    }
7047
7048    #[cfg(not(target_arch = "wasm32"))]
7049    #[allow(clippy::too_many_arguments)]
7050    fn render_segment_draw_chunk_fused_native_partition<C: FrameCommandRecorder>(
7051        &mut self,
7052        frame_encoder: &mut C,
7053        target_view: &wgpu::TextureView,
7054        ordered_items: &[(usize, SegmentDrawItem)],
7055        composites: &[(usize, CompositeBatchItem<'_>)],
7056        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
7057        shapes: &[DrawShape],
7058        images: &[ImageDraw],
7059        texts: &[TextDraw],
7060        retained_draws: &[RetainedDraw],
7061        chunk: &SegmentDrawChunkPlan,
7062        budget: NativeSegmentFusionBudget,
7063        width: u32,
7064        height: u32,
7065        root_scale: f32,
7066        load_op: wgpu::LoadOp<wgpu::Color>,
7067    ) -> Result<SegmentRenderOutcome, String> {
7068        let partition_start = Instant::now();
7069        let mut staged_uploads = self.take_staged_uploads();
7070        staged_uploads.clear();
7071        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
7072        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
7073        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
7074        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
7075
7076        image_vertices.clear();
7077        image_indices.clear();
7078        image_cmds.clear();
7079        glyph_cmds.clear();
7080
7081        let result = (|| {
7082            let viewport = ViewportUniformParams {
7083                width,
7084                height,
7085                offset: [0.0, 0.0],
7086            };
7087            self.prewarm_offscreen_text_glyph_draws_in_chunk(
7088                ordered_items,
7089                texts,
7090                chunk,
7091                viewport,
7092                root_scale,
7093                &mut staged_uploads,
7094                &mut image_vertices,
7095                &mut image_indices,
7096                &mut glyph_cmds,
7097            )?;
7098            let mut shape_refs = Vec::with_capacity(budget.shape_count);
7099            for batch in chunk.iter() {
7100                let SegmentBatchPlan::Shape { start, end, .. } = batch else {
7101                    continue;
7102                };
7103                for (_, item) in &ordered_items[start..end] {
7104                    let SegmentDrawItem::Shape(shape_index) = item else {
7105                        return Err(format!(
7106                            "shape batch contains non-shape draw item: {item:?}"
7107                        ));
7108                    };
7109                    shape_refs.push(&shapes[*shape_index]);
7110                }
7111            }
7112            let after_shape_refs = Instant::now();
7113
7114            let mut direct_shape_uploads = StagedBufferUploads::default();
7115            let mut shape_upload_base = 0u64;
7116            if !shape_refs.is_empty() {
7117                let Some((_, upload_base)) = self.prepare_shapes_batch_direct(
7118                    frame_encoder,
7119                    shape_refs.iter().copied(),
7120                    root_scale,
7121                    viewport,
7122                    &mut direct_shape_uploads,
7123                ) else {
7124                    return Err(
7125                        "native fused segment shape preparation produced no draw batch".to_string(),
7126                    );
7127                };
7128                shape_upload_base = upload_base;
7129            }
7130            let after_shape_prepare = Instant::now();
7131
7132            let mut fused_batches = Vec::with_capacity(chunk.batches.len());
7133            let mut shape_cursor = 0_u32;
7134            let mut composite_cursor = 0usize;
7135            let mut shader_composite_cursor = 0usize;
7136            for batch in chunk.iter() {
7137                match batch {
7138                    SegmentBatchPlan::Shape {
7139                        start,
7140                        end,
7141                        blend_mode,
7142                    } => {
7143                        for (_, item) in &ordered_items[start..end] {
7144                            if !matches!(item, SegmentDrawItem::Shape(_)) {
7145                                return Err(format!(
7146                                    "shape batch contains non-shape draw item: {item:?}"
7147                                ));
7148                            }
7149                        }
7150                        let shape_count = end - start;
7151                        if shape_count > 0 {
7152                            fused_batches.push(FusedSegmentBatch::Shape {
7153                                batch: PreparedShapeBatch {
7154                                    vertex_start: shape_cursor * 6,
7155                                    vertex_count: shape_count as u32 * 6,
7156                                },
7157                                blend_mode,
7158                            });
7159                            shape_cursor += shape_count as u32;
7160                        }
7161                    }
7162                    SegmentBatchPlan::Image {
7163                        start,
7164                        end,
7165                        blend_mode,
7166                    } => {
7167                        let cmd_start = image_cmds.len();
7168                        for (_, item) in &ordered_items[start..end] {
7169                            let SegmentDrawItem::Image(image_index) = item else {
7170                                return Err(format!(
7171                                    "image batch contains non-image draw item: {item:?}"
7172                                ));
7173                            };
7174                            self.append_image_draw_cmd(
7175                                &images[*image_index],
7176                                viewport,
7177                                root_scale,
7178                                &mut image_vertices,
7179                                &mut image_indices,
7180                                &mut image_cmds,
7181                            )?;
7182                        }
7183                        let cmd_end = image_cmds.len();
7184                        if cmd_start < cmd_end {
7185                            fused_batches.push(FusedSegmentBatch::Image {
7186                                cmd_range: cmd_start..cmd_end,
7187                                blend_mode,
7188                            });
7189                        }
7190                    }
7191                    SegmentBatchPlan::Text { start, end } => {
7192                        let glyph_cmd_start = glyph_cmds.len();
7193                        let image_cmd_start = image_cmds.len();
7194                        let text_draws =
7195                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7196                        if !self.append_text_glyph_draws(
7197                            text_draws,
7198                            viewport,
7199                            root_scale,
7200                            false,
7201                            &mut staged_uploads,
7202                            &mut image_vertices,
7203                            &mut image_indices,
7204                            &mut glyph_cmds,
7205                        )? {
7206                            let text_draws =
7207                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7208                            self.append_text_image_draw_cmds(
7209                                text_draws,
7210                                viewport,
7211                                root_scale,
7212                                &mut image_vertices,
7213                                &mut image_indices,
7214                                &mut image_cmds,
7215                            )?;
7216                        }
7217                        let image_cmd_end = image_cmds.len();
7218                        let glyph_cmd_end = glyph_cmds.len();
7219                        if image_cmd_start < image_cmd_end || glyph_cmd_start < glyph_cmd_end {
7220                            fused_batches.push(FusedSegmentBatch::Text {
7221                                image_cmd_range: image_cmd_start..image_cmd_end,
7222                                glyph_cmd_range: glyph_cmd_start..glyph_cmd_end,
7223                            });
7224                        }
7225                    }
7226                    SegmentBatchPlan::Composite { start, end } => {
7227                        for (_, item) in &ordered_items[start..end] {
7228                            if !matches!(item, SegmentDrawItem::Composite(_)) {
7229                                return Err(format!(
7230                                    "composite batch contains non-composite draw item: {item:?}"
7231                                ));
7232                            }
7233                        }
7234                        let draw_count = end - start;
7235                        if draw_count > 0 {
7236                            let draw_start = composite_cursor;
7237                            composite_cursor += draw_count;
7238                            fused_batches.push(FusedSegmentBatch::Composite {
7239                                draw_range: draw_start..composite_cursor,
7240                            });
7241                        }
7242                    }
7243                    SegmentBatchPlan::ShaderComposite { start, end } => {
7244                        for (_, item) in &ordered_items[start..end] {
7245                            if !matches!(item, SegmentDrawItem::ShaderComposite(_)) {
7246                                return Err(format!(
7247                                    "shader composite batch contains non-shader-composite draw item: {item:?}"
7248                                ));
7249                            }
7250                        }
7251                        let draw_count = end - start;
7252                        if draw_count > 0 {
7253                            let draw_start = shader_composite_cursor;
7254                            shader_composite_cursor += draw_count;
7255                            fused_batches.push(FusedSegmentBatch::ShaderComposite {
7256                                draw_range: draw_start..shader_composite_cursor,
7257                            });
7258                        }
7259                    }
7260                    SegmentBatchPlan::Retained { start, end } => {
7261                        self.stage_replay_patches(&mut staged_uploads);
7262                        for (_, item) in &ordered_items[start..end] {
7263                            let SegmentDrawItem::Retained(index) = item else {
7264                                return Err(format!(
7265                                    "retained batch contains non-retained draw item: {item:?}"
7266                                ));
7267                            };
7268                            let retained = retained_draws.get(*index).ok_or_else(|| {
7269                                format!("retained draw index {index} out of bounds")
7270                            })?;
7271                            if (*index as u32) < MAX_REPLAY_SLOTS
7272                                && self.replay_slots.slots.contains_key(&retained.slot)
7273                            {
7274                                let transform = retained.transform.with_retained_paint();
7275                                staged_uploads.stage_at(
7276                                    UploadTarget::ReplayTransform,
7277                                    *index as u64 * REPLAY_TRANSFORM_STRIDE,
7278                                    bytemuck::bytes_of(&transform),
7279                                );
7280                            }
7281                        }
7282                        if end > start {
7283                            fused_batches.push(FusedSegmentBatch::Retained {
7284                                item_range: start..end,
7285                            });
7286                        }
7287                    }
7288                }
7289            }
7290            let after_batch_prepare = Instant::now();
7291
7292            if !image_indices.is_empty() {
7293                self.stage_native_image_buffers(
7294                    &mut staged_uploads,
7295                    viewport,
7296                    &image_vertices,
7297                    &image_indices,
7298                );
7299            }
7300
7301            let device = self.device.clone();
7302            let composite_items: Vec<_> = chunk
7303                .iter()
7304                .filter_map(|batch| match batch {
7305                    SegmentBatchPlan::Composite { start, end } => Some((start, end)),
7306                    _ => None,
7307                })
7308                .flat_map(|(start, end)| {
7309                    ordered_items[start..end].iter().filter_map(|(_, item)| {
7310                        let SegmentDrawItem::Composite(composite_index) = item else {
7311                            return None;
7312                        };
7313                        composites
7314                            .get(*composite_index)
7315                            .map(|(_, composite)| *composite)
7316                    })
7317                })
7318                .collect();
7319            let prepared_composites = self.effect_renderer.prepare_composite_batch_draws(
7320                frame_encoder,
7321                &device,
7322                load_op,
7323                &composite_items,
7324            );
7325            let shader_items: Vec<_> = chunk
7326                .iter()
7327                .filter_map(|batch| match batch {
7328                    SegmentBatchPlan::ShaderComposite { start, end } => Some((start, end)),
7329                    _ => None,
7330                })
7331                .flat_map(|(start, end)| {
7332                    ordered_items[start..end].iter().filter_map(|(_, item)| {
7333                        let SegmentDrawItem::ShaderComposite(composite_index) = item else {
7334                            return None;
7335                        };
7336                        shader_composites
7337                            .get(*composite_index)
7338                            .map(|(_, composite)| *composite)
7339                    })
7340                })
7341                .collect();
7342            let prepared_shaders = self
7343                .effect_renderer
7344                .prepare_shader_batch_draws(frame_encoder, &device, &shader_items)
7345                .ok_or_else(|| "shader composite batch preparation failed".to_string())?;
7346            if !shader_items.is_empty() {
7347                self.effect_renderer.record_composite_pass();
7348                self.effect_renderer
7349                    .debug_effects
7350                    .set(self.effect_renderer.debug_effects.get() + shader_items.len() as u32);
7351            }
7352            let after_composite_prepare = Instant::now();
7353
7354            if fused_batches.is_empty() {
7355                return Ok(SegmentRenderOutcome {
7356                    rendered_any: false,
7357                    pass_count: 0,
7358                });
7359            }
7360
7361            // The direct shape copies must be recorded before the staged
7362            // flush: its capacity check may replace `upload_buffer`, and the
7363            // shape payload was written into the buffer that existed at
7364            // prepare time. Recording first binds the copies to that buffer.
7365            self.flush_staged_uploads_at(
7366                frame_encoder.encoder(),
7367                &direct_shape_uploads,
7368                shape_upload_base,
7369            );
7370            let upload_offset =
7371                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7372            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
7373            let after_upload = Instant::now();
7374
7375            let use_retained_bundles = retained_bundles_enabled();
7376            let mut retained_encode_ms = 0.0_f64;
7377            {
7378                let mut render_pass =
7379                    frame_encoder
7380                        .encoder()
7381                        .begin_render_pass(&wgpu::RenderPassDescriptor {
7382                            label: Some("Fused Segment Draw Pass"),
7383                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7384                                view: target_view,
7385                                resolve_target: None,
7386                                depth_slice: None,
7387                                ops: wgpu::Operations {
7388                                    load: load_op,
7389                                    store: wgpu::StoreOp::Store,
7390                                },
7391                            })],
7392                            depth_stencil_attachment: None,
7393                            timestamp_writes: None,
7394                            occlusion_query_set: None,
7395                            multiview_mask: None,
7396                        });
7397
7398                for batch in &fused_batches {
7399                    match batch {
7400                        FusedSegmentBatch::Shape { batch, blend_mode } => {
7401                            self.draw_prepared_shapes(
7402                                &mut render_pass,
7403                                *blend_mode,
7404                                *batch,
7405                                width,
7406                                height,
7407                            );
7408                        }
7409                        FusedSegmentBatch::Image {
7410                            cmd_range,
7411                            blend_mode,
7412                        } => {
7413                            self.draw_native_prepared_image_cmd_range(
7414                                &mut render_pass,
7415                                &image_cmds,
7416                                cmd_range.clone(),
7417                                *blend_mode,
7418                            )?;
7419                        }
7420                        FusedSegmentBatch::Text {
7421                            image_cmd_range,
7422                            glyph_cmd_range,
7423                        } => {
7424                            if !image_cmd_range.is_empty() {
7425                                self.draw_native_prepared_image_cmd_range(
7426                                    &mut render_pass,
7427                                    &image_cmds,
7428                                    image_cmd_range.clone(),
7429                                    BlendMode::SrcOver,
7430                                )?;
7431                                self.frame_stats.bump_text();
7432                            }
7433                            if !glyph_cmd_range.is_empty() {
7434                                self.draw_native_prepared_glyph_cmd_range(
7435                                    &mut render_pass,
7436                                    &glyph_cmds,
7437                                    glyph_cmd_range.clone(),
7438                                )?;
7439                            }
7440                        }
7441                        FusedSegmentBatch::Composite { draw_range } => {
7442                            for draw in
7443                                prepared_composites.get(draw_range.clone()).ok_or_else(|| {
7444                                    "composite draw range is outside the prepared command buffer"
7445                                        .to_string()
7446                                })?
7447                            {
7448                                self.effect_renderer.draw_prepared_composite(
7449                                    &mut render_pass,
7450                                    (width, height),
7451                                    draw,
7452                                );
7453                            }
7454                        }
7455                        FusedSegmentBatch::ShaderComposite { draw_range } => {
7456                            for draw in prepared_shaders.get(draw_range.clone()).ok_or_else(|| {
7457                                "shader composite draw range is outside the prepared command buffer"
7458                                    .to_string()
7459                            })? {
7460                                self.effect_renderer.draw_prepared_shader_src_over(
7461                                    &device,
7462                                    &mut render_pass,
7463                                    (width, height),
7464                                    draw,
7465                                );
7466                            }
7467                        }
7468                        FusedSegmentBatch::Retained { item_range } => {
7469                            // Each Retained arm is one MAXIMAL consecutive
7470                            // retained stretch — the planner groups adjacent
7471                            // retained items into a single batch — so caching
7472                            // per arm never flattens across the dynamic
7473                            // batches interleaved at their z positions.
7474                            let retained_start = Instant::now();
7475                            if use_retained_bundles {
7476                                self.draw_retained_stretch_bundled(
7477                                    &mut render_pass,
7478                                    ordered_items,
7479                                    retained_draws,
7480                                    item_range.clone(),
7481                                    width,
7482                                    height,
7483                                );
7484                            } else {
7485                                for (_, item) in &ordered_items[item_range.clone()] {
7486                                    if let SegmentDrawItem::Retained(index) = item {
7487                                        if let Some(retained) = retained_draws.get(*index) {
7488                                            self.draw_retained_batch(
7489                                                &mut render_pass,
7490                                                retained,
7491                                                *index,
7492                                                width,
7493                                                height,
7494                                            );
7495                                        }
7496                                    }
7497                                }
7498                            }
7499                            retained_encode_ms += instant_ms(retained_start, Instant::now());
7500                        }
7501                    }
7502                }
7503            }
7504            let after_pass = Instant::now();
7505            if let Some(total_ms) = should_log_wgpu_render_stage(partition_start, after_pass) {
7506                log::warn!(
7507                    "[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={}",
7508                    instant_ms(partition_start, after_shape_refs),
7509                    instant_ms(after_shape_refs, after_shape_prepare),
7510                    instant_ms(after_shape_prepare, after_batch_prepare),
7511                    instant_ms(after_batch_prepare, after_composite_prepare),
7512                    instant_ms(after_composite_prepare, after_upload),
7513                    instant_ms(after_upload, after_pass),
7514                    fused_batches.len(),
7515                    budget.shape_count,
7516                    image_cmds.len(),
7517                    glyph_cmds.len(),
7518                    staged_uploads.bytes.len(),
7519                );
7520            }
7521
7522            Ok(SegmentRenderOutcome {
7523                rendered_any: true,
7524                pass_count: 1,
7525            })
7526        })();
7527
7528        self.scratch_image_vertices = image_vertices;
7529        self.scratch_image_indices = image_indices;
7530        self.scratch_image_cmds = image_cmds;
7531        self.scratch_glyph_cmds = glyph_cmds;
7532        self.restore_staged_uploads(staged_uploads);
7533        result
7534    }
7535
7536    #[allow(clippy::too_many_arguments)]
7537    fn render_segment_draw_chunk<C: FrameCommandRecorder>(
7538        &mut self,
7539        frame_encoder: &mut C,
7540        target_view: &wgpu::TextureView,
7541        ordered_items: &[(usize, SegmentDrawItem)],
7542        composites: &[(usize, CompositeBatchItem<'_>)],
7543        shader_composites: &[(usize, ShaderCompositeBatchItem<'_>)],
7544        shapes: &[DrawShape],
7545        images: &[ImageDraw],
7546        texts: &[TextDraw],
7547        retained_draws: &[RetainedDraw],
7548        chunk: SegmentDrawChunkPlan,
7549        width: u32,
7550        height: u32,
7551        root_scale: f32,
7552        load_op: wgpu::LoadOp<wgpu::Color>,
7553    ) -> Result<SegmentRenderOutcome, String> {
7554        #[cfg(target_arch = "wasm32")]
7555        let _ = retained_draws;
7556        #[cfg(not(target_arch = "wasm32"))]
7557        if let Some(outcome) = self.render_segment_draw_chunk_fused_native(
7558            frame_encoder,
7559            target_view,
7560            ordered_items,
7561            composites,
7562            shader_composites,
7563            shapes,
7564            images,
7565            texts,
7566            retained_draws,
7567            &chunk,
7568            width,
7569            height,
7570            root_scale,
7571            load_op,
7572        )? {
7573            return Ok(outcome);
7574        }
7575
7576        let mut staged_uploads = self.take_staged_uploads();
7577        let result = (|| {
7578            let mut rendered_any = false;
7579            let mut pass_count = 0_u32;
7580            let mut next_load_op = load_op;
7581            for batch in chunk.iter() {
7582                staged_uploads.clear();
7583                match batch {
7584                    SegmentBatchPlan::Shape {
7585                        start,
7586                        end,
7587                        blend_mode,
7588                    } => {
7589                        let slice = &ordered_items[start..end];
7590                        if slice.len() > self.shape_batch_limits.max_shapes_per_batch {
7591                            return Err(format!(
7592                                "shape batch contains {} shapes, exceeding the renderer limit of {}",
7593                                slice.len(),
7594                                self.shape_batch_limits.max_shapes_per_batch
7595                            ));
7596                        }
7597                        let viewport = ViewportUniformParams {
7598                            width,
7599                            height,
7600                            offset: [0.0, 0.0],
7601                        };
7602                        for (_, item) in slice {
7603                            if !matches!(item, SegmentDrawItem::Shape(_)) {
7604                                return Err(format!(
7605                                    "shape batch contains non-shape draw item: {item:?}"
7606                                ));
7607                            }
7608                        }
7609                        let Some(prepared) = self.prepare_shapes_batch(
7610                            slice.iter().filter_map(|(_, item)| match item {
7611                                SegmentDrawItem::Shape(shape_index) => Some(&shapes[*shape_index]),
7612                                _ => None,
7613                            }),
7614                            root_scale,
7615                            viewport,
7616                            &mut staged_uploads,
7617                        ) else {
7618                            continue;
7619                        };
7620                        let upload_offset = frame_encoder
7621                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7622                        self.flush_staged_uploads_at(
7623                            frame_encoder.encoder(),
7624                            &staged_uploads,
7625                            upload_offset,
7626                        );
7627                        {
7628                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
7629                                &wgpu::RenderPassDescriptor {
7630                                    label: Some("Segment Shape Pass"),
7631                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7632                                        view: target_view,
7633                                        resolve_target: None,
7634                                        depth_slice: None,
7635                                        ops: wgpu::Operations {
7636                                            load: next_load_op,
7637                                            store: wgpu::StoreOp::Store,
7638                                        },
7639                                    })],
7640                                    depth_stencil_attachment: None,
7641                                    timestamp_writes: None,
7642                                    occlusion_query_set: None,
7643                                    multiview_mask: None,
7644                                },
7645                            );
7646                            self.draw_prepared_shapes(
7647                                &mut render_pass,
7648                                blend_mode,
7649                                prepared,
7650                                width,
7651                                height,
7652                            );
7653                        }
7654                        pass_count = pass_count.saturating_add(1);
7655                        rendered_any = true;
7656                        next_load_op = wgpu::LoadOp::Load;
7657                    }
7658                    SegmentBatchPlan::Image {
7659                        start,
7660                        end,
7661                        blend_mode,
7662                    } => {
7663                        let viewport = ViewportUniformParams {
7664                            width,
7665                            height,
7666                            offset: [0.0, 0.0],
7667                        };
7668                        for (_, item) in &ordered_items[start..end] {
7669                            if !matches!(item, SegmentDrawItem::Image(_)) {
7670                                return Err(format!(
7671                                    "image batch contains non-image draw item: {item:?}"
7672                                ));
7673                            }
7674                        }
7675                        let prepared_images = self.prepare_image_draw_cmds(
7676                            ordered_items[start..end]
7677                                .iter()
7678                                .filter_map(|(_, item)| match item {
7679                                    SegmentDrawItem::Image(image_index) => {
7680                                        Some(&images[*image_index])
7681                                    }
7682                                    _ => None,
7683                                }),
7684                            viewport,
7685                            root_scale,
7686                            &mut staged_uploads,
7687                        )?;
7688                        if prepared_images.is_empty() {
7689                            self.scratch_image_cmds = prepared_images.into_cmds();
7690                            continue;
7691                        }
7692                        let upload_offset = frame_encoder
7693                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7694                        self.flush_staged_uploads_at(
7695                            frame_encoder.encoder(),
7696                            &staged_uploads,
7697                            upload_offset,
7698                        );
7699                        let draw_result = {
7700                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
7701                                &wgpu::RenderPassDescriptor {
7702                                    label: Some("Segment Image Pass"),
7703                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
7704                                        view: target_view,
7705                                        resolve_target: None,
7706                                        depth_slice: None,
7707                                        ops: wgpu::Operations {
7708                                            load: next_load_op,
7709                                            store: wgpu::StoreOp::Store,
7710                                        },
7711                                    })],
7712                                    depth_stencil_attachment: None,
7713                                    timestamp_writes: None,
7714                                    occlusion_query_set: None,
7715                                    multiview_mask: None,
7716                                },
7717                            );
7718                            self.draw_prepared_images(
7719                                &mut render_pass,
7720                                &prepared_images,
7721                                blend_mode,
7722                            )
7723                        };
7724                        pass_count = pass_count.saturating_add(1);
7725                        self.scratch_image_cmds = prepared_images.into_cmds();
7726                        draw_result?;
7727                        rendered_any = true;
7728                        next_load_op = wgpu::LoadOp::Load;
7729                    }
7730                    SegmentBatchPlan::Text { start, end } => {
7731                        let viewport = ViewportUniformParams {
7732                            width,
7733                            height,
7734                            offset: [0.0, 0.0],
7735                        };
7736                        let text_draws =
7737                            text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7738                        if let Some(prepared_glyphs) = self.prepare_text_glyph_draw_cmds(
7739                            text_draws,
7740                            viewport,
7741                            root_scale,
7742                            &mut staged_uploads,
7743                        )? {
7744                            if prepared_glyphs.is_empty() {
7745                                self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
7746                                continue;
7747                            }
7748                            let upload_offset = frame_encoder
7749                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7750                            self.flush_staged_uploads_at(
7751                                frame_encoder.encoder(),
7752                                &staged_uploads,
7753                                upload_offset,
7754                            );
7755                            {
7756                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
7757                                    &wgpu::RenderPassDescriptor {
7758                                        label: Some("Segment Text Glyph Atlas Pass"),
7759                                        color_attachments: &[Some(
7760                                            wgpu::RenderPassColorAttachment {
7761                                                view: target_view,
7762                                                resolve_target: None,
7763                                                depth_slice: None,
7764                                                ops: wgpu::Operations {
7765                                                    load: next_load_op,
7766                                                    store: wgpu::StoreOp::Store,
7767                                                },
7768                                            },
7769                                        )],
7770                                        depth_stencil_attachment: None,
7771                                        timestamp_writes: None,
7772                                        occlusion_query_set: None,
7773                                        multiview_mask: None,
7774                                    },
7775                                );
7776                                self.draw_prepared_glyphs(&mut render_pass, &prepared_glyphs)?;
7777                            }
7778                            pass_count = pass_count.saturating_add(1);
7779                            self.scratch_glyph_cmds = prepared_glyphs.into_cmds();
7780                            rendered_any = true;
7781                            next_load_op = wgpu::LoadOp::Load;
7782                        } else {
7783                            let text_draws =
7784                                text_draws_for_ordered_range(ordered_items, texts, start, end)?;
7785                            let prepared_images = self.prepare_text_image_draw_cmds(
7786                                text_draws,
7787                                viewport,
7788                                root_scale,
7789                                &mut staged_uploads,
7790                            )?;
7791                            if prepared_images.is_empty() {
7792                                self.scratch_image_cmds = prepared_images.into_cmds();
7793                                continue;
7794                            }
7795                            let upload_offset = frame_encoder
7796                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7797                            self.flush_staged_uploads_at(
7798                                frame_encoder.encoder(),
7799                                &staged_uploads,
7800                                upload_offset,
7801                            );
7802                            {
7803                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
7804                                    &wgpu::RenderPassDescriptor {
7805                                        label: Some("Segment Text Pass"),
7806                                        color_attachments: &[Some(
7807                                            wgpu::RenderPassColorAttachment {
7808                                                view: target_view,
7809                                                resolve_target: None,
7810                                                depth_slice: None,
7811                                                ops: wgpu::Operations {
7812                                                    load: next_load_op,
7813                                                    store: wgpu::StoreOp::Store,
7814                                                },
7815                                            },
7816                                        )],
7817                                        depth_stencil_attachment: None,
7818                                        timestamp_writes: None,
7819                                        occlusion_query_set: None,
7820                                        multiview_mask: None,
7821                                    },
7822                                );
7823                                self.draw_prepared_images(
7824                                    &mut render_pass,
7825                                    &prepared_images,
7826                                    BlendMode::SrcOver,
7827                                )?;
7828                            }
7829                            self.frame_stats.bump_text();
7830                            pass_count = pass_count.saturating_add(1);
7831                            self.scratch_image_cmds = prepared_images.into_cmds();
7832                            rendered_any = true;
7833                            next_load_op = wgpu::LoadOp::Load;
7834                        }
7835                    }
7836                    SegmentBatchPlan::Composite { start, end } => {
7837                        let batch_items: Vec<_> = ordered_items[start..end]
7838                            .iter()
7839                            .map(|(_, item)| match item {
7840                                SegmentDrawItem::Composite(composite_index) => composites
7841                                    .get(*composite_index)
7842                                    .map(|(_, composite)| *composite)
7843                                    .ok_or_else(|| {
7844                                        "composite item index is outside the composite buffer"
7845                                            .to_string()
7846                                    }),
7847                                other => Err(format!(
7848                                    "composite batch contains non-composite draw item: {other:?}"
7849                                )),
7850                            })
7851                            .collect::<Result<_, _>>()?;
7852                        let device = self.device.clone();
7853                        self.effect_renderer.encode_composite_batch_to_view_pass(
7854                            frame_encoder,
7855                            &device,
7856                            target_view,
7857                            (width, height),
7858                            next_load_op,
7859                            &batch_items,
7860                        );
7861                        self.effect_renderer.record_composite_pass();
7862                        pass_count = pass_count.saturating_add(1);
7863                        rendered_any = true;
7864                        next_load_op = wgpu::LoadOp::Load;
7865                    }
7866                    SegmentBatchPlan::ShaderComposite { start, end } => {
7867                        let batch_items: Vec<_> = ordered_items[start..end]
7868                            .iter()
7869                            .map(|(_, item)| match item {
7870                                SegmentDrawItem::ShaderComposite(composite_index) => {
7871                                    shader_composites
7872                                        .get(*composite_index)
7873                                        .map(|(_, composite)| *composite)
7874                                        .ok_or_else(|| {
7875                                            "shader composite item index is outside the shader composite buffer"
7876                                                .to_string()
7877                                        })
7878                                }
7879                                other => Err(format!(
7880                                    "shader composite batch contains non-shader-composite draw item: {other:?}"
7881                                )),
7882                            })
7883                            .collect::<Result<Vec<_>, _>>()?;
7884                        let device = self.device.clone();
7885                        let encoded = self.effect_renderer.encode_shader_batch_src_over_to_view(
7886                            frame_encoder,
7887                            &device,
7888                            target_view,
7889                            (width, height),
7890                            next_load_op,
7891                            &batch_items,
7892                        );
7893                        if !encoded {
7894                            return Err("shader composite batch failed to encode".to_string());
7895                        }
7896                        self.effect_renderer.record_composite_pass();
7897                        self.effect_renderer.debug_effects.set(
7898                            self.effect_renderer.debug_effects.get() + batch_items.len() as u32,
7899                        );
7900                        pass_count = pass_count.saturating_add(1);
7901                        rendered_any = true;
7902                        next_load_op = wgpu::LoadOp::Load;
7903                    }
7904                    SegmentBatchPlan::Retained { start, end } => {
7905                        // Reached only when native fusion declined the chunk;
7906                        // retained batches exist on storage-mode native
7907                        // devices, where fusion always accepts, but the arm
7908                        // stays a real draw so that assumption is not load-
7909                        // bearing for correctness. Deliberately direct encode
7910                        // — retained bundle caching lives in the fused path
7911                        // only; this fallback stays the simple reference.
7912                        #[cfg(target_arch = "wasm32")]
7913                        {
7914                            let _ = (start, end);
7915                            return Err("retained shape batches are native-only".to_string());
7916                        }
7917                        #[cfg(not(target_arch = "wasm32"))]
7918                        {
7919                            self.stage_replay_patches(&mut staged_uploads);
7920                            for (_, item) in &ordered_items[start..end] {
7921                                let SegmentDrawItem::Retained(index) = item else {
7922                                    return Err(format!(
7923                                        "retained batch contains non-retained draw item: {item:?}"
7924                                    ));
7925                                };
7926                                let retained = retained_draws.get(*index).ok_or_else(|| {
7927                                    format!("retained draw index {index} out of bounds")
7928                                })?;
7929                                if (*index as u32) < MAX_REPLAY_SLOTS
7930                                    && self.replay_slots.slots.contains_key(&retained.slot)
7931                                {
7932                                    let transform = retained.transform.with_retained_paint();
7933                                    staged_uploads.stage_at(
7934                                        UploadTarget::ReplayTransform,
7935                                        *index as u64 * REPLAY_TRANSFORM_STRIDE,
7936                                        bytemuck::bytes_of(&transform),
7937                                    );
7938                                }
7939                            }
7940                            let upload_offset = frame_encoder
7941                                .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
7942                            self.flush_staged_uploads_at(
7943                                frame_encoder.encoder(),
7944                                &staged_uploads,
7945                                upload_offset,
7946                            );
7947                            {
7948                                let mut render_pass = frame_encoder.encoder().begin_render_pass(
7949                                    &wgpu::RenderPassDescriptor {
7950                                        label: Some("Segment Retained Pass"),
7951                                        color_attachments: &[Some(
7952                                            wgpu::RenderPassColorAttachment {
7953                                                view: target_view,
7954                                                resolve_target: None,
7955                                                depth_slice: None,
7956                                                ops: wgpu::Operations {
7957                                                    load: next_load_op,
7958                                                    store: wgpu::StoreOp::Store,
7959                                                },
7960                                            },
7961                                        )],
7962                                        depth_stencil_attachment: None,
7963                                        timestamp_writes: None,
7964                                        occlusion_query_set: None,
7965                                        multiview_mask: None,
7966                                    },
7967                                );
7968                                for (_, item) in &ordered_items[start..end] {
7969                                    if let SegmentDrawItem::Retained(index) = item {
7970                                        if let Some(retained) = retained_draws.get(*index) {
7971                                            self.draw_retained_batch(
7972                                                &mut render_pass,
7973                                                retained,
7974                                                *index,
7975                                                width,
7976                                                height,
7977                                            );
7978                                        }
7979                                    }
7980                                }
7981                            }
7982                            pass_count = pass_count.saturating_add(1);
7983                            rendered_any = true;
7984                            next_load_op = wgpu::LoadOp::Load;
7985                        }
7986                    }
7987                }
7988            }
7989            Ok(SegmentRenderOutcome {
7990                rendered_any,
7991                pass_count,
7992            })
7993        })();
7994        self.restore_staged_uploads(staged_uploads);
7995        result
7996    }
7997
7998    fn viewport_uniforms(params: ViewportUniformParams) -> Uniforms {
7999        Uniforms {
8000            viewport: [params.width as f32, params.height as f32],
8001            viewport_offset: params.offset,
8002        }
8003    }
8004
8005    #[cfg(not(target_arch = "wasm32"))]
8006    fn stage_viewport_uniforms(
8007        &self,
8008        staged_uploads: &mut StagedBufferUploads,
8009        params: ViewportUniformParams,
8010    ) {
8011        let uniforms = Self::viewport_uniforms(params);
8012        staged_uploads.stage(UploadTarget::Uniform, bytemuck::bytes_of(&uniforms));
8013    }
8014
8015    #[cfg(not(target_arch = "wasm32"))]
8016    fn stage_retained_glyph_viewport_uniforms(
8017        &mut self,
8018        staged_uploads: &mut StagedBufferUploads,
8019        params: ViewportUniformParams,
8020    ) -> usize {
8021        let slot = self.claim_retained_glyph_uniform_slot();
8022        let uniforms = Self::viewport_uniforms(params);
8023        staged_uploads.stage_at(
8024            UploadTarget::RetainedGlyphUniform,
8025            self.retained_glyph_uniform_offset(slot),
8026            bytemuck::bytes_of(&uniforms),
8027        );
8028        slot
8029    }
8030
8031    #[cfg(not(target_arch = "wasm32"))]
8032    fn claim_retained_glyph_uniform_slot(&mut self) -> usize {
8033        let slot = self.retained_glyph_uniform_cursor;
8034        self.retained_glyph_uniform_cursor = self.retained_glyph_uniform_cursor.saturating_add(1);
8035        self.ensure_retained_glyph_uniform_capacity(slot.saturating_add(1));
8036        slot
8037    }
8038
8039    #[cfg(not(target_arch = "wasm32"))]
8040    fn retained_glyph_uniform_offset(&self, slot: usize) -> u64 {
8041        self.retained_glyph_uniform_stride * slot as u64
8042    }
8043
8044    #[cfg(not(target_arch = "wasm32"))]
8045    fn retained_glyph_uniform_dynamic_offset(&self, slot: usize) -> Result<u32, String> {
8046        let offset = self.retained_glyph_uniform_offset(slot);
8047        u32::try_from(offset).map_err(|_| {
8048            "retained glyph uniform offset exceeded WGPU dynamic offset range".to_string()
8049        })
8050    }
8051
8052    #[cfg(not(target_arch = "wasm32"))]
8053    fn ensure_retained_glyph_uniform_capacity(&mut self, required_slots: usize) {
8054        if required_slots <= self.retained_glyph_uniform_capacity {
8055            return;
8056        }
8057        let new_capacity = required_slots
8058            .next_power_of_two()
8059            .max(INITIAL_RETAINED_GLYPH_UNIFORM_SLOTS);
8060        self.retained_glyph_uniform_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
8061            label: Some("Retained Glyph Uniform Buffer"),
8062            size: self.retained_glyph_uniform_stride * new_capacity as u64,
8063            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
8064            mapped_at_creation: false,
8065        });
8066        self.retained_glyph_uniform_bind_group =
8067            self.device.create_bind_group(&wgpu::BindGroupDescriptor {
8068                label: Some("Retained Glyph Uniform Bind Group"),
8069                layout: &self.retained_glyph_uniform_bind_group_layout,
8070                entries: &[wgpu::BindGroupEntry {
8071                    binding: 0,
8072                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
8073                        buffer: &self.retained_glyph_uniform_buffer,
8074                        offset: 0,
8075                        size: wgpu::BufferSize::new(std::mem::size_of::<Uniforms>() as u64),
8076                    }),
8077                }],
8078            });
8079        self.retained_glyph_uniform_capacity = new_capacity;
8080    }
8081
8082    #[cfg(target_arch = "wasm32")]
8083    fn prepare_wasm_viewport_uniforms(&mut self, params: ViewportUniformParams) -> usize {
8084        let slot = self.claim_wasm_uniform_batch();
8085        let uniforms = Self::viewport_uniforms(params);
8086        let bytes = bytemuck::bytes_of(&uniforms);
8087        let upload_stats = self.frame_graph_executor.upload_buffer(
8088            &self.queue,
8089            &self.wasm_uniform_batches[slot].buffer,
8090            0,
8091            bytes,
8092        );
8093        self.frame_stats.record_command_stats(upload_stats);
8094        slot
8095    }
8096
8097    #[cfg(target_arch = "wasm32")]
8098    fn claim_wasm_uniform_batch(&mut self) -> usize {
8099        let slot = self.wasm_uniform_batch_cursor;
8100        self.wasm_uniform_batch_cursor += 1;
8101        while self.wasm_uniform_batches.len() <= slot {
8102            self.wasm_uniform_batches.push(UniformBatchBuffer::new(
8103                &self.device,
8104                &self.uniform_bind_group_layout,
8105            ));
8106        }
8107        slot
8108    }
8109
8110    #[cfg(target_arch = "wasm32")]
8111    fn claim_wasm_shape_batch(&mut self) -> usize {
8112        let slot = self.wasm_shape_batch_cursor;
8113        self.wasm_shape_batch_cursor += 1;
8114        while self.wasm_shape_batches.len() <= slot {
8115            self.wasm_shape_batches.push(ShapeBatchBuffers::new(
8116                &self.device,
8117                &self.shape_bind_group_layout,
8118                &self.identity_similarity_buffer,
8119                self.dummy_paint_buffer.as_ref(),
8120                self.shape_batch_limits,
8121            ));
8122        }
8123        slot
8124    }
8125
8126    #[cfg(target_arch = "wasm32")]
8127    fn claim_wasm_image_batch(&mut self) -> usize {
8128        let slot = self.wasm_image_batch_cursor;
8129        self.wasm_image_batch_cursor += 1;
8130        while self.wasm_image_batches.len() <= slot {
8131            self.wasm_image_batches
8132                .push(ImageBatchBuffers::new(&self.device));
8133        }
8134        slot
8135    }
8136
8137    #[cfg(target_arch = "wasm32")]
8138    fn write_wasm_buffer(&self, buffer: &wgpu::Buffer, bytes: &[u8]) {
8139        let upload_stats = self
8140            .frame_graph_executor
8141            .upload_buffer(&self.queue, buffer, 0, bytes);
8142        self.frame_stats.record_command_stats(upload_stats);
8143    }
8144
8145    fn take_staged_uploads(&mut self) -> StagedBufferUploads {
8146        let mut staged_uploads = std::mem::take(&mut self.staged_uploads);
8147        debug_assert!(
8148            staged_uploads.is_empty(),
8149            "renderer-owned staged uploads should be restored as empty scratch storage"
8150        );
8151        staged_uploads.clear();
8152        staged_uploads
8153    }
8154
8155    fn restore_staged_uploads(&mut self, mut staged_uploads: StagedBufferUploads) {
8156        staged_uploads.clear();
8157        self.staged_uploads = staged_uploads;
8158    }
8159
8160    #[cfg(not(target_arch = "wasm32"))]
8161    fn ensure_upload_buffer_capacity(&mut self, required_bytes: u64) {
8162        if required_bytes <= self.upload_buffer.size() {
8163            return;
8164        }
8165
8166        let new_size = required_bytes
8167            .next_power_of_two()
8168            .max(INITIAL_UPLOAD_BUFFER_BYTES);
8169        self.upload_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
8170            label: Some("Frame Upload Buffer"),
8171            size: new_size,
8172            usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
8173            mapped_at_creation: false,
8174        });
8175    }
8176
8177    fn flush_staged_uploads_at(
8178        &mut self,
8179        encoder: &mut wgpu::CommandEncoder,
8180        staged_uploads: &StagedBufferUploads,
8181        upload_buffer_offset: u64,
8182    ) {
8183        if staged_uploads.is_empty() {
8184            return;
8185        }
8186        debug_assert_eq!(
8187            upload_buffer_offset % wgpu::COPY_BUFFER_ALIGNMENT,
8188            0,
8189            "upload-buffer base offset must satisfy copy alignment"
8190        );
8191
8192        #[cfg(target_arch = "wasm32")]
8193        {
8194            let _ = upload_buffer_offset;
8195            let _ = encoder;
8196            debug_assert!(
8197                staged_uploads.is_empty(),
8198                "wasm draw uploads use retained per-batch resource slots"
8199            );
8200            return;
8201        }
8202
8203        #[cfg(not(target_arch = "wasm32"))]
8204        {
8205            self.ensure_upload_buffer_capacity(
8206                upload_buffer_offset + staged_uploads.bytes.len() as u64,
8207            );
8208            let upload_stats = self.frame_graph_executor.upload_buffer(
8209                &self.queue,
8210                &self.upload_buffer,
8211                upload_buffer_offset,
8212                &staged_uploads.bytes,
8213            );
8214            self.frame_stats.record_command_stats(upload_stats);
8215
8216            for copy in &staged_uploads.copies {
8217                let target_buffer = match copy.target {
8218                    UploadTarget::Uniform => &self.uniform_buffer,
8219                    UploadTarget::ShapeData => &self.shape_buffers.shape_buffer,
8220                    UploadTarget::ShapeGradient => &self.shape_buffers.gradient_buffer,
8221                    UploadTarget::ImageVertex => &self.image_vertex_buffer,
8222                    UploadTarget::ImageIndex => &self.image_index_buffer,
8223                    UploadTarget::RetainedGlyphUniform => &self.retained_glyph_uniform_buffer,
8224                    UploadTarget::ReplayTransform => &self.replay_slots.transform_buffer,
8225                    UploadTarget::ReplayPaintData(slot) => {
8226                        // A slot released between staging and flush has
8227                        // nothing left to patch.
8228                        let Some(entry) = self.replay_slots.slots.get(&slot) else {
8229                            continue;
8230                        };
8231                        &entry.paint_buffer
8232                    }
8233                };
8234                encoder.copy_buffer_to_buffer(
8235                    &self.upload_buffer,
8236                    upload_buffer_offset + copy.source_offset,
8237                    target_buffer,
8238                    copy.target_offset,
8239                    copy.size,
8240                );
8241            }
8242        }
8243    }
8244
8245    #[allow(clippy::too_many_arguments)]
8246    fn encode_shadow_draw<C: FrameCommandRecorder>(
8247        &mut self,
8248        frame_encoder: &mut C,
8249        target_view: &wgpu::TextureView,
8250        shadow: &ShadowDraw,
8251        width: u32,
8252        height: u32,
8253        root_scale: f32,
8254    ) {
8255        if shadow.shapes.is_empty() && shadow.texts.is_empty() {
8256            return;
8257        }
8258
8259        let shape_bounds_opt = shadow
8260            .shapes
8261            .iter()
8262            .map(|(shape, _)| shape.rect)
8263            .reduce(|a, b| Rect {
8264                x: a.x.min(b.x),
8265                y: a.y.min(b.y),
8266                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
8267                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
8268            });
8269
8270        let text_bounds_opt = shadow
8271            .texts
8272            .iter()
8273            .map(|text| text.rect)
8274            .reduce(|a, b| Rect {
8275                x: a.x.min(b.x),
8276                y: a.y.min(b.y),
8277                width: (a.x + a.width).max(b.x + b.width) - a.x.min(b.x),
8278                height: (a.y + a.height).max(b.y + b.height) - a.y.min(b.y),
8279            });
8280
8281        let combined_bounds = match (shape_bounds_opt, text_bounds_opt) {
8282            (Some(s), Some(t)) => Some(Rect {
8283                x: s.x.min(t.x),
8284                y: s.y.min(t.y),
8285                width: (s.x + s.width).max(t.x + t.width) - s.x.min(t.x),
8286                height: (s.y + s.height).max(t.y + t.height) - s.y.min(t.y),
8287            }),
8288            (Some(s), None) => Some(s),
8289            (None, Some(t)) => Some(t),
8290            (None, None) => None,
8291        };
8292
8293        let Some(shape_bounds) = combined_bounds else {
8294            return;
8295        };
8296
8297        let blur_margin = blur_extent_margin(shadow.blur_radius);
8298        let source_blur_bounds = Rect {
8299            x: shape_bounds.x - blur_margin,
8300            y: shape_bounds.y - blur_margin,
8301            width: shape_bounds.width + blur_margin * 2.0,
8302            height: shape_bounds.height + blur_margin * 2.0,
8303        };
8304        let mut visible_blur_bounds = source_blur_bounds;
8305        if let Some(clip) = shadow.clip {
8306            let clip_expanded = Rect {
8307                x: clip.x - blur_margin,
8308                y: clip.y - blur_margin,
8309                width: clip.width + blur_margin * 2.0,
8310                height: clip.height + blur_margin * 2.0,
8311            };
8312            let Some(intersection) = visible_blur_bounds.intersect(clip_expanded) else {
8313                return;
8314            };
8315            visible_blur_bounds = intersection;
8316        }
8317        let processing_scissor =
8318            scissor_rect_for_rect(visible_blur_bounds, root_scale, width, height);
8319        if processing_scissor.is_none() {
8320            return;
8321        }
8322
8323        // Zero blur: render shapes directly to target (fast path).
8324        if shadow.blur_radius <= 0.0 {
8325            for (shape, blend_mode) in &shadow.shapes {
8326                self.encode_shapes_pass(
8327                    frame_encoder,
8328                    target_view,
8329                    std::iter::once(shape),
8330                    *blend_mode,
8331                    width,
8332                    height,
8333                    root_scale,
8334                    wgpu::LoadOp::Load,
8335                    [0.0, 0.0],
8336                );
8337                frame_encoder.record_pass();
8338            }
8339            if !shadow.texts.is_empty() {
8340                let mut staged_uploads = self.take_staged_uploads();
8341                let viewport = ViewportUniformParams {
8342                    width,
8343                    height,
8344                    offset: [0.0, 0.0],
8345                };
8346                match self.prepare_text_image_draw_cmds(
8347                    shadow.texts.iter(),
8348                    viewport,
8349                    root_scale,
8350                    &mut staged_uploads,
8351                ) {
8352                    Ok(prepared_images) if !prepared_images.is_empty() => {
8353                        let upload_offset = frame_encoder
8354                            .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
8355                        self.flush_staged_uploads_at(
8356                            frame_encoder.encoder(),
8357                            &staged_uploads,
8358                            upload_offset,
8359                        );
8360                        let draw_result = {
8361                            let mut render_pass = frame_encoder.encoder().begin_render_pass(
8362                                &wgpu::RenderPassDescriptor {
8363                                    label: Some("Zero Blur Shadow Text Image Pass"),
8364                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8365                                        view: target_view,
8366                                        resolve_target: None,
8367                                        depth_slice: None,
8368                                        ops: wgpu::Operations {
8369                                            load: wgpu::LoadOp::Load,
8370                                            store: wgpu::StoreOp::Store,
8371                                        },
8372                                    })],
8373                                    depth_stencil_attachment: None,
8374                                    timestamp_writes: None,
8375                                    occlusion_query_set: None,
8376                                    multiview_mask: None,
8377                                },
8378                            );
8379                            self.draw_prepared_images(
8380                                &mut render_pass,
8381                                &prepared_images,
8382                                BlendMode::SrcOver,
8383                            )
8384                        };
8385                        self.scratch_image_cmds = prepared_images.into_cmds();
8386                        if let Err(e) = draw_result {
8387                            eprintln!("Failed to draw text for zero-blur shadow: {}", e);
8388                        } else {
8389                            self.frame_stats.bump_text();
8390                            frame_encoder.record_pass();
8391                        }
8392                    }
8393                    Ok(prepared_images) => {
8394                        self.scratch_image_cmds = prepared_images.into_cmds();
8395                    }
8396                    Err(e) => {
8397                        eprintln!("Failed to prepare text image for zero-blur shadow: {}", e);
8398                    }
8399                }
8400                self.restore_staged_uploads(staged_uploads);
8401            }
8402            return;
8403        }
8404
8405        // Compute pixel-space bounds for the offscreen textures, clamped to viewport.
8406        let Some(device_bounds) =
8407            device_pixel_bounds_for_rect(visible_blur_bounds, width, height, root_scale)
8408        else {
8409            return;
8410        };
8411        let bounds_x = device_bounds.x;
8412        let bounds_y = device_bounds.y;
8413        let bounds_w = device_bounds.width;
8414        let bounds_h = device_bounds.height;
8415        let pixel_radius = shadow.blur_radius * root_scale;
8416
8417        if shadow.texts.is_empty() && !shadow.shapes.is_empty() {
8418            if let Some(plan) = shape_shadow_surface_plan(
8419                &shadow.shapes,
8420                shadow.clip,
8421                shadow.blur_radius,
8422                width,
8423                height,
8424                root_scale,
8425                self.max_texture_dim(),
8426            ) {
8427                if self.encode_shape_only_blurred_shadow_draw(
8428                    frame_encoder,
8429                    target_view,
8430                    shadow,
8431                    plan.source_device_bounds,
8432                    plan.pixel_radius,
8433                    plan.processing_scissor,
8434                    width,
8435                    height,
8436                    root_scale,
8437                ) {
8438                    return;
8439                }
8440            }
8441        }
8442
8443        if !shadow.texts.is_empty() {
8444            self.frame_stats.record_shadow_text_blur_fallback();
8445        }
8446
8447        let device = self.device.clone();
8448        let source_descriptor =
8449            self.transient_offscreen_descriptor("Shadow Source", bounds_w, bounds_h);
8450        let source = frame_encoder.acquire_transient_offscreen(&device, source_descriptor);
8451        let viewport_offset = [bounds_x, bounds_y];
8452        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
8453        let source_outcome = self.encode_shadow_shape_source_passes(
8454            frame_encoder,
8455            &source.view,
8456            &shadow.shapes,
8457            bounds_w,
8458            bounds_h,
8459            viewport_offset,
8460            root_scale,
8461            &mut next_load_op,
8462        );
8463        frame_encoder.record_passes(source_outcome.pass_count);
8464        let mut rendered_any = source_outcome.rendered_any;
8465
8466        if !shadow.texts.is_empty() {
8467            let mut shifted_texts = shadow.texts.clone();
8468            for text in &mut shifted_texts {
8469                text.rect.x -= viewport_offset[0] / root_scale;
8470                text.rect.y -= viewport_offset[1] / root_scale;
8471                if let Some(clip) = text.clip.as_mut() {
8472                    clip.x -= viewport_offset[0] / root_scale;
8473                    clip.y -= viewport_offset[1] / root_scale;
8474                }
8475            }
8476
8477            let mut staged_uploads = self.take_staged_uploads();
8478            let viewport = ViewportUniformParams {
8479                width: bounds_w,
8480                height: bounds_h,
8481                offset: [0.0, 0.0],
8482            };
8483            match self.prepare_text_image_draw_cmds(
8484                shifted_texts.iter(),
8485                viewport,
8486                root_scale,
8487                &mut staged_uploads,
8488            ) {
8489                Ok(prepared_images) if !prepared_images.is_empty() => {
8490                    let upload_offset = frame_encoder
8491                        .allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
8492                    self.flush_staged_uploads_at(
8493                        frame_encoder.encoder(),
8494                        &staged_uploads,
8495                        upload_offset,
8496                    );
8497                    let draw_result = {
8498                        let mut render_pass = frame_encoder.encoder().begin_render_pass(
8499                            &wgpu::RenderPassDescriptor {
8500                                label: Some("Shadow Source Text Image Pass"),
8501                                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8502                                    view: &source.view,
8503                                    resolve_target: None,
8504                                    depth_slice: None,
8505                                    ops: wgpu::Operations {
8506                                        load: next_load_op,
8507                                        store: wgpu::StoreOp::Store,
8508                                    },
8509                                })],
8510                                depth_stencil_attachment: None,
8511                                timestamp_writes: None,
8512                                occlusion_query_set: None,
8513                                multiview_mask: None,
8514                            },
8515                        );
8516                        self.draw_prepared_images(
8517                            &mut render_pass,
8518                            &prepared_images,
8519                            BlendMode::SrcOver,
8520                        )
8521                    };
8522                    self.scratch_image_cmds = prepared_images.into_cmds();
8523                    if let Err(e) = draw_result {
8524                        eprintln!("Failed to draw text for shadow: {}", e);
8525                    } else {
8526                        self.frame_stats.bump_text();
8527                        frame_encoder.record_pass();
8528                        rendered_any = true;
8529                    }
8530                }
8531                Ok(prepared_images) => {
8532                    self.scratch_image_cmds = prepared_images.into_cmds();
8533                }
8534                Err(e) => {
8535                    eprintln!("Failed to prepare text image for shadow: {}", e);
8536                }
8537            }
8538            self.restore_staged_uploads(staged_uploads);
8539        }
8540
8541        if !rendered_any {
8542            frame_encoder.release_transient_offscreen(source_descriptor, source);
8543            return;
8544        }
8545
8546        let scratch_descriptor =
8547            self.transient_offscreen_descriptor("Shadow Blur Scratch", bounds_w, bounds_h);
8548        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
8549        {
8550            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
8551                frame_encoder,
8552                &device,
8553                &source,
8554                &scratch,
8555                &source.view,
8556                pixel_radius,
8557                pixel_radius,
8558                TileMode::Decal,
8559                None, // No scissor needed — the texture is already bounds-sized
8560            );
8561        }
8562        frame_encoder.record_passes(2);
8563
8564        let clip_scissor = shadow
8565            .clip
8566            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
8567        let scissor = clip_scissor.or(processing_scissor);
8568        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
8569            // Adjust mask coordinates from viewport-space to texture-local space,
8570            // since the blit shader computes world_pos = uv * tex_size.
8571            mask.rect[0] -= viewport_offset[0];
8572            mask.rect[1] -= viewport_offset[1];
8573            mask
8574        });
8575        let dest_viewport = Some((
8576            viewport_offset[0],
8577            viewport_offset[1],
8578            bounds_w as f32,
8579            bounds_h as f32,
8580        ));
8581        {
8582            self.effect_renderer
8583                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8584                    frame_encoder,
8585                    &device,
8586                    &source,
8587                    target_view,
8588                    1.0,
8589                    wgpu::LoadOp::Load,
8590                    scissor,
8591                    rounded_mask,
8592                    BlendMode::SrcOver,
8593                    dest_viewport,
8594                    CompositeSampleMode::Linear,
8595                );
8596        }
8597        frame_encoder.record_pass();
8598        self.effect_renderer.record_blur_pass();
8599        self.effect_renderer.record_composite_pass();
8600        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
8601        frame_encoder.release_transient_offscreen(source_descriptor, source);
8602    }
8603
8604    #[allow(clippy::too_many_arguments)]
8605    fn encode_shadow_shape_source_passes<C: FrameCommandRecorder>(
8606        &mut self,
8607        frame_encoder: &mut C,
8608        source_view: &wgpu::TextureView,
8609        shapes: &[(DrawShape, BlendMode)],
8610        width: u32,
8611        height: u32,
8612        viewport_offset: [f32; 2],
8613        root_scale: f32,
8614        next_load_op: &mut wgpu::LoadOp<wgpu::Color>,
8615    ) -> ShadowSourceRenderOutcome {
8616        if shapes.is_empty() {
8617            return ShadowSourceRenderOutcome {
8618                rendered_any: false,
8619                pass_count: 0,
8620            };
8621        }
8622
8623        let mut staged_uploads = self.take_staged_uploads();
8624        let mut rendered_any = false;
8625        let mut pass_count = 0_u32;
8626        let mut start = 0usize;
8627        while start < shapes.len() {
8628            let blend_mode = supported_blend_mode(shapes[start].1);
8629            let mut end = start + 1;
8630            while end < shapes.len()
8631                && end - start < self.shape_batch_limits.max_shapes_per_batch
8632                && supported_blend_mode(shapes[end].1) == blend_mode
8633            {
8634                end += 1;
8635            }
8636
8637            staged_uploads.clear();
8638            let viewport = ViewportUniformParams {
8639                width,
8640                height,
8641                offset: viewport_offset,
8642            };
8643            let Some(prepared_shape) = self.prepare_shapes_batch(
8644                shapes[start..end]
8645                    .iter()
8646                    .map(|(shape, _blend_mode)| shape)
8647                    .filter(|shape| shape_draw_is_visible_in_viewport(shape, viewport, root_scale)),
8648                root_scale,
8649                viewport,
8650                &mut staged_uploads,
8651            ) else {
8652                start = end;
8653                continue;
8654            };
8655
8656            let upload_offset =
8657                frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
8658            self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
8659
8660            {
8661                let mut render_pass =
8662                    frame_encoder
8663                        .encoder()
8664                        .begin_render_pass(&wgpu::RenderPassDescriptor {
8665                            label: Some("Shadow Source Shape Pass"),
8666                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
8667                                view: source_view,
8668                                resolve_target: None,
8669                                depth_slice: None,
8670                                ops: wgpu::Operations {
8671                                    load: *next_load_op,
8672                                    store: wgpu::StoreOp::Store,
8673                                },
8674                            })],
8675                            depth_stencil_attachment: None,
8676                            timestamp_writes: None,
8677                            occlusion_query_set: None,
8678                            multiview_mask: None,
8679                        });
8680                self.draw_prepared_shapes(
8681                    &mut render_pass,
8682                    blend_mode,
8683                    prepared_shape,
8684                    width,
8685                    height,
8686                );
8687            }
8688
8689            pass_count = pass_count.saturating_add(1);
8690            rendered_any = true;
8691            *next_load_op = wgpu::LoadOp::Load;
8692            start = end;
8693        }
8694
8695        self.restore_staged_uploads(staged_uploads);
8696        ShadowSourceRenderOutcome {
8697            rendered_any,
8698            pass_count,
8699        }
8700    }
8701
8702    #[allow(clippy::too_many_arguments)]
8703    fn encode_shape_only_blurred_shadow_draw<C: FrameCommandRecorder>(
8704        &mut self,
8705        frame_encoder: &mut C,
8706        target_view: &wgpu::TextureView,
8707        shadow: &ShadowDraw,
8708        device_bounds: DevicePixelBounds,
8709        pixel_radius: f32,
8710        processing_scissor: Option<(u32, u32, u32, u32)>,
8711        width: u32,
8712        height: u32,
8713        root_scale: f32,
8714    ) -> bool {
8715        let bounds_w = device_bounds.width;
8716        let bounds_h = device_bounds.height;
8717        let viewport_offset = [device_bounds.x, device_bounds.y];
8718        let cache_key =
8719            shape_shadow_surface_cache_key(&shadow.shapes, device_bounds, pixel_radius, root_scale);
8720
8721        if let Some(key) = cache_key {
8722            if let Some(cached) = self.cached_shadow_surface(&key) {
8723                self.frame_stats
8724                    .record_shadow_shape_cache_hit(bounds_w, bounds_h);
8725                let clip_scissor = shadow
8726                    .clip
8727                    .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
8728                let scissor = clip_scissor.or(processing_scissor);
8729                let rounded_mask =
8730                    inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
8731                        mask.rect[0] -= viewport_offset[0];
8732                        mask.rect[1] -= viewport_offset[1];
8733                        mask
8734                    });
8735                let dest_viewport = Some((
8736                    viewport_offset[0],
8737                    viewport_offset[1],
8738                    bounds_w as f32,
8739                    bounds_h as f32,
8740                ));
8741                {
8742                    self.effect_renderer
8743                        .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8744                            frame_encoder,
8745                            &self.device,
8746                            &cached,
8747                            target_view,
8748                            1.0,
8749                            wgpu::LoadOp::Load,
8750                            scissor,
8751                            rounded_mask,
8752                            BlendMode::SrcOver,
8753                            dest_viewport,
8754                            CompositeSampleMode::Nearest,
8755                        );
8756                }
8757                frame_encoder.record_pass();
8758                self.effect_renderer.record_composite_pass();
8759                return true;
8760            }
8761            self.frame_stats
8762                .record_shadow_shape_cache_miss(bounds_w, bounds_h);
8763            self.frame_stats.maybe_print_shadow_shape_cache_miss(
8764                bounds_w,
8765                bounds_h,
8766                key.content_hash,
8767                pixel_radius,
8768                viewport_offset,
8769                shadow.shapes.len(),
8770                shadow.clip,
8771            );
8772        }
8773
8774        let device = self.device.clone();
8775        let source_descriptor =
8776            self.transient_offscreen_descriptor("Shape Shadow Source", bounds_w, bounds_h);
8777        let source_is_cacheable = cache_key.is_some();
8778        let source = if source_is_cacheable {
8779            self.acquire_retained_surface(bounds_w, bounds_h)
8780        } else {
8781            frame_encoder.acquire_transient_offscreen(&device, source_descriptor)
8782        };
8783        let scratch_descriptor =
8784            self.transient_offscreen_descriptor("Shape Shadow Blur Scratch", bounds_w, bounds_h);
8785        let scratch = frame_encoder.acquire_transient_offscreen(&device, scratch_descriptor);
8786        let mut next_load_op = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
8787        let source_outcome = self.encode_shadow_shape_source_passes(
8788            frame_encoder,
8789            &source.view,
8790            &shadow.shapes,
8791            bounds_w,
8792            bounds_h,
8793            viewport_offset,
8794            root_scale,
8795            &mut next_load_op,
8796        );
8797        frame_encoder.record_passes(source_outcome.pass_count);
8798
8799        if !source_outcome.rendered_any {
8800            frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
8801            if source_is_cacheable {
8802                self.defer_offscreen_release(source);
8803            } else {
8804                frame_encoder.release_transient_offscreen(source_descriptor, source);
8805            }
8806            return true;
8807        }
8808
8809        {
8810            self.effect_renderer.encode_blur_scissored_ping_pong_passes(
8811                frame_encoder,
8812                &device,
8813                &source,
8814                &scratch,
8815                &source.view,
8816                pixel_radius,
8817                pixel_radius,
8818                TileMode::Decal,
8819                None,
8820            );
8821        }
8822        frame_encoder.record_passes(2);
8823
8824        let clip_scissor = shadow
8825            .clip
8826            .and_then(|clip| scissor_rect_for_rect(clip, root_scale, width, height));
8827        let scissor = clip_scissor.or(processing_scissor);
8828        let rounded_mask = inner_shadow_composite_mask(shadow, root_scale).map(|mut mask| {
8829            mask.rect[0] -= viewport_offset[0];
8830            mask.rect[1] -= viewport_offset[1];
8831            mask
8832        });
8833        let dest_viewport = Some((
8834            viewport_offset[0],
8835            viewport_offset[1],
8836            bounds_w as f32,
8837            bounds_h as f32,
8838        ));
8839        {
8840            self.effect_renderer
8841                .encode_composite_to_view_scissored_with_alpha_and_mask_and_blend_mode(
8842                    frame_encoder,
8843                    &device,
8844                    &source,
8845                    target_view,
8846                    1.0,
8847                    wgpu::LoadOp::Load,
8848                    scissor,
8849                    rounded_mask,
8850                    BlendMode::SrcOver,
8851                    dest_viewport,
8852                    CompositeSampleMode::Nearest,
8853                );
8854        }
8855        frame_encoder.record_pass();
8856
8857        self.effect_renderer.record_blur_pass();
8858        self.effect_renderer.record_composite_pass();
8859        frame_encoder.release_transient_offscreen(scratch_descriptor, scratch);
8860        if let Some(key) = cache_key {
8861            self.insert_cached_shadow_surface(key, source);
8862        } else {
8863            frame_encoder.release_transient_offscreen(source_descriptor, source);
8864        }
8865        true
8866    }
8867
8868    fn prepare_shapes_batch<'a, I>(
8869        &mut self,
8870        layer_shapes: I,
8871        root_scale: f32,
8872        viewport: ViewportUniformParams,
8873        staged_uploads: &mut StagedBufferUploads,
8874    ) -> Option<PreparedShapeBatch>
8875    where
8876        I: Iterator<Item = &'a DrawShape>,
8877    {
8878        #[cfg(target_arch = "wasm32")]
8879        let _ = staged_uploads;
8880
8881        // Build shape data for this subset. Callers hand in only shapes visible in
8882        // `viewport`: the segment paths culled at collect time, and the layer and
8883        // shadow-source paths filter at the call site. Re-checking here would run
8884        // the same quad math a second time on every shape of every frame.
8885        let shape_refs: Vec<&DrawShape> = layer_shapes
8886            .take(self.shape_batch_limits.max_shapes_per_batch)
8887            .collect();
8888        let shape_count = shape_refs.len();
8889        if shape_count == 0 {
8890            return None;
8891        }
8892
8893        // Per-shape gradient spans as a prefix sum, so every output slot is
8894        // known before conversion starts and the shapes can convert in
8895        // parallel into disjoint sub-slices.
8896        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
8897        let mut total_gradient_stops = 0u32;
8898        gradient_offsets.push(0);
8899        for shape in &shape_refs {
8900            total_gradient_stops += shape_gradient_stop_count(shape) as u32;
8901            gradient_offsets.push(total_gradient_stops);
8902        }
8903
8904        self.scratch_shape_data.clear();
8905        self.scratch_shape_data
8906            .resize(shape_count, ShapeData::zeroed());
8907        self.scratch_gradients.clear();
8908        self.scratch_gradients
8909            .resize(total_gradient_stops as usize, GradientStop::zeroed());
8910
8911        convert_shapes_into_outputs(
8912            &shape_refs,
8913            &gradient_offsets,
8914            root_scale,
8915            &mut self.scratch_shape_data,
8916            &mut self.scratch_gradients,
8917        );
8918
8919        #[cfg(not(target_arch = "wasm32"))]
8920        {
8921            self.shape_buffers.ensure_capacity(
8922                &self.device,
8923                &self.shape_bind_group_layout,
8924                &self.identity_similarity_buffer,
8925                self.dummy_paint_buffer.as_ref(),
8926                shape_count,
8927                self.scratch_gradients.len().max(1),
8928            );
8929            self.stage_viewport_uniforms(staged_uploads, viewport);
8930            staged_uploads.stage(
8931                UploadTarget::ShapeData,
8932                bytemuck::cast_slice(&self.scratch_shape_data),
8933            );
8934            if !self.scratch_gradients.is_empty() {
8935                staged_uploads.stage(
8936                    UploadTarget::ShapeGradient,
8937                    bytemuck::cast_slice(&self.scratch_gradients),
8938                );
8939            }
8940        }
8941
8942        #[cfg(target_arch = "wasm32")]
8943        let shape_slot = {
8944            let slot = self.claim_wasm_shape_batch();
8945            {
8946                let buffers = &mut self.wasm_shape_batches[slot];
8947                buffers.ensure_capacity(
8948                    &self.device,
8949                    &self.shape_bind_group_layout,
8950                    &self.identity_similarity_buffer,
8951                    self.dummy_paint_buffer.as_ref(),
8952                    shape_count,
8953                    self.scratch_gradients.len().max(1),
8954                );
8955            }
8956            let buffers = &self.wasm_shape_batches[slot];
8957            self.write_wasm_buffer(
8958                &buffers.shape_buffer,
8959                bytemuck::cast_slice(&self.scratch_shape_data),
8960            );
8961            if !self.scratch_gradients.is_empty() {
8962                self.write_wasm_buffer(
8963                    &buffers.gradient_buffer,
8964                    bytemuck::cast_slice(&self.scratch_gradients),
8965                );
8966            }
8967            slot
8968        };
8969
8970        #[cfg(target_arch = "wasm32")]
8971        let uniform_slot = self.prepare_wasm_viewport_uniforms(viewport);
8972
8973        Some(PreparedShapeBatch {
8974            vertex_start: 0,
8975            vertex_count: shape_count as u32 * 6,
8976            #[cfg(target_arch = "wasm32")]
8977            shape_slot,
8978            #[cfg(target_arch = "wasm32")]
8979            uniform_slot,
8980        })
8981    }
8982
8983    /// Like [`Self::prepare_shapes_batch`], but converts shapes straight into
8984    /// mapped regions of the frame upload buffer instead of scratch vectors —
8985    /// one CPU pass over the data instead of three (convert, stage, upload).
8986    /// Returns the prepared batch and the upload-buffer base offset to pass
8987    /// to `flush_staged_uploads_at`; the GPU copies are recorded into
8988    /// `staged_uploads` while its byte blob stays empty.
8989    #[cfg(not(target_arch = "wasm32"))]
8990    fn prepare_shapes_batch_direct<'a, I, C: FrameCommandRecorder>(
8991        &mut self,
8992        frame_encoder: &mut C,
8993        layer_shapes: I,
8994        root_scale: f32,
8995        viewport: ViewportUniformParams,
8996        staged_uploads: &mut StagedBufferUploads,
8997    ) -> Option<(PreparedShapeBatch, u64)>
8998    where
8999        I: Iterator<Item = &'a DrawShape>,
9000    {
9001        let shape_refs: Vec<&DrawShape> = layer_shapes
9002            .take(self.shape_batch_limits.max_shapes_per_batch)
9003            .collect();
9004        let shape_count = shape_refs.len();
9005        if shape_count == 0 {
9006            return None;
9007        }
9008
9009        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
9010        let mut total_gradient_stops = 0u32;
9011        gradient_offsets.push(0);
9012        for shape in &shape_refs {
9013            total_gradient_stops += shape_gradient_stop_count(shape) as u32;
9014            gradient_offsets.push(total_gradient_stops);
9015        }
9016
9017        self.shape_buffers.ensure_capacity(
9018            &self.device,
9019            &self.shape_bind_group_layout,
9020            &self.identity_similarity_buffer,
9021            self.dummy_paint_buffer.as_ref(),
9022            shape_count,
9023            (total_gradient_stops as usize).max(1),
9024        );
9025
9026        self.scratch_shape_data.clear();
9027        self.scratch_shape_data
9028            .resize(shape_count, ShapeData::zeroed());
9029        self.scratch_gradients.clear();
9030        self.scratch_gradients
9031            .resize(total_gradient_stops as usize, GradientStop::zeroed());
9032        convert_shapes_into_outputs(
9033            &shape_refs,
9034            &gradient_offsets,
9035            root_scale,
9036            &mut self.scratch_shape_data,
9037            &mut self.scratch_gradients,
9038        );
9039
9040        // Region layout inside the frame upload buffer. Every element type is
9041        // f32/u32-based, so all lengths are multiples of
9042        // `COPY_BUFFER_ALIGNMENT` and back-to-back packing keeps each offset
9043        // copy-aligned. Writing each scratch slice straight into the upload
9044        // buffer skips the intermediate staged-bytes blob (one fewer CPU pass
9045        // over the batch payload).
9046        let uniform_len = std::mem::size_of::<Uniforms>() as u64;
9047        let shape_len = (shape_count * std::mem::size_of::<ShapeData>()) as u64;
9048        let gradient_len = total_gradient_stops as u64 * std::mem::size_of::<GradientStop>() as u64;
9049        let total_len = uniform_len + shape_len + gradient_len;
9050        let upload_base = frame_encoder.allocate_staged_upload_bytes(total_len);
9051        self.ensure_upload_buffer_capacity(upload_base + total_len);
9052
9053        let shape_off = uniform_len;
9054        let gradient_off = shape_off + shape_len;
9055
9056        let uniforms = Self::viewport_uniforms(viewport);
9057        let mut upload_stats = self.frame_graph_executor.upload_buffer(
9058            &self.queue,
9059            &self.upload_buffer,
9060            upload_base,
9061            bytemuck::bytes_of(&uniforms),
9062        );
9063        upload_stats.upload_bytes += self
9064            .frame_graph_executor
9065            .upload_buffer(
9066                &self.queue,
9067                &self.upload_buffer,
9068                upload_base + shape_off,
9069                bytemuck::cast_slice(&self.scratch_shape_data),
9070            )
9071            .upload_bytes;
9072        if !self.scratch_gradients.is_empty() {
9073            upload_stats.upload_bytes += self
9074                .frame_graph_executor
9075                .upload_buffer(
9076                    &self.queue,
9077                    &self.upload_buffer,
9078                    upload_base + gradient_off,
9079                    bytemuck::cast_slice(&self.scratch_gradients),
9080                )
9081                .upload_bytes;
9082        }
9083        self.frame_stats.record_command_stats(upload_stats);
9084
9085        staged_uploads.record_upload_copy(UploadTarget::Uniform, 0, 0, uniform_len);
9086        staged_uploads.record_upload_copy(UploadTarget::ShapeData, shape_off, 0, shape_len);
9087        staged_uploads.record_upload_copy(
9088            UploadTarget::ShapeGradient,
9089            gradient_off,
9090            0,
9091            gradient_len,
9092        );
9093
9094        Some((
9095            PreparedShapeBatch {
9096                vertex_start: 0,
9097                vertex_count: shape_count as u32 * 6,
9098            },
9099            upload_base,
9100        ))
9101    }
9102
9103    /// Whether retained replay batches can exist on this device: they bind
9104    /// unsized buffers, so they ride the storage-buffer batch mode only.
9105    /// Always `false` on wasm, which has no retained replay path — the
9106    /// method exists on both arches so the packet producer has one
9107    /// architecture.
9108    pub(crate) fn replay_supported(&self) -> bool {
9109        // Deliberately not conditioned on free slot ids: an exhausted pool
9110        // only means new captures fail (handled per capture), while flipping
9111        // this bit would retire every live feed slot.
9112        #[cfg(target_arch = "wasm32")]
9113        {
9114            false
9115        }
9116        #[cfg(not(target_arch = "wasm32"))]
9117        {
9118            self.shape_batch_limits.storage
9119        }
9120    }
9121
9122    /// Return the planner-drained ack confirmations buffer (capacity
9123    /// intact) to the store after the producer applied a frame's
9124    /// [`crate::frame_packet::ReplayAck`] — the ack channel's half of the
9125    /// P4b no-allocation contract, closed by the caller now that ack
9126    /// application lives producer-side. No-op on wasm.
9127    pub(crate) fn restore_replay_ack_confirmations(
9128        &mut self,
9129        confirmations: Vec<crate::frame_packet::ReplayConfirmation>,
9130    ) {
9131        #[cfg(not(target_arch = "wasm32"))]
9132        {
9133            self.replay_ack_confirmations = confirmations;
9134        }
9135        #[cfg(target_arch = "wasm32")]
9136        let _ = confirmations;
9137    }
9138
9139    /// Present-side consumption of one frame's [`ReplayFrameOps`]: frees
9140    /// the plan's releases, then honors its capture requests against the
9141    /// scene they were recorded for, answering with a [`ReplayAck`] of
9142    /// (identity, gpu slot) confirmations plus the batch's emptied buffers
9143    /// for recycling. This is the store half of the split — it touches NO
9144    /// planner state: `feed_slots`, confirmation stamping, displaced-slot
9145    /// release, and age eviction all live in the planner
9146    /// (`take_frame_ops`/`apply_ack`).
9147    ///
9148    /// Ordering is what makes slot release safe: a slot the plan releases
9149    /// is never referenced by a retained op of the same frame (misses
9150    /// release before their op would have been pushed, and rebuild frames
9151    /// release at flush start), so freeing it here — before any encoding —
9152    /// cannot orphan a draw.
9153    #[cfg(not(target_arch = "wasm32"))]
9154    fn consume_replay_ops(
9155        &mut self,
9156        mut ops: crate::frame_packet::ReplayFrameOps,
9157        shapes: &[DrawShape],
9158        root_scale: f32,
9159    ) -> (
9160        crate::frame_packet::ReplayAck,
9161        crate::frame_packet::ReplayFrameOps,
9162    ) {
9163        if ops.generation < self.store_feed_generation {
9164            // Fail-closed: ops planned under an OLDER slot universe name
9165            // slots this store does not hold. Drop the batch whole —
9166            // captures unconfirmed self-heal (the planner never serves
9167            // them), and stale releases must not free live ids.
9168            // Synchronously impossible today; structural for the split.
9169            self.replay_generation_drops += 1;
9170            log::warn!(
9171                "[command-feed] dropping replay ops of generation {} against store \
9172                 generation {} ({} captures, {} patches, {} releases; lifetime drops {})",
9173                ops.generation,
9174                self.store_feed_generation,
9175                ops.captures.len(),
9176                ops.color_patches.len(),
9177                ops.releases.len(),
9178                self.replay_generation_drops,
9179            );
9180            ops.captures.clear();
9181            ops.color_patches.clear();
9182            ops.releases.clear();
9183            return (
9184                crate::frame_packet::ReplayAck {
9185                    generation: self.store_feed_generation,
9186                    confirmations: Vec::new(),
9187                },
9188                ops,
9189            );
9190        }
9191        if ops.generation > self.store_feed_generation {
9192            // Adopt forward: a producer-side bump (scale change,
9193            // `retire_feed`) delivers its whole retirement — the releases
9194            // for every retired slot — THROUGH this very batch, so a
9195            // higher generation is the new universe arriving, not a stale
9196            // one. The store follows the producer's authority; it never
9197            // reads the producer's thread-local.
9198            self.store_feed_generation = ops.generation;
9199        }
9200        let generation = ops.generation;
9201        // Queued releases free first, so their buffers are available before
9202        // this frame's captures ask.
9203        for slot in ops.releases.drain(..) {
9204            self.release_replay_slot(slot);
9205        }
9206        // `take` leaves `Vec::new()` behind (no allocation); the render
9207        // loop restores the vec after the planner drains the ack.
9208        let mut confirmations = std::mem::take(&mut self.replay_ack_confirmations);
9209        debug_assert!(confirmations.is_empty());
9210        for capture in ops.captures.drain(..) {
9211            if capture.frame != ops.frame {
9212                // Defensive: a capture that outlived its frame references
9213                // shape indices of a scene that never rendered; honoring it
9214                // against THIS frame's shapes would retain wrong content
9215                // under a confirmed identity. Categorically drop it. Should
9216                // never fire now that ops travel inside the frame's own
9217                // packet.
9218                log::warn!(
9219                    "[command-feed] dropping stale capture for slot {} of {:?} \
9220                     (queued frame {}, ops frame {})",
9221                    capture.key.1,
9222                    capture.key.0,
9223                    capture.frame,
9224                    ops.frame,
9225                );
9226                continue;
9227            }
9228            let end = capture.shape_start + capture.shape_count;
9229            let Some(slice) = shapes.get(capture.shape_start..end) else {
9230                continue;
9231            };
9232            let refs: Vec<&DrawShape> = slice.iter().collect();
9233            let Some(gpu_slot) = self.capture_replay_slot(&refs, root_scale) else {
9234                continue;
9235            };
9236            confirmations.push((capture.key, gpu_slot));
9237        }
9238        // Park the frame's recolor patches for the retained prepare arms
9239        // (`stage_replay_patches`); the vec swapped out is last frame's,
9240        // already drained empty, and returns to the producer with the ack.
9241        // The defensive clear only bites when no prepare arm ran last
9242        // frame (aborted render): those patches targeted a frame that
9243        // never encoded, and their spans re-queue fresh recolors each
9244        // served frame.
9245        self.replay_color_patches.clear();
9246        std::mem::swap(&mut self.replay_color_patches, &mut ops.color_patches);
9247        (
9248            crate::frame_packet::ReplayAck {
9249                generation,
9250                confirmations,
9251            },
9252            ops,
9253        )
9254    }
9255
9256    /// Test/diagnostic view of the store's lifetime count of replay-ops
9257    /// batches dropped whole by the generation check — the consume gate's
9258    /// proof that Surface frames (default plans, generation 0) are never
9259    /// fed to the store.
9260    #[cfg(not(target_arch = "wasm32"))]
9261    pub(crate) fn replay_generation_drops(&self) -> u64 {
9262        self.replay_generation_drops
9263    }
9264
9265    /// Test hook for the message protocol: runs one planner→store→planner
9266    /// replay cycle outside a frame, with the batch stamped
9267    /// `store_feed_generation + generation_skew`, and returns how many
9268    /// captures the store confirmed. A skew that lands BELOW the store's
9269    /// generation manufactures the fail-closed drop; a skew above it
9270    /// exercises adopt-forward. Both are synchronously impossible through
9271    /// the public render path today.
9272    #[cfg(not(target_arch = "wasm32"))]
9273    pub(crate) fn replay_ops_roundtrip_for_tests(&mut self, generation_skew: u64) -> usize {
9274        let generation = self.store_feed_generation.wrapping_add(generation_skew);
9275        let ops = crate::shape_replay::SHAPE_REPLAY
9276            .with(|state| state.borrow_mut().take_frame_ops(generation));
9277        let (ack, recycled) = self.consume_replay_ops(ops, &[], 1.0);
9278        let confirmed = ack.confirmations.len();
9279        self.replay_ack_confirmations = crate::shape_replay::SHAPE_REPLAY
9280            .with(|state| state.borrow_mut().apply_ack(ack, recycled));
9281        confirmed
9282    }
9283
9284    /// Stages every queued replay recolor patch. Feed recolors are always
9285    /// solid, so every patch rewrites the shape's 16-byte record in the
9286    /// slot's paint buffer; the captured `ShapeData` itself is immutable, so
9287    /// a recolored frame uploads colors, not geometry. Runs in the retained
9288    /// prepare arms so the writes land in the same staged-upload flush that
9289    /// carries the frame's transforms; draining is idempotent across arms.
9290    #[cfg(not(target_arch = "wasm32"))]
9291    fn stage_replay_patches(&mut self, staged_uploads: &mut StagedBufferUploads) {
9292        // Capacity-retaining drain: swap the frame's parked patch buffer
9293        // (see `consume_replay_ops`) against the scratch arena instead of
9294        // `mem::take`, so both keep their high-water capacity across
9295        // frames. The scratch is cleared before every return, which
9296        // preserves drain idempotence across the retained prepare arms: a
9297        // later drain in the same frame swaps one empty-with-capacity
9298        // arena for another and stages nothing.
9299        std::mem::swap(
9300            &mut self.replay_color_patches,
9301            &mut self.color_patch_scratch,
9302        );
9303        let total_patches = self.color_patch_scratch.len();
9304        if total_patches == 0 {
9305            self.replay_upload_stats.note_frame(0, 0, 0, 0, 0);
9306            return;
9307        }
9308
9309        // Patches land in the slot's CPU mirror and upload as one contiguous
9310        // span per slot. Uploading each patch individually would record one
9311        // copy command per patch, and MEGA's twinkle field recolors ~1.7k
9312        // dots a frame — that many commands stall a mobile GPU for longer
9313        // than the spans' untouched bytes ever cost.
9314        #[derive(Clone, Copy)]
9315        struct DirtySpan {
9316            paint_min: u32,
9317            paint_max: u32,
9318        }
9319        const CLEAN: DirtySpan = DirtySpan {
9320            paint_min: u32::MAX,
9321            paint_max: 0,
9322        };
9323        let mut dirty: std::collections::HashMap<
9324            u32,
9325            DirtySpan,
9326            cranpose_ui_graphics::FxBuildHasher,
9327        > = std::collections::HashMap::default();
9328
9329        // One bare 16-byte write into the slot's paint mirror per patch.
9330        for patch in &self.color_patch_scratch {
9331            let Some(slot) = self.replay_slots.slots.get_mut(&patch.slot) else {
9332                continue;
9333            };
9334            let Some(paint) = slot.paint_mirror.get_mut(patch.shape_index as usize) else {
9335                continue;
9336            };
9337            *paint = patch.color;
9338            let span = dirty.entry(patch.slot).or_insert(CLEAN);
9339            span.paint_min = span.paint_min.min(patch.shape_index);
9340            span.paint_max = span.paint_max.max(patch.shape_index);
9341        }
9342
9343        let mut uploaded_records = 0u64;
9344        let mut uploaded_bytes = 0u64;
9345        let slots_touched = dirty.len() as u64;
9346        for (slot_id, span) in dirty {
9347            let Some(slot) = self.replay_slots.slots.get(&slot_id) else {
9348                continue;
9349            };
9350            if span.paint_min <= span.paint_max {
9351                let range = span.paint_min as usize..span.paint_max as usize + 1;
9352                uploaded_records += range.len() as u64;
9353                uploaded_bytes += (range.len() * std::mem::size_of::<[f32; 4]>()) as u64;
9354                staged_uploads.stage_at(
9355                    UploadTarget::ReplayPaintData(slot_id),
9356                    range.start as u64 * std::mem::size_of::<[f32; 4]>() as u64,
9357                    bytemuck::cast_slice(&slot.paint_mirror[range]),
9358                );
9359            }
9360        }
9361        // A patched color is one 16-byte vec4; the staged bytes exceed this
9362        // only by the untouched records inside each coalesced span.
9363        let ideal_bytes = total_patches as u64 * 16;
9364        self.replay_upload_stats.note_frame(
9365            total_patches as u64,
9366            slots_touched,
9367            uploaded_records,
9368            uploaded_bytes,
9369            ideal_bytes,
9370        );
9371        if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
9372            log::warn!(
9373                "[replay-upload] frame: {} patches -> {} records / {:.1} KB staged \
9374                 across {} slots (color-only {:.1} KB)",
9375                total_patches,
9376                uploaded_records,
9377                uploaded_bytes as f64 / 1024.0,
9378                slots_touched,
9379                ideal_bytes as f64 / 1024.0,
9380            );
9381        }
9382        self.color_patch_scratch.clear();
9383    }
9384
9385    /// Converts `shape_refs` once and retains the result on the GPU as a
9386    /// replay slot. Returns the slot id the scene's retained draws reference.
9387    #[cfg(not(target_arch = "wasm32"))]
9388    pub(crate) fn capture_replay_slot(
9389        &mut self,
9390        shape_refs: &[&DrawShape],
9391        root_scale: f32,
9392    ) -> Option<u32> {
9393        if !self.shape_batch_limits.storage || shape_refs.is_empty() {
9394            return None;
9395        }
9396        let id = self.replay_slots.free_ids.pop()?;
9397        let shape_count = shape_refs.len();
9398
9399        let mut gradient_offsets: Vec<u32> = Vec::with_capacity(shape_count + 1);
9400        let mut total_gradient_stops = 0u32;
9401        gradient_offsets.push(0);
9402        for shape in shape_refs {
9403            total_gradient_stops += shape_gradient_stop_count(shape) as u32;
9404            gradient_offsets.push(total_gradient_stops);
9405        }
9406
9407        let mut shape_data = vec![ShapeData::zeroed(); shape_count];
9408        let mut gradients = vec![GradientStop::zeroed(); (total_gradient_stops as usize).max(1)];
9409        convert_shapes_into_outputs(
9410            shape_refs,
9411            &gradient_offsets,
9412            root_scale,
9413            &mut shape_data,
9414            &mut gradients,
9415        );
9416
9417        let shape_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9418            label: Some("Replay Shape Buffer"),
9419            size: (std::mem::size_of::<ShapeData>() * shape_count) as u64,
9420            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
9421            mapped_at_creation: true,
9422        });
9423        shape_buffer
9424            .slice(..)
9425            .get_mapped_range_mut()
9426            .copy_from_slice(bytemuck::cast_slice(&shape_data));
9427        shape_buffer.unmap();
9428
9429        let gradient_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9430            label: Some("Replay Gradient Buffer"),
9431            size: (std::mem::size_of::<GradientStop>() * gradients.len()) as u64,
9432            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
9433            mapped_at_creation: true,
9434        });
9435        gradient_buffer
9436            .slice(..)
9437            .get_mapped_range_mut()
9438            .copy_from_slice(bytemuck::cast_slice(&gradients));
9439        gradient_buffer.unmap();
9440
9441        let mesh = if arc_mesh_enabled() {
9442            match build_arc_mesh_vertices(&shape_data) {
9443                Some(build) => {
9444                    let cut = if build.quad_area > 0.0 {
9445                        (1.0 - build.mesh_area / build.quad_area) * 100.0
9446                    } else {
9447                        0.0
9448                    };
9449                    // Always-on warn: `log::info` is invisible on the desktop
9450                    // console, and captures are rare — one line per slot
9451                    // lifetime. The unique-vert/index counts against the
9452                    // six-per-shape quad baseline are the vertex-amplification
9453                    // instrument P1b exists for.
9454                    log::warn!(
9455                        "[arc-mesh] slot {id}: {} arcs meshed ({} segs), {} passthrough; \
9456                         {} unique verts / {} indices (quad path: {} verts); \
9457                         quad_px {:.0} -> mesh_px {:.0} (-{:.1}%)",
9458                        build.meshed_arcs,
9459                        build.meshed_segments,
9460                        build.passthrough,
9461                        build.vertices.len(),
9462                        build.indices.len(),
9463                        shape_count * 6,
9464                        build.quad_area,
9465                        build.mesh_area,
9466                        cut,
9467                    );
9468                    // A slot that meshed nothing gains nothing over the
9469                    // indexless quad path — skip the buffers.
9470                    (build.meshed_arcs > 0).then(|| {
9471                        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9472                            label: Some("Replay Mesh Vertex Buffer"),
9473                            size: (std::mem::size_of::<MeshVertex>() * build.vertices.len()) as u64,
9474                            usage: wgpu::BufferUsages::VERTEX,
9475                            mapped_at_creation: true,
9476                        });
9477                        vertex_buffer
9478                            .slice(..)
9479                            .get_mapped_range_mut()
9480                            .copy_from_slice(bytemuck::cast_slice(&build.vertices));
9481                        vertex_buffer.unmap();
9482                        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9483                            label: Some("Replay Mesh Index Buffer"),
9484                            size: (std::mem::size_of::<u32>() * build.indices.len()) as u64,
9485                            usage: wgpu::BufferUsages::INDEX,
9486                            mapped_at_creation: true,
9487                        });
9488                        index_buffer
9489                            .slice(..)
9490                            .get_mapped_range_mut()
9491                            .copy_from_slice(bytemuck::cast_slice(&build.indices));
9492                        index_buffer.unmap();
9493                        ReplaySlotMesh {
9494                            vertex_buffer,
9495                            index_buffer,
9496                            index_prefix: build.index_prefix,
9497                        }
9498                    })
9499                }
9500                None => {
9501                    log::warn!(
9502                        "[arc-mesh] slot {id}: geometry byte budget overflowed for \
9503                         {shape_count} shapes; whole slot falls back to quad passthrough"
9504                    );
9505                    None
9506                }
9507            }
9508        } else {
9509            None
9510        };
9511
9512        // Seed the mutable paint from the converted colors, so an unpatched
9513        // replay renders bit-identically to the capture frame.
9514        let paint: Vec<[f32; 4]> = shape_data.iter().map(|shape| shape.color).collect();
9515        let paint_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
9516            label: Some("Replay Paint Buffer"),
9517            size: (std::mem::size_of::<[f32; 4]>() * shape_count) as u64,
9518            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
9519            mapped_at_creation: true,
9520        });
9521        paint_buffer
9522            .slice(..)
9523            .get_mapped_range_mut()
9524            .copy_from_slice(bytemuck::cast_slice(&paint));
9525        paint_buffer.unmap();
9526
9527        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
9528            label: Some("Replay Shape Bind Group"),
9529            layout: &self.shape_bind_group_layout,
9530            entries: &[
9531                wgpu::BindGroupEntry {
9532                    binding: 0,
9533                    resource: shape_buffer.as_entire_binding(),
9534                },
9535                wgpu::BindGroupEntry {
9536                    binding: 1,
9537                    resource: gradient_buffer.as_entire_binding(),
9538                },
9539                // The transform slot is selected per draw via the dynamic
9540                // offset, so retained draws sharing this capture can each
9541                // move independently.
9542                wgpu::BindGroupEntry {
9543                    binding: 2,
9544                    resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
9545                        buffer: &self.replay_slots.transform_buffer,
9546                        offset: 0,
9547                        size: Some(
9548                            std::num::NonZeroU64::new(
9549                                std::mem::size_of::<SimilarityTransform>() as u64
9550                            )
9551                            .expect("similarity transform is non-empty"),
9552                        ),
9553                    }),
9554                },
9555                wgpu::BindGroupEntry {
9556                    binding: 3,
9557                    resource: paint_buffer.as_entire_binding(),
9558                },
9559            ],
9560        });
9561
9562        let capture_epoch = self.replay_slots.next_capture_epoch;
9563        self.replay_slots.next_capture_epoch += 1;
9564        self.replay_slots.slots.insert(
9565            id,
9566            ReplaySlot {
9567                paint_buffer,
9568                bind_group,
9569                shape_count: shape_count as u32,
9570                paint_mirror: paint,
9571                mesh,
9572                capture_epoch,
9573            },
9574        );
9575        Some(id)
9576    }
9577
9578    /// Frees a replay slot's GPU resources and returns its id to the pool.
9579    #[cfg(not(target_arch = "wasm32"))]
9580    pub(crate) fn release_replay_slot(&mut self, id: u32) {
9581        if self.replay_slots.slots.remove(&id).is_some() {
9582            self.replay_slots.free_ids.push(id);
9583            // A cached bundle keeps references on the slot buffers it binds.
9584            // The epoch in each key already makes entries for this capture
9585            // unreachable — releases are rare (churn, retire_feed), so drop
9586            // the whole cache and free those references now rather than one
9587            // frame later through eviction.
9588            self.retained_bundle_cache.clear();
9589        }
9590    }
9591
9592    /// Test/diagnostic view of the latched instanced-quad selection: `true`
9593    /// when this renderer's ordinary shape draws ride `vs_shape_instanced`.
9594    #[cfg(not(target_arch = "wasm32"))]
9595    #[doc(hidden)]
9596    pub fn instanced_quads_active(&self) -> bool {
9597        self.instanced_quads.is_some()
9598    }
9599
9600    /// Test/diagnostic view of retained arc meshes: how many live replay
9601    /// slots hold a mesh, out of all live slots.
9602    #[cfg(not(target_arch = "wasm32"))]
9603    #[doc(hidden)]
9604    pub fn replay_slot_mesh_stats(&self) -> (usize, usize) {
9605        let meshed = self
9606            .replay_slots
9607            .slots
9608            .values()
9609            .filter(|slot| slot.mesh.is_some())
9610            .count();
9611        (meshed, self.replay_slots.slots.len())
9612    }
9613
9614    /// Draws one retained replay batch — `retained`'s shape range of its
9615    /// slot's capture, under the transform staged for this draw's index (see
9616    /// the retained arms of the segment paths).
9617    #[cfg(not(target_arch = "wasm32"))]
9618    fn draw_retained_batch(
9619        &self,
9620        render_pass: &mut wgpu::RenderPass<'_>,
9621        retained: &RetainedDraw,
9622        retained_index: usize,
9623        width: u32,
9624        height: u32,
9625    ) {
9626        let Some(slot) = self.replay_slots.slots.get(&retained.slot) else {
9627            return;
9628        };
9629        if retained_index as u32 >= MAX_REPLAY_SLOTS {
9630            return;
9631        }
9632        let first = retained.first_shape.min(slot.shape_count);
9633        let last = retained
9634            .first_shape
9635            .saturating_add(retained.shape_count)
9636            .min(slot.shape_count);
9637        if first >= last {
9638            return;
9639        }
9640        self.frame_stats.bump_shapes();
9641        self.frame_stats.add_draw_calls(1);
9642        render_pass.set_scissor_rect(0, 0, width, height);
9643        // A captured mesh replaces the six-per-shape quad expansion with the
9644        // slot's conservative arc mesh — same bind groups, same SrcOver
9645        // blend, one draw per op over the identical shape range, so z order
9646        // is untouched either way. Slots without a mesh draw through the
9647        // latched instanced-quad path when it exists (four vertex executions
9648        // per shape, shape index from the instance index), else the plain
9649        // six-vertex expansion.
9650        let mesh = slot.mesh.as_ref().map(|mesh| (mesh, self.mesh_pipeline()));
9651        match &mesh {
9652            Some((_, mesh_pipeline)) => render_pass.set_pipeline(mesh_pipeline),
9653            None => match &self.instanced_quads {
9654                Some(instanced) => {
9655                    render_pass.set_pipeline(self.instanced_pipeline(instanced, BlendMode::SrcOver))
9656                }
9657                None => render_pass.set_pipeline(self.shape_pipeline(BlendMode::SrcOver)),
9658            },
9659        }
9660        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
9661        render_pass.set_bind_group(
9662            1,
9663            &slot.bind_group,
9664            &[retained_index as u32 * REPLAY_TRANSFORM_STRIDE as u32],
9665        );
9666        match mesh {
9667            Some((mesh, _)) => {
9668                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
9669                render_pass
9670                    .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
9671                render_pass.draw_indexed(
9672                    mesh.index_prefix[first as usize]..mesh.index_prefix[last as usize],
9673                    0,
9674                    0..1,
9675                );
9676            }
9677            None => match &self.instanced_quads {
9678                Some(instanced) => {
9679                    render_pass.set_index_buffer(
9680                        instanced.index_buffer.slice(..),
9681                        wgpu::IndexFormat::Uint16,
9682                    );
9683                    render_pass.draw_indexed(0..6, 0, first..last);
9684                }
9685                None => render_pass.draw(first * 6..last * 6, 0..1),
9686            },
9687        }
9688    }
9689
9690    /// Key of the retained stretch at `item_range`: one op key per resolved
9691    /// retained item, in draw order, carrying exactly the state that decides
9692    /// the commands [`Self::draw_retained_batch`] would encode for it —
9693    /// clamped range, dynamic-offset index, mesh-vs-quad pipeline choice,
9694    /// and the slot's capture epoch (`None` while the slot is absent, when
9695    /// the op draws nothing on the direct path too).
9696    #[cfg(not(target_arch = "wasm32"))]
9697    fn retained_bundle_key(
9698        &self,
9699        ordered_items: &[(usize, SegmentDrawItem)],
9700        retained_draws: &[RetainedDraw],
9701        item_range: Range<usize>,
9702    ) -> RetainedBundleKey {
9703        let mut ops = Vec::with_capacity(item_range.len());
9704        for (_, item) in &ordered_items[item_range] {
9705            let SegmentDrawItem::Retained(index) = item else {
9706                continue;
9707            };
9708            let Some(retained) = retained_draws.get(*index) else {
9709                continue;
9710            };
9711            let slot = self.replay_slots.slots.get(&retained.slot);
9712            let (first, last) = match slot {
9713                Some(slot) => (
9714                    retained.first_shape.min(slot.shape_count),
9715                    retained
9716                        .first_shape
9717                        .saturating_add(retained.shape_count)
9718                        .min(slot.shape_count),
9719                ),
9720                None => (
9721                    retained.first_shape,
9722                    retained.first_shape.saturating_add(retained.shape_count),
9723                ),
9724            };
9725            ops.push(RetainedBundleOpKey {
9726                slot: retained.slot,
9727                capture_epoch: slot.map(|slot| slot.capture_epoch),
9728                first,
9729                last,
9730                retained_index: *index as u32,
9731                has_mesh: slot.is_some_and(|slot| slot.mesh.is_some())
9732                    && self.shape_batch_limits.storage,
9733            });
9734        }
9735        RetainedBundleKey { ops }
9736    }
9737
9738    /// Encodes `key`'s stretch into a render bundle: the IDENTICAL command
9739    /// sequence [`Self::draw_retained_batch`] issues on the pass, minus the
9740    /// scissor reset (bundles cannot set scissor; the caller sets the same
9741    /// full-target scissor on the pass before executing). Must only be
9742    /// called with a key built this frame, so every op with an epoch still
9743    /// resolves to its slot.
9744    #[cfg(not(target_arch = "wasm32"))]
9745    fn build_retained_bundle(&self, key: &RetainedBundleKey) -> wgpu::RenderBundle {
9746        let mut encoder =
9747            self.device
9748                .create_render_bundle_encoder(&wgpu::RenderBundleEncoderDescriptor {
9749                    label: Some("Retained Stretch Bundle"),
9750                    // Every fused-pass target — the swapchain, screenshot
9751                    // textures, pooled layer surfaces — is created with the
9752                    // renderer's one surface format.
9753                    color_formats: &[Some(self.surface_format)],
9754                    depth_stencil: None,
9755                    sample_count: 1,
9756                    multiview: None,
9757                });
9758        for op in &key.ops {
9759            if op.capture_epoch.is_none()
9760                || op.retained_index >= MAX_REPLAY_SLOTS
9761                || op.first >= op.last
9762            {
9763                continue;
9764            }
9765            let Some(slot) = self.replay_slots.slots.get(&op.slot) else {
9766                continue;
9767            };
9768            let mesh = slot.mesh.as_ref().map(|mesh| (mesh, self.mesh_pipeline()));
9769            match &mesh {
9770                Some((_, mesh_pipeline)) => encoder.set_pipeline(mesh_pipeline),
9771                None => match &self.instanced_quads {
9772                    Some(instanced) => {
9773                        encoder.set_pipeline(self.instanced_pipeline(instanced, BlendMode::SrcOver))
9774                    }
9775                    None => encoder.set_pipeline(self.shape_pipeline(BlendMode::SrcOver)),
9776                },
9777            }
9778            encoder.set_bind_group(0, &self.uniform_bind_group, &[]);
9779            encoder.set_bind_group(
9780                1,
9781                &slot.bind_group,
9782                &[op.retained_index * REPLAY_TRANSFORM_STRIDE as u32],
9783            );
9784            match mesh {
9785                Some((mesh, _)) => {
9786                    encoder.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
9787                    encoder
9788                        .set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
9789                    encoder.draw_indexed(
9790                        mesh.index_prefix[op.first as usize]..mesh.index_prefix[op.last as usize],
9791                        0,
9792                        0..1,
9793                    );
9794                }
9795                // The latched selection is a per-renderer constant, so it
9796                // needs no place in `RetainedBundleOpKey` — every cached
9797                // bundle in this renderer's lifetime encodes the same choice
9798                // the direct path makes.
9799                None => match &self.instanced_quads {
9800                    Some(instanced) => {
9801                        encoder.set_index_buffer(
9802                            instanced.index_buffer.slice(..),
9803                            wgpu::IndexFormat::Uint16,
9804                        );
9805                        encoder.draw_indexed(0..6, 0, op.first..op.last);
9806                    }
9807                    None => encoder.draw(op.first * 6..op.last * 6, 0..1),
9808                },
9809            }
9810        }
9811        encoder.finish(&wgpu::RenderBundleDescriptor {
9812            label: Some("Retained Stretch Bundle"),
9813        })
9814    }
9815
9816    /// Draws one maximal consecutive retained stretch through the bundle
9817    /// cache: key the stretch, rebuild on any mismatch (recapture, reorder,
9818    /// range or count change, slot release), then execute the cached bundle.
9819    /// Replays byte-identical commands to the per-op direct path.
9820    /// `stage_replay_patches` and the per-frame transform staging stay in
9821    /// the prepare arms, untouched — bundles bind buffers whose contents are
9822    /// read at execution.
9823    #[cfg(not(target_arch = "wasm32"))]
9824    fn draw_retained_stretch_bundled(
9825        &mut self,
9826        render_pass: &mut wgpu::RenderPass<'_>,
9827        ordered_items: &[(usize, SegmentDrawItem)],
9828        retained_draws: &[RetainedDraw],
9829        item_range: Range<usize>,
9830        width: u32,
9831        height: u32,
9832    ) {
9833        let key = self.retained_bundle_key(ordered_items, retained_draws, item_range);
9834        if !self.retained_bundle_cache.hit(&key) {
9835            let bundle = self.build_retained_bundle(&key);
9836            self.retained_bundle_cache.insert(key.clone(), bundle);
9837        }
9838        // Mirror the direct path's per-op stats for every op the bundle
9839        // draws, so bundling is invisible to the frame counters.
9840        for op in &key.ops {
9841            if op.capture_epoch.is_some()
9842                && op.retained_index < MAX_REPLAY_SLOTS
9843                && op.first < op.last
9844            {
9845                self.frame_stats.bump_shapes();
9846                self.frame_stats.add_draw_calls(1);
9847            }
9848        }
9849        // Bundles inherit the pass scissor: set the same full-target rect
9850        // the direct path sets before every retained draw. Executing the
9851        // bundle then resets pipeline/bind/vertex state, which is harmless —
9852        // every following fused arm re-binds its own.
9853        render_pass.set_scissor_rect(0, 0, width, height);
9854        if let Some(bundle) = self.retained_bundle_cache.get(&key) {
9855            render_pass.execute_bundles(std::iter::once(bundle));
9856        }
9857    }
9858
9859    /// Test/diagnostic view of the retained bundle cache: lifetime
9860    /// (rebuilds, cached executes).
9861    #[cfg(not(target_arch = "wasm32"))]
9862    #[doc(hidden)]
9863    pub fn retained_bundle_stats(&self) -> (u64, u64) {
9864        self.retained_bundle_cache.stats()
9865    }
9866
9867    fn draw_prepared_shapes(
9868        &self,
9869        render_pass: &mut wgpu::RenderPass<'_>,
9870        blend_mode: BlendMode,
9871        batch: PreparedShapeBatch,
9872        width: u32,
9873        height: u32,
9874    ) {
9875        self.frame_stats.bump_shapes();
9876        self.frame_stats.add_draw_calls(1);
9877        render_pass.set_scissor_rect(0, 0, width, height);
9878        #[cfg(not(target_arch = "wasm32"))]
9879        let (uniform_bind_group, shape_buffers) = (&self.uniform_bind_group, &self.shape_buffers);
9880        #[cfg(target_arch = "wasm32")]
9881        let (uniform_bind_group, shape_buffers) = (
9882            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
9883            &self.wasm_shape_batches[batch.shape_slot],
9884        );
9885        // Latched instanced path (storage mode only): one instance per
9886        // shape, four vertices through the static quad index buffer —
9887        // identical triangles, identical bind groups, still one draw call.
9888        // The uniform/WebGL path never latches it and stays on `vs_main`.
9889        #[cfg(not(target_arch = "wasm32"))]
9890        if let Some(instanced) = &self.instanced_quads {
9891            assert!(
9892                batch.vertex_start.is_multiple_of(6) && batch.vertex_count.is_multiple_of(6),
9893                "shape batches are whole shapes: vertex range {}..+{} must be \
9894                 six-aligned to convert to an instance range",
9895                batch.vertex_start,
9896                batch.vertex_count,
9897            );
9898            render_pass.set_pipeline(self.instanced_pipeline(instanced, blend_mode));
9899            render_pass.set_bind_group(0, uniform_bind_group, &[]);
9900            // Dynamic offset 0: ordinary batches read the identity
9901            // similarity transform.
9902            render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
9903            let first_shape = batch.vertex_start / 6;
9904            let shape_count = batch.vertex_count / 6;
9905            render_pass
9906                .set_index_buffer(instanced.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
9907            render_pass.draw_indexed(0..6, 0, first_shape..first_shape + shape_count);
9908            return;
9909        }
9910        render_pass.set_pipeline(self.shape_pipeline(blend_mode));
9911        render_pass.set_bind_group(0, uniform_bind_group, &[]);
9912        // Dynamic offset 0: ordinary batches read the identity similarity
9913        // transform.
9914        render_pass.set_bind_group(1, &shape_buffers.bind_group, &[0]);
9915        // Six unindexed vertices per shape; `vs_main` derives the corner from
9916        // `vertex_index` and pulls the quad out of `ShapeData`.
9917        render_pass.draw(
9918            batch.vertex_start..batch.vertex_start + batch.vertex_count,
9919            0..1,
9920        );
9921    }
9922
9923    /// Stage shape buffer writes and record a shape render pass onto the
9924    /// provided encoder. The caller is responsible for submitting.
9925    #[allow(clippy::too_many_arguments)]
9926    fn encode_shapes_pass<'a, I, C: FrameCommandRecorder>(
9927        &mut self,
9928        frame_encoder: &mut C,
9929        target_view: &wgpu::TextureView,
9930        layer_shapes: I,
9931        blend_mode: BlendMode,
9932        width: u32,
9933        height: u32,
9934        root_scale: f32,
9935        load_op: wgpu::LoadOp<wgpu::Color>,
9936        viewport_offset: [f32; 2],
9937    ) where
9938        I: Iterator<Item = &'a DrawShape>,
9939    {
9940        let mut staged_uploads = self.take_staged_uploads();
9941        let viewport = ViewportUniformParams {
9942            width,
9943            height,
9944            offset: viewport_offset,
9945        };
9946        let Some(batch) = self.prepare_shapes_batch(
9947            layer_shapes
9948                .filter(|shape| shape_draw_is_visible_in_viewport(shape, viewport, root_scale)),
9949            root_scale,
9950            viewport,
9951            &mut staged_uploads,
9952        ) else {
9953            self.restore_staged_uploads(staged_uploads);
9954            return;
9955        };
9956        let upload_offset =
9957            frame_encoder.allocate_staged_upload_bytes(staged_uploads.bytes.len() as u64);
9958        self.flush_staged_uploads_at(frame_encoder.encoder(), &staged_uploads, upload_offset);
9959        self.restore_staged_uploads(staged_uploads);
9960        let mut render_pass =
9961            frame_encoder
9962                .encoder()
9963                .begin_render_pass(&wgpu::RenderPassDescriptor {
9964                    label: Some("Shape Pass"),
9965                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
9966                        view: target_view,
9967                        resolve_target: None,
9968                        depth_slice: None,
9969                        ops: wgpu::Operations {
9970                            load: load_op,
9971                            store: wgpu::StoreOp::Store,
9972                        },
9973                    })],
9974                    depth_stencil_attachment: None,
9975                    timestamp_writes: None,
9976                    occlusion_query_set: None,
9977                    multiview_mask: None,
9978                });
9979        self.draw_prepared_shapes(&mut render_pass, blend_mode, batch, width, height);
9980    }
9981
9982    fn draw_prepared_images(
9983        &mut self,
9984        render_pass: &mut wgpu::RenderPass<'_>,
9985        batch: &PreparedImageBatch,
9986        blend_mode: BlendMode,
9987    ) -> Result<(), String> {
9988        if batch.cmds.is_empty() {
9989            return Ok(());
9990        }
9991        self.frame_stats.bump_images();
9992        self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
9993        render_pass.set_pipeline(self.image_pipeline(blend_mode));
9994        #[cfg(not(target_arch = "wasm32"))]
9995        let (uniform_bind_group, vertex_buffer, index_buffer) = (
9996            &self.uniform_bind_group,
9997            &self.image_vertex_buffer,
9998            &self.image_index_buffer,
9999        );
10000        #[cfg(target_arch = "wasm32")]
10001        let (uniform_bind_group, vertex_buffer, index_buffer) = (
10002            &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
10003            &self.wasm_image_batches[batch.image_slot].vertex_buffer,
10004            &self.wasm_image_batches[batch.image_slot].index_buffer,
10005        );
10006        render_pass.set_bind_group(0, uniform_bind_group, &[]);
10007        render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
10008        render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
10009
10010        for cmd in &batch.cmds {
10011            let (sx, sy, sw, sh) = cmd.scissor;
10012            render_pass.set_scissor_rect(sx, sy, sw, sh);
10013
10014            let cached = self
10015                .image_texture_cache
10016                .get(&cmd.image_id)
10017                .ok_or_else(|| "image texture missing from cache".to_string())?;
10018            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
10019            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
10020        }
10021        Ok(())
10022    }
10023
10024    fn draw_prepared_glyphs(
10025        &mut self,
10026        render_pass: &mut wgpu::RenderPass<'_>,
10027        batch: &PreparedGlyphBatch,
10028    ) -> Result<(), String> {
10029        if batch.cmds.is_empty() {
10030            return Ok(());
10031        }
10032        #[cfg(not(target_arch = "wasm32"))]
10033        {
10034            self.draw_native_prepared_glyph_cmd_range(
10035                render_pass,
10036                &batch.cmds,
10037                0..batch.cmds.len(),
10038            )?;
10039        }
10040        #[cfg(target_arch = "wasm32")]
10041        {
10042            self.frame_stats.bump_text();
10043            self.frame_stats.add_draw_calls(batch.cmds.len() as u32);
10044            render_pass.set_pipeline(self.glyph_atlas_pipeline());
10045            let (uniform_bind_group, vertex_buffer, index_buffer) = (
10046                &self.wasm_uniform_batches[batch.uniform_slot].bind_group,
10047                &self.wasm_image_batches[batch.image_slot].vertex_buffer,
10048                &self.wasm_image_batches[batch.image_slot].index_buffer,
10049            );
10050            render_pass.set_bind_group(0, uniform_bind_group, &[]);
10051            render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
10052            render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
10053            render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
10054
10055            for cmd in &batch.cmds {
10056                let (sx, sy, sw, sh) = cmd.scissor;
10057                render_pass.set_scissor_rect(sx, sy, sw, sh);
10058                let GlyphDrawSource::Shared {
10059                    index_start,
10060                    index_count,
10061                } = cmd.source;
10062                render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
10063            }
10064        }
10065        Ok(())
10066    }
10067
10068    #[cfg(not(target_arch = "wasm32"))]
10069    fn draw_native_prepared_image_cmd_range(
10070        &mut self,
10071        render_pass: &mut wgpu::RenderPass<'_>,
10072        cmds: &[ImageDrawCmd],
10073        cmd_range: Range<usize>,
10074        blend_mode: BlendMode,
10075    ) -> Result<(), String> {
10076        let Some(cmds) = cmds.get(cmd_range) else {
10077            return Err("image command range is outside the prepared command buffer".to_string());
10078        };
10079        if cmds.is_empty() {
10080            return Ok(());
10081        }
10082
10083        self.frame_stats.bump_images();
10084        self.frame_stats.add_draw_calls(cmds.len() as u32);
10085        render_pass.set_pipeline(self.image_pipeline(blend_mode));
10086        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
10087        render_pass.set_index_buffer(self.image_index_buffer.slice(..), wgpu::IndexFormat::Uint32);
10088        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
10089
10090        for cmd in cmds {
10091            let (sx, sy, sw, sh) = cmd.scissor;
10092            render_pass.set_scissor_rect(sx, sy, sw, sh);
10093
10094            let cached = self
10095                .image_texture_cache
10096                .get(&cmd.image_id)
10097                .ok_or_else(|| "image texture missing from cache".to_string())?;
10098            render_pass.set_bind_group(1, cached.bind_group(cmd.sampling), &[]);
10099            render_pass.draw_indexed(cmd.index_start..(cmd.index_start + 6), 0, 0..1);
10100        }
10101        Ok(())
10102    }
10103
10104    #[cfg(not(target_arch = "wasm32"))]
10105    fn draw_native_prepared_glyph_cmd_range(
10106        &mut self,
10107        render_pass: &mut wgpu::RenderPass<'_>,
10108        cmds: &[GlyphDrawCmd],
10109        cmd_range: Range<usize>,
10110    ) -> Result<(), String> {
10111        let Some(cmds) = cmds.get(cmd_range) else {
10112            return Err("glyph command range is outside the prepared command buffer".to_string());
10113        };
10114        if cmds.is_empty() {
10115            return Ok(());
10116        }
10117
10118        self.frame_stats.bump_text();
10119        self.frame_stats.add_draw_calls(cmds.len() as u32);
10120
10121        let mut shared_buffers_bound = false;
10122        let mut retained_pipeline_bound = false;
10123        for cmd in cmds {
10124            let (sx, sy, sw, sh) = cmd.scissor;
10125            render_pass.set_scissor_rect(sx, sy, sw, sh);
10126            match cmd.source {
10127                GlyphDrawSource::Shared {
10128                    index_start,
10129                    index_count,
10130                } => {
10131                    if retained_pipeline_bound || !shared_buffers_bound {
10132                        render_pass.set_pipeline(self.glyph_atlas_pipeline());
10133                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
10134                        retained_pipeline_bound = false;
10135                    }
10136                    if !shared_buffers_bound {
10137                        render_pass.set_bind_group(0, &self.uniform_bind_group, &[]);
10138                        render_pass.set_index_buffer(
10139                            self.image_index_buffer.slice(..),
10140                            wgpu::IndexFormat::Uint32,
10141                        );
10142                        render_pass.set_vertex_buffer(0, self.image_vertex_buffer.slice(..));
10143                        shared_buffers_bound = true;
10144                    }
10145                    render_pass.draw_indexed(index_start..(index_start + index_count), 0, 0..1);
10146                }
10147                GlyphDrawSource::Retained {
10148                    cache_key,
10149                    uniform_slot,
10150                } => {
10151                    shared_buffers_bound = false;
10152                    if !retained_pipeline_bound {
10153                        render_pass.set_pipeline(self.retained_glyph_atlas_pipeline());
10154                        render_pass.set_bind_group(1, &self.text_glyph_atlas.bind_group, &[]);
10155                        retained_pipeline_bound = true;
10156                    }
10157                    let cached = self
10158                        .text_glyph_gpu_run_cache
10159                        .peek(&cache_key)
10160                        .ok_or_else(|| "retained glyph buffer missing from cache".to_string())?;
10161                    let dynamic_offset =
10162                        self.retained_glyph_uniform_dynamic_offset(uniform_slot)?;
10163                    render_pass.set_bind_group(
10164                        0,
10165                        &self.retained_glyph_uniform_bind_group,
10166                        &[dynamic_offset],
10167                    );
10168                    render_pass
10169                        .set_index_buffer(cached.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
10170                    render_pass.set_vertex_buffer(0, cached.vertex_buffer.slice(..));
10171                    render_pass.draw_indexed(0..cached.index_count, 0, 0..1);
10172                }
10173            }
10174        }
10175        Ok(())
10176    }
10177
10178    fn append_image_draw_cmd(
10179        &mut self,
10180        image_draw: &ImageDraw,
10181        viewport: ViewportUniformParams,
10182        root_scale: f32,
10183        image_vertices: &mut Vec<Vertex>,
10184        image_indices: &mut Vec<u32>,
10185        image_cmds: &mut Vec<ImageDrawCmd>,
10186    ) -> Result<(), String> {
10187        let snap_delta = image_draw
10188            .snap_anchor
10189            .map(|anchor| snap_delta_for_anchor(anchor, root_scale))
10190            .unwrap_or_default();
10191        let rect = image_draw.rect.translate(snap_delta.x, snap_delta.y);
10192        if rect.width <= 0.0 || rect.height <= 0.0 || image_draw.alpha <= 0.0 {
10193            return Ok(());
10194        }
10195
10196        let (tint, cpu_filter) = tint_for_image(image_draw.color_filter, image_draw.alpha);
10197        if tint[3] <= 0.0 {
10198            return Ok(());
10199        }
10200
10201        let prepared_image = if let Some(filter) = cpu_filter {
10202            apply_filter_to_bitmap(&image_draw.image, filter)?
10203        } else {
10204            image_draw.image.clone()
10205        };
10206        self.ensure_image_cached(&prepared_image)?;
10207
10208        let mut adjusted_image = ImageDraw {
10209            rect,
10210            local_rect: image_draw.local_rect.translate(snap_delta.x, snap_delta.y),
10211            quad: translate_quad(image_draw.quad, snap_delta),
10212            snap_anchor: image_draw.snap_anchor,
10213            image: image_draw.image.clone(),
10214            alpha: image_draw.alpha,
10215            color_filter: image_draw.color_filter,
10216            sampling: image_draw.sampling,
10217            z_index: image_draw.z_index,
10218            clip: image_draw.clip,
10219            blend_mode: image_draw.blend_mode,
10220            src_rect: image_draw.src_rect,
10221            motion_context_animated: image_draw.motion_context_animated,
10222        };
10223        snap_nearest_image_to_device_pixels(&mut adjusted_image, root_scale);
10224        let Some(scissor) =
10225            scissor_rect_for_image(&adjusted_image, root_scale, viewport.width, viewport.height)
10226        else {
10227            return Ok(());
10228        };
10229
10230        let Some(uv_rect) = image_uv_rect(&image_draw.image, image_draw.src_rect) else {
10231            return Ok(());
10232        };
10233        let device_quad =
10234            nearest_image_device_quad(&adjusted_image, root_scale).unwrap_or_else(|| {
10235                if adjusted_image.snap_anchor.is_some() {
10236                    canonicalized_scaled_quad(adjusted_image.quad, root_scale)
10237                } else {
10238                    scaled_quad(adjusted_image.quad, root_scale)
10239                }
10240            });
10241
10242        let base_vertex = image_vertices.len() as u32;
10243        let index_start = image_indices.len() as u32;
10244        image_indices.extend_from_slice(&[
10245            base_vertex,
10246            base_vertex + 1,
10247            base_vertex + 2,
10248            base_vertex + 2,
10249            base_vertex + 1,
10250            base_vertex + 3,
10251        ]);
10252        image_vertices.extend_from_slice(&[
10253            Vertex {
10254                position: device_quad[0],
10255                color: tint,
10256                uv: [uv_rect.min[0], uv_rect.min[1]],
10257                uv_bounds: uv_rect.sample_bounds,
10258            },
10259            Vertex {
10260                position: device_quad[1],
10261                color: tint,
10262                uv: [uv_rect.max[0], uv_rect.min[1]],
10263                uv_bounds: uv_rect.sample_bounds,
10264            },
10265            Vertex {
10266                position: device_quad[2],
10267                color: tint,
10268                uv: [uv_rect.min[0], uv_rect.max[1]],
10269                uv_bounds: uv_rect.sample_bounds,
10270            },
10271            Vertex {
10272                position: device_quad[3],
10273                color: tint,
10274                uv: [uv_rect.max[0], uv_rect.max[1]],
10275                uv_bounds: uv_rect.sample_bounds,
10276            },
10277        ]);
10278
10279        image_cmds.push(ImageDrawCmd {
10280            index_start,
10281            scissor,
10282            image_id: prepared_image.id(),
10283            sampling: image_draw.sampling,
10284        });
10285        Ok(())
10286    }
10287
10288    #[cfg(not(target_arch = "wasm32"))]
10289    fn stage_native_image_buffers(
10290        &mut self,
10291        staged_uploads: &mut StagedBufferUploads,
10292        viewport: ViewportUniformParams,
10293        image_vertices: &[Vertex],
10294        image_indices: &[u32],
10295    ) {
10296        if image_indices.is_empty() {
10297            return;
10298        }
10299
10300        self.stage_viewport_uniforms(staged_uploads, viewport);
10301        // Grow to a power of two, as the shape batch and frame upload buffers
10302        // do. Sizing these to the exact byte count instead means one more glyph
10303        // quad than the last frame destroys and recreates both buffers, and a
10304        // caption that grows a character at a time does it on every frame.
10305        let needed_bytes = std::mem::size_of_val(image_vertices) as u64;
10306        if needed_bytes > self.image_vertex_buffer.size() {
10307            self.image_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10308                label: Some("Image Vertex Buffer"),
10309                size: needed_bytes.next_power_of_two(),
10310                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
10311                mapped_at_creation: false,
10312            });
10313        }
10314        let needed_index_bytes = std::mem::size_of_val(image_indices) as u64;
10315        if needed_index_bytes > self.image_index_buffer.size() {
10316            self.image_index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10317                label: Some("Image Index Buffer"),
10318                size: needed_index_bytes.next_power_of_two(),
10319                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
10320                mapped_at_creation: false,
10321            });
10322        }
10323
10324        staged_uploads.stage(
10325            UploadTarget::ImageVertex,
10326            bytemuck::cast_slice(image_vertices),
10327        );
10328        staged_uploads.stage(
10329            UploadTarget::ImageIndex,
10330            bytemuck::cast_slice(image_indices),
10331        );
10332    }
10333
10334    /// Prepare image vertices, indices, ensure caching, and write to GPU buffers.
10335    /// Returns the draw commands needed by `encode_images_pass`.
10336    fn prepare_image_draw_cmds<'a, I>(
10337        &mut self,
10338        layer_images: I,
10339        viewport: ViewportUniformParams,
10340        root_scale: f32,
10341        staged_uploads: &mut StagedBufferUploads,
10342    ) -> Result<PreparedImageBatch, String>
10343    where
10344        I: Iterator<Item = &'a ImageDraw>,
10345    {
10346        #[cfg(target_arch = "wasm32")]
10347        let _ = staged_uploads;
10348
10349        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
10350        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
10351        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
10352        image_vertices.clear();
10353        image_indices.clear();
10354        image_cmds.clear();
10355
10356        for image_draw in layer_images {
10357            self.append_image_draw_cmd(
10358                image_draw,
10359                viewport,
10360                root_scale,
10361                &mut image_vertices,
10362                &mut image_indices,
10363                &mut image_cmds,
10364            )?;
10365        }
10366
10367        #[cfg(not(target_arch = "wasm32"))]
10368        if !image_cmds.is_empty() {
10369            self.stage_native_image_buffers(
10370                staged_uploads,
10371                viewport,
10372                &image_vertices,
10373                &image_indices,
10374            );
10375        }
10376
10377        #[cfg(target_arch = "wasm32")]
10378        let image_slot = if image_cmds.is_empty() {
10379            0
10380        } else {
10381            let slot = self.claim_wasm_image_batch();
10382            {
10383                let buffers = &mut self.wasm_image_batches[slot];
10384                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
10385            }
10386            let buffers = &self.wasm_image_batches[slot];
10387            self.write_wasm_buffer(
10388                &buffers.vertex_buffer,
10389                bytemuck::cast_slice(&image_vertices),
10390            );
10391            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
10392            slot
10393        };
10394
10395        #[cfg(target_arch = "wasm32")]
10396        let uniform_slot = if image_cmds.is_empty() {
10397            0
10398        } else {
10399            self.prepare_wasm_viewport_uniforms(viewport)
10400        };
10401
10402        self.scratch_image_vertices = image_vertices;
10403        self.scratch_image_indices = image_indices;
10404        Ok(PreparedImageBatch {
10405            cmds: image_cmds,
10406            #[cfg(target_arch = "wasm32")]
10407            image_slot,
10408            #[cfg(target_arch = "wasm32")]
10409            uniform_slot,
10410        })
10411    }
10412
10413    fn glyph_atlas_entry_for(
10414        &mut self,
10415        glyph: &SoftwareGlyphAtlasGlyph,
10416    ) -> Result<GlyphAtlasEntry, String> {
10417        if let Some(entry) = self.text_glyph_atlas.upload_glyph(
10418            glyph.key,
10419            glyph,
10420            &self.queue,
10421            &mut self.frame_graph_executor,
10422            &mut self.frame_stats,
10423        ) {
10424            return Ok(entry);
10425        }
10426
10427        self.text_glyph_atlas.reset(
10428            &self.device,
10429            &self.image_bind_group_layout,
10430            &self.image_nearest_sampler,
10431        );
10432        Err("text glyph atlas filled and was reset".to_string())
10433    }
10434
10435    fn glyph_atlas_entry_for_cached(
10436        &mut self,
10437        glyph: &SoftwareGlyphAtlasPlacement,
10438    ) -> Option<GlyphAtlasEntry> {
10439        let entry = self.text_glyph_atlas.entry(&glyph.key)?;
10440        self.frame_stats.record_text_glyph_atlas_hit();
10441        Some(entry)
10442    }
10443
10444    fn glyph_atlas_entry_for_placement(
10445        &mut self,
10446        glyph: &SoftwareGlyphAtlasPlacement,
10447    ) -> Result<GlyphAtlasEntry, String> {
10448        if let Some(entry) = self.glyph_atlas_entry_for_cached(glyph) {
10449            return Ok(entry);
10450        }
10451
10452        let Some(upload_glyph) = self.text_glyph_mask_cache.atlas_glyph_for_placement(glyph) else {
10453            return Err("text glyph placement has no retained raster mask".to_string());
10454        };
10455        self.glyph_atlas_entry_for(&upload_glyph)
10456    }
10457
10458    fn prepare_text_glyph_quads(
10459        &mut self,
10460        run_key: TextGlyphRunCacheKey,
10461        atlas_generation: u64,
10462        cached_glyph_run: Option<&[SoftwareGlyphAtlasPlacement]>,
10463        collected_run: &[SoftwareGlyphAtlasRunGlyph],
10464        generated_quads: &mut Vec<CachedTextGlyphQuad>,
10465    ) -> Result<Rc<[CachedTextGlyphQuad]>, String> {
10466        generated_quads.clear();
10467        if let Some(glyph_run) = cached_glyph_run {
10468            for glyph in glyph_run {
10469                if glyph.width == 0 || glyph.height == 0 || glyph.color.3 <= 0.0 {
10470                    continue;
10471                }
10472                let entry = self.glyph_atlas_entry_for_placement(glyph)?;
10473                // Read the size after the entry is in hand: the only path that
10474                // resizes the atlas is the overflow reset, which returns `Err`
10475                // above, so `entry` is always normalised against the atlas it
10476                // was placed in.
10477                generated_quads.push(cached_text_glyph_quad(
10478                    glyph,
10479                    entry,
10480                    self.text_glyph_atlas.size(),
10481                ));
10482            }
10483        } else {
10484            for run_glyph in collected_run {
10485                let placement = run_glyph.placement();
10486                if placement.width == 0 || placement.height == 0 || placement.color.3 <= 0.0 {
10487                    continue;
10488                }
10489                let entry = match run_glyph {
10490                    SoftwareGlyphAtlasRunGlyph::Cached(placement) => {
10491                        self.glyph_atlas_entry_for_placement(placement)?
10492                    }
10493                    SoftwareGlyphAtlasRunGlyph::New(glyph) => self.glyph_atlas_entry_for(glyph)?,
10494                };
10495                generated_quads.push(cached_text_glyph_quad(
10496                    &placement,
10497                    entry,
10498                    self.text_glyph_atlas.size(),
10499                ));
10500            }
10501        }
10502
10503        let quads: Rc<[CachedTextGlyphQuad]> = Rc::from(generated_quads.clone().into_boxed_slice());
10504        if let Some(cached) = self.text_glyph_run_cache.get_mut(&run_key) {
10505            cached.quads = Some(Rc::clone(&quads));
10506            cached.atlas_generation = atlas_generation;
10507        }
10508        Ok(quads)
10509    }
10510
10511    #[allow(clippy::too_many_arguments)]
10512    fn append_text_glyph_quad_run(
10513        &mut self,
10514        source_raster_rect: Rect,
10515        quads: &[CachedTextGlyphQuad],
10516        clip: Option<Rect>,
10517        viewport: ViewportUniformParams,
10518        root_scale: f32,
10519        image_vertices: &mut Vec<Vertex>,
10520        image_indices: &mut Vec<u32>,
10521        record_cached_hits: bool,
10522    ) -> usize {
10523        let mut appended = 0usize;
10524        for quad in quads {
10525            if !cached_text_glyph_quad_is_visible_in_viewport(
10526                source_raster_rect,
10527                quad,
10528                clip,
10529                viewport,
10530                root_scale,
10531            ) {
10532                continue;
10533            }
10534            if append_cached_text_glyph_quad(
10535                source_raster_rect,
10536                quad,
10537                image_vertices,
10538                image_indices,
10539            ) {
10540                if record_cached_hits {
10541                    self.frame_stats.record_text_glyph_atlas_hit();
10542                }
10543                appended = appended.saturating_add(1);
10544            }
10545        }
10546        appended
10547    }
10548
10549    #[cfg(not(target_arch = "wasm32"))]
10550    fn retained_glyph_viewport(
10551        viewport: ViewportUniformParams,
10552        source_raster_rect: Rect,
10553    ) -> ViewportUniformParams {
10554        ViewportUniformParams {
10555            width: viewport.width,
10556            height: viewport.height,
10557            offset: [
10558                viewport.offset[0] - source_raster_rect.x,
10559                viewport.offset[1] - source_raster_rect.y,
10560            ],
10561        }
10562    }
10563
10564    #[cfg(not(target_arch = "wasm32"))]
10565    fn retained_text_glyph_run_ready(&mut self, cache_key: TextGlyphRunCacheKey) -> bool {
10566        let atlas_generation = self.text_glyph_atlas.generation();
10567        self.text_glyph_gpu_run_cache
10568            .peek(&cache_key)
10569            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
10570    }
10571
10572    #[cfg(not(target_arch = "wasm32"))]
10573    #[allow(clippy::too_many_arguments)]
10574    fn emit_retained_text_glyph_run_if_ready(
10575        &mut self,
10576        cache_key: TextGlyphRunCacheKey,
10577        quads: &[CachedTextGlyphQuad],
10578        clip: Option<Rect>,
10579        viewport: ViewportUniformParams,
10580        source_raster_rect: Rect,
10581        scissor: (u32, u32, u32, u32),
10582        staged_uploads: &mut StagedBufferUploads,
10583        glyph_cmds: &mut Vec<GlyphDrawCmd>,
10584    ) -> bool {
10585        if !should_use_retained_text_glyph_run(quads.len(), clip) {
10586            return false;
10587        }
10588        if !self.retained_text_glyph_run_ready(cache_key)
10589            && !self.ensure_retained_text_glyph_run(cache_key, quads)
10590        {
10591            return false;
10592        }
10593
10594        let uniform_slot = self.stage_retained_glyph_viewport_uniforms(
10595            staged_uploads,
10596            Self::retained_glyph_viewport(viewport, source_raster_rect),
10597        );
10598        glyph_cmds.push(GlyphDrawCmd::retained(cache_key, uniform_slot, scissor));
10599        true
10600    }
10601
10602    #[cfg(not(target_arch = "wasm32"))]
10603    fn ensure_retained_text_glyph_run(
10604        &mut self,
10605        cache_key: TextGlyphRunCacheKey,
10606        quads: &[CachedTextGlyphQuad],
10607    ) -> bool {
10608        let atlas_generation = self.text_glyph_atlas.generation();
10609        if self
10610            .text_glyph_gpu_run_cache
10611            .peek(&cache_key)
10612            .is_some_and(|cached| cached.atlas_generation == atlas_generation)
10613        {
10614            return true;
10615        }
10616
10617        let mut vertices = Vec::with_capacity(quads.len().saturating_mul(4));
10618        let mut indices = Vec::with_capacity(quads.len().saturating_mul(6));
10619        let origin = Rect {
10620            x: 0.0,
10621            y: 0.0,
10622            width: 0.0,
10623            height: 0.0,
10624        };
10625        for quad in quads {
10626            append_cached_text_glyph_quad(origin, quad, &mut vertices, &mut indices);
10627        }
10628        if indices.is_empty() {
10629            return false;
10630        }
10631
10632        let vertex_bytes = bytemuck::cast_slice(&vertices);
10633        let index_bytes = bytemuck::cast_slice(&indices);
10634        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10635            label: Some("Retained Text Glyph Vertex Buffer"),
10636            size: vertex_bytes.len() as u64,
10637            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
10638            mapped_at_creation: false,
10639        });
10640        let index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
10641            label: Some("Retained Text Glyph Index Buffer"),
10642            size: index_bytes.len() as u64,
10643            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
10644            mapped_at_creation: false,
10645        });
10646        let vertex_upload =
10647            self.frame_graph_executor
10648                .upload_buffer(&self.queue, &vertex_buffer, 0, vertex_bytes);
10649        self.frame_stats.record_command_stats(vertex_upload);
10650        let index_upload =
10651            self.frame_graph_executor
10652                .upload_buffer(&self.queue, &index_buffer, 0, index_bytes);
10653        self.frame_stats.record_command_stats(index_upload);
10654
10655        self.text_glyph_gpu_run_cache.put(
10656            cache_key,
10657            CachedGpuTextGlyphRun {
10658                vertex_buffer,
10659                index_buffer,
10660                index_count: indices.len() as u32,
10661                atlas_generation,
10662            },
10663        );
10664        true
10665    }
10666
10667    #[allow(clippy::too_many_arguments)]
10668    fn append_text_glyph_draws<'a, I>(
10669        &mut self,
10670        layer_texts: I,
10671        viewport: ViewportUniformParams,
10672        root_scale: f32,
10673        allow_offscreen_prewarm: bool,
10674        staged_uploads: &mut StagedBufferUploads,
10675        image_vertices: &mut Vec<Vertex>,
10676        image_indices: &mut Vec<u32>,
10677        glyph_cmds: &mut Vec<GlyphDrawCmd>,
10678    ) -> Result<bool, String>
10679    where
10680        I: IntoIterator<Item = &'a TextDraw>,
10681    {
10682        let append_start = Instant::now();
10683        let initial_vertex_len = image_vertices.len();
10684        let initial_index_len = image_indices.len();
10685        let initial_cmd_len = glyph_cmds.len();
10686        let initial_staged_bytes_len = staged_uploads.bytes.len();
10687        let initial_staged_copies_len = staged_uploads.copies.len();
10688        let mut collected_run = std::mem::take(&mut self.scratch_text_glyph_run);
10689        let mut collected_placements = std::mem::take(&mut self.scratch_text_glyph_placements);
10690        let mut generated_quads = std::mem::take(&mut self.scratch_text_glyph_quads);
10691        generated_quads.clear();
10692        let mut visited = 0usize;
10693        let mut emitted_glyphs = 0usize;
10694        let mut prewarmed_glyphs = 0usize;
10695        let mut run_hits = 0usize;
10696        let mut run_misses = 0usize;
10697
10698        for text_draw in layer_texts {
10699            visited = visited.saturating_add(1);
10700            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
10701                self.text_raster_geometry(text_draw, root_scale)
10702            else {
10703                continue;
10704            };
10705            if !static_text_motion {
10706                image_vertices.truncate(initial_vertex_len);
10707                image_indices.truncate(initial_index_len);
10708                glyph_cmds.truncate(initial_cmd_len);
10709                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
10710                self.scratch_text_glyph_run = collected_run;
10711                self.scratch_text_glyph_placements = collected_placements;
10712                self.scratch_text_glyph_quads = generated_quads;
10713                return Ok(false);
10714            }
10715            let is_visible =
10716                text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale);
10717            let draw_action = text_glyph_draw_action(
10718                is_visible,
10719                text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale),
10720                allow_offscreen_prewarm,
10721            );
10722            if draw_action == TextGlyphDrawAction::Skip {
10723                continue;
10724            }
10725
10726            let raster_source = text_glyph_raster_source(text_draw, raster_rect);
10727            let source_draw = raster_source.draw.as_ref();
10728            let source_raster_rect = raster_source.raster_rect;
10729
10730            let run_key = Self::text_glyph_run_cache_key(
10731                source_draw,
10732                source_raster_rect,
10733                text_scale,
10734                static_text_motion,
10735            );
10736            let atlas_generation = self.text_glyph_atlas.generation();
10737            let mut cached_quad_run = None;
10738            let mut miss_collect_ms = None;
10739            let mut miss_cached_glyphs = 0usize;
10740            let mut miss_new_glyphs = 0usize;
10741            let cached_glyph_run = if let Some(cached) = self.text_glyph_run_cache.get(&run_key) {
10742                run_hits = run_hits.saturating_add(1);
10743                if cached.atlas_generation == atlas_generation {
10744                    cached_quad_run = cached.quads.as_ref().map(Rc::clone);
10745                }
10746                Some(Rc::clone(&cached.glyphs))
10747            } else {
10748                run_misses = run_misses.saturating_add(1);
10749                collected_run.clear();
10750                let collect_start = Instant::now();
10751                let collect_result = collect_solid_text_atlas_run(
10752                    source_draw.text.as_ref(),
10753                    source_raster_rect,
10754                    &source_draw.text_style,
10755                    source_draw.color,
10756                    source_draw.font_size,
10757                    text_scale,
10758                    &self.text_fonts,
10759                    &mut self.text_glyph_mask_cache,
10760                    &mut collected_run,
10761                );
10762                miss_collect_ms = Some(instant_ms(collect_start, Instant::now()));
10763                if collect_result.is_none() {
10764                    if text_atlas_fallback_diag_enabled() {
10765                        let preview: String = source_draw.text.text.chars().take(96).collect();
10766                        log::warn!(
10767                            "[text-atlas-fallback] node={:?} visible={} prewarm={} spans={} links={} text_len={} preview={:?} span_style={:?} paragraph_style={:?}",
10768                            source_draw.node_id,
10769                            is_visible,
10770                            draw_action == TextGlyphDrawAction::PrewarmOffscreen,
10771                            source_draw.text.span_styles.len(),
10772                            source_draw.text.links.len(),
10773                            source_draw.text.text.len(),
10774                            preview,
10775                            source_draw.text_style.span_style,
10776                            source_draw.text_style.paragraph_style,
10777                        );
10778                    }
10779                    if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
10780                        continue;
10781                    }
10782                    image_vertices.truncate(initial_vertex_len);
10783                    image_indices.truncate(initial_index_len);
10784                    glyph_cmds.truncate(initial_cmd_len);
10785                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
10786                    self.scratch_text_glyph_run = collected_run;
10787                    self.scratch_text_glyph_placements = collected_placements;
10788                    self.scratch_text_glyph_quads = generated_quads;
10789                    return Ok(false);
10790                }
10791                if text_glyph_run_diag_enabled() {
10792                    miss_cached_glyphs = collected_run
10793                        .iter()
10794                        .filter(|glyph| matches!(glyph, SoftwareGlyphAtlasRunGlyph::Cached(_)))
10795                        .count();
10796                    miss_new_glyphs = collected_run.len().saturating_sub(miss_cached_glyphs);
10797                }
10798                collected_placements.clear();
10799                collected_placements.extend(
10800                    collected_run
10801                        .iter()
10802                        .map(SoftwareGlyphAtlasRunGlyph::placement),
10803                );
10804                let glyphs: Rc<[SoftwareGlyphAtlasPlacement]> =
10805                    Rc::from(collected_placements.clone().into_boxed_slice());
10806                self.text_glyph_run_cache.put(
10807                    run_key,
10808                    CachedTextGlyphRun {
10809                        glyphs,
10810                        quads: None,
10811                        atlas_generation: 0,
10812                    },
10813                );
10814                None
10815            };
10816
10817            if draw_action == TextGlyphDrawAction::PrewarmOffscreen {
10818                let prewarm_quads = if let Some(quad_run) = cached_quad_run {
10819                    quad_run
10820                } else {
10821                    let prepare_start = Instant::now();
10822                    match self.prepare_text_glyph_quads(
10823                        run_key,
10824                        atlas_generation,
10825                        cached_glyph_run.as_deref(),
10826                        &collected_run,
10827                        &mut generated_quads,
10828                    ) {
10829                        Ok(quads) => {
10830                            if let Some(collect_ms) = miss_collect_ms {
10831                                if text_glyph_run_diag_enabled() {
10832                                    log::warn!(
10833                                        "[text-glyph-run-diag] visible=false glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
10834                                        quads.len(),
10835                                        miss_cached_glyphs,
10836                                        miss_new_glyphs,
10837                                        collect_ms,
10838                                        instant_ms(prepare_start, Instant::now()),
10839                                    );
10840                                }
10841                            }
10842                            quads
10843                        }
10844                        Err(_) => continue,
10845                    }
10846                };
10847                #[cfg(not(target_arch = "wasm32"))]
10848                if should_use_retained_text_glyph_run(prewarm_quads.len(), source_draw.clip) {
10849                    self.ensure_retained_text_glyph_run(run_key, prewarm_quads.as_ref());
10850                }
10851                prewarmed_glyphs = prewarmed_glyphs.saturating_add(prewarm_quads.len());
10852                continue;
10853            }
10854
10855            let draw_rect = Rect {
10856                x: source_raster_rect.x / root_scale,
10857                y: source_raster_rect.y / root_scale,
10858                width: source_raster_rect.width / root_scale,
10859                height: source_raster_rect.height / root_scale,
10860            };
10861            let Some(scissor) = scissor_rect_for_layer(
10862                draw_rect,
10863                source_draw.clip,
10864                root_scale,
10865                viewport.width,
10866                viewport.height,
10867            ) else {
10868                continue;
10869            };
10870
10871            #[cfg(not(target_arch = "wasm32"))]
10872            if let Some(quad_run) = cached_quad_run.as_ref() {
10873                if should_use_retained_text_glyph_run(quad_run.len(), source_draw.clip)
10874                    && self.emit_retained_text_glyph_run_if_ready(
10875                        run_key,
10876                        quad_run.as_ref(),
10877                        source_draw.clip,
10878                        viewport,
10879                        source_raster_rect,
10880                        scissor,
10881                        staged_uploads,
10882                        glyph_cmds,
10883                    )
10884                {
10885                    emitted_glyphs = emitted_glyphs.saturating_add(quad_run.len());
10886                    continue;
10887                }
10888            }
10889
10890            let index_start = image_indices.len() as u32;
10891            if let Some(quad_run) = cached_quad_run {
10892                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
10893                    source_raster_rect,
10894                    quad_run.as_ref(),
10895                    source_draw.clip,
10896                    viewport,
10897                    root_scale,
10898                    image_vertices,
10899                    image_indices,
10900                    true,
10901                ));
10902            } else {
10903                let prepare_start = Instant::now();
10904                let Ok(quad_run) = self.prepare_text_glyph_quads(
10905                    run_key,
10906                    atlas_generation,
10907                    cached_glyph_run.as_deref(),
10908                    &collected_run,
10909                    &mut generated_quads,
10910                ) else {
10911                    image_vertices.truncate(initial_vertex_len);
10912                    image_indices.truncate(initial_index_len);
10913                    glyph_cmds.truncate(initial_cmd_len);
10914                    staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
10915                    self.scratch_text_glyph_run = collected_run;
10916                    self.scratch_text_glyph_placements = collected_placements;
10917                    self.scratch_text_glyph_quads = generated_quads;
10918                    return Ok(false);
10919                };
10920                if let Some(collect_ms) = miss_collect_ms {
10921                    if text_glyph_run_diag_enabled() {
10922                        log::warn!(
10923                            "[text-glyph-run-diag] visible=true glyphs={} cached={} new={} collect_ms={:.2} prepare_ms={:.2}",
10924                            quad_run.len(),
10925                            miss_cached_glyphs,
10926                            miss_new_glyphs,
10927                            collect_ms,
10928                            instant_ms(prepare_start, Instant::now()),
10929                        );
10930                    }
10931                }
10932                emitted_glyphs = emitted_glyphs.saturating_add(self.append_text_glyph_quad_run(
10933                    source_raster_rect,
10934                    quad_run.as_ref(),
10935                    source_draw.clip,
10936                    viewport,
10937                    root_scale,
10938                    image_vertices,
10939                    image_indices,
10940                    false,
10941                ));
10942            }
10943            let index_count = image_indices.len() as u32 - index_start;
10944            if index_count > 0 {
10945                glyph_cmds.push(GlyphDrawCmd::shared(index_start, index_count, scissor));
10946            }
10947        }
10948
10949        self.scratch_text_glyph_run = collected_run;
10950        self.scratch_text_glyph_placements = collected_placements;
10951        self.scratch_text_glyph_quads = generated_quads;
10952        let append_end = Instant::now();
10953        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
10954            log::warn!(
10955                "[wgpu-render-stage:text-glyph-atlas] total_ms={total_ms:.2} visited={} cmds={} glyphs={} prewarmed={} run_hits={} run_misses={}",
10956                visited,
10957                glyph_cmds.len().saturating_sub(initial_cmd_len),
10958                emitted_glyphs,
10959                prewarmed_glyphs,
10960                run_hits,
10961                run_misses,
10962            );
10963        }
10964        Ok(true)
10965    }
10966
10967    #[cfg(not(target_arch = "wasm32"))]
10968    fn text_glyph_prewarm_decision(
10969        &self,
10970        text_draw: &TextDraw,
10971        viewport: ViewportUniformParams,
10972        root_scale: f32,
10973    ) -> TextGlyphPrewarmDecision {
10974        let Some((logical_rect, _, clip, _, static_text_motion)) =
10975            self.text_raster_geometry(text_draw, root_scale)
10976        else {
10977            return TextGlyphPrewarmDecision::MissingGeometry;
10978        };
10979        if !static_text_motion {
10980            return TextGlyphPrewarmDecision::DynamicMotion;
10981        }
10982        if text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
10983            return TextGlyphPrewarmDecision::Visible;
10984        }
10985        if text_draw_should_prewarm_in_viewport(logical_rect, clip, viewport, root_scale) {
10986            TextGlyphPrewarmDecision::Candidate
10987        } else {
10988            TextGlyphPrewarmDecision::OutsidePrewarmWindow
10989        }
10990    }
10991
10992    #[cfg(not(target_arch = "wasm32"))]
10993    #[allow(clippy::too_many_arguments)]
10994    fn prewarm_offscreen_text_glyph_draws_in_chunk(
10995        &mut self,
10996        ordered_items: &[(usize, SegmentDrawItem)],
10997        texts: &[TextDraw],
10998        chunk: &SegmentDrawChunkPlan,
10999        viewport: ViewportUniformParams,
11000        root_scale: f32,
11001        staged_uploads: &mut StagedBufferUploads,
11002        image_vertices: &mut Vec<Vertex>,
11003        image_indices: &mut Vec<u32>,
11004        glyph_cmds: &mut Vec<GlyphDrawCmd>,
11005    ) -> Result<(), String> {
11006        let prewarm_start = Instant::now();
11007        let diag_enabled = cranpose_core::env_flag!("CRANPOSE_TEXT_PREWARM_DIAG");
11008        let mut text_items = 0usize;
11009        let mut candidates = 0usize;
11010        let mut missing_geometry = 0usize;
11011        let mut dynamic_motion = 0usize;
11012        let mut visible = 0usize;
11013        let mut outside = 0usize;
11014        let mut already_prepared = 0usize;
11015        let mut admitted_candidates = 0usize;
11016        let mut skipped_unbounded = 0usize;
11017        let mut skipped_budget = 0usize;
11018        let initial_vertex_len = image_vertices.len();
11019        let initial_index_len = image_indices.len();
11020        let initial_cmd_len = glyph_cmds.len();
11021        let initial_staged_bytes_len = staged_uploads.bytes.len();
11022        let initial_staged_copies_len = staged_uploads.copies.len();
11023        'batches: for batch in chunk.iter() {
11024            let SegmentBatchPlan::Text { start, end } = batch else {
11025                continue;
11026            };
11027            for (_, item) in &ordered_items[start..end] {
11028                if offscreen_text_glyph_prewarm_budget_exhausted(prewarm_start, admitted_candidates)
11029                {
11030                    skipped_budget = skipped_budget.saturating_add(1);
11031                    break 'batches;
11032                }
11033                let SegmentDrawItem::Text(text_index) = item else {
11034                    return Err(format!(
11035                        "text prewarm batch contains non-text draw item: {item:?}"
11036                    ));
11037                };
11038                let Some(text_draw) = texts.get(*text_index) else {
11039                    continue;
11040                };
11041                text_items = text_items.saturating_add(1);
11042                match self.text_glyph_prewarm_decision(text_draw, viewport, root_scale) {
11043                    TextGlyphPrewarmDecision::Candidate => {}
11044                    TextGlyphPrewarmDecision::MissingGeometry => {
11045                        missing_geometry = missing_geometry.saturating_add(1);
11046                        continue;
11047                    }
11048                    TextGlyphPrewarmDecision::DynamicMotion => {
11049                        dynamic_motion = dynamic_motion.saturating_add(1);
11050                        continue;
11051                    }
11052                    TextGlyphPrewarmDecision::Visible => {
11053                        visible = visible.saturating_add(1);
11054                        continue;
11055                    }
11056                    TextGlyphPrewarmDecision::OutsidePrewarmWindow => {
11057                        outside = outside.saturating_add(1);
11058                        continue;
11059                    }
11060                }
11061
11062                candidates = candidates.saturating_add(1);
11063                let Some((_, raster_rect, _, text_scale, static_text_motion)) =
11064                    self.text_raster_geometry(text_draw, root_scale)
11065                else {
11066                    missing_geometry = missing_geometry.saturating_add(1);
11067                    continue;
11068                };
11069                let raster_source = text_glyph_raster_source(text_draw, raster_rect);
11070                let source_draw = raster_source.draw.as_ref();
11071                let run_key = Self::text_glyph_run_cache_key(
11072                    source_draw,
11073                    raster_source.raster_rect,
11074                    text_scale,
11075                    static_text_motion,
11076                );
11077                let atlas_generation = self.text_glyph_atlas.generation();
11078                let cached_glyphs = if let Some(cached) = self.text_glyph_run_cache.peek(&run_key) {
11079                    if cached.atlas_generation == atlas_generation && cached.quads.is_some() {
11080                        already_prepared = already_prepared.saturating_add(1);
11081                        continue;
11082                    }
11083                    Some(cached.glyphs.len())
11084                } else {
11085                    None
11086                };
11087                if !offscreen_text_glyph_prewarm_work_is_bounded(
11088                    cached_glyphs,
11089                    source_draw.text.text.len(),
11090                ) {
11091                    skipped_unbounded = skipped_unbounded.saturating_add(1);
11092                    continue;
11093                }
11094                admitted_candidates = admitted_candidates.saturating_add(1);
11095                self.append_text_glyph_draws(
11096                    std::iter::once(text_draw),
11097                    viewport,
11098                    root_scale,
11099                    true,
11100                    staged_uploads,
11101                    image_vertices,
11102                    image_indices,
11103                    glyph_cmds,
11104                )?;
11105                image_vertices.truncate(initial_vertex_len);
11106                image_indices.truncate(initial_index_len);
11107                glyph_cmds.truncate(initial_cmd_len);
11108                staged_uploads.truncate(initial_staged_bytes_len, initial_staged_copies_len);
11109            }
11110        }
11111
11112        if diag_enabled && text_items > 0 {
11113            log::warn!(
11114                "[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}"
11115            );
11116        }
11117        if admitted_candidates > 0 {
11118            if let Some(total_ms) = should_log_wgpu_render_stage(prewarm_start, Instant::now()) {
11119                log::warn!(
11120                    "[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}"
11121                );
11122            }
11123        }
11124        Ok(())
11125    }
11126
11127    fn prepare_text_glyph_draw_cmds<'a, I>(
11128        &mut self,
11129        layer_texts: I,
11130        viewport: ViewportUniformParams,
11131        root_scale: f32,
11132        staged_uploads: &mut StagedBufferUploads,
11133    ) -> Result<Option<PreparedGlyphBatch>, String>
11134    where
11135        I: IntoIterator<Item = &'a TextDraw>,
11136    {
11137        #[cfg(target_arch = "wasm32")]
11138        let _ = staged_uploads;
11139
11140        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
11141        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
11142        let mut glyph_cmds = std::mem::take(&mut self.scratch_glyph_cmds);
11143        image_vertices.clear();
11144        image_indices.clear();
11145        glyph_cmds.clear();
11146
11147        if !self.append_text_glyph_draws(
11148            layer_texts,
11149            viewport,
11150            root_scale,
11151            false,
11152            staged_uploads,
11153            &mut image_vertices,
11154            &mut image_indices,
11155            &mut glyph_cmds,
11156        )? {
11157            self.scratch_image_vertices = image_vertices;
11158            self.scratch_image_indices = image_indices;
11159            self.scratch_glyph_cmds = glyph_cmds;
11160            return Ok(None);
11161        }
11162
11163        #[cfg(not(target_arch = "wasm32"))]
11164        if !image_indices.is_empty() {
11165            self.stage_native_image_buffers(
11166                staged_uploads,
11167                viewport,
11168                &image_vertices,
11169                &image_indices,
11170            );
11171        }
11172
11173        #[cfg(target_arch = "wasm32")]
11174        let image_slot = if glyph_cmds.is_empty() {
11175            0
11176        } else {
11177            let slot = self.claim_wasm_image_batch();
11178            {
11179                let buffers = &mut self.wasm_image_batches[slot];
11180                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
11181            }
11182            let buffers = &self.wasm_image_batches[slot];
11183            self.write_wasm_buffer(
11184                &buffers.vertex_buffer,
11185                bytemuck::cast_slice(&image_vertices),
11186            );
11187            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
11188            slot
11189        };
11190
11191        #[cfg(target_arch = "wasm32")]
11192        let uniform_slot = if glyph_cmds.is_empty() {
11193            0
11194        } else {
11195            self.prepare_wasm_viewport_uniforms(viewport)
11196        };
11197
11198        self.scratch_image_vertices = image_vertices;
11199        self.scratch_image_indices = image_indices;
11200        Ok(Some(PreparedGlyphBatch {
11201            cmds: glyph_cmds,
11202            #[cfg(target_arch = "wasm32")]
11203            image_slot,
11204            #[cfg(target_arch = "wasm32")]
11205            uniform_slot,
11206        }))
11207    }
11208
11209    #[allow(clippy::too_many_arguments)]
11210    fn append_image_bitmap_draw_cmd(
11211        &mut self,
11212        image: &ImageBitmap,
11213        rect: Rect,
11214        clip: Option<Rect>,
11215        sampling: ImageSampling,
11216        viewport: ViewportUniformParams,
11217        root_scale: f32,
11218        image_vertices: &mut Vec<Vertex>,
11219        image_indices: &mut Vec<u32>,
11220        image_cmds: &mut Vec<ImageDrawCmd>,
11221    ) -> Result<(), String> {
11222        if rect.width <= 0.0 || rect.height <= 0.0 {
11223            return Ok(());
11224        }
11225
11226        self.ensure_image_cached(image)?;
11227
11228        let (device_quad, scissor_rect) =
11229            if sampling == ImageSampling::Nearest && root_scale.is_finite() && root_scale > 0.0 {
11230                let left_px = (rect.x * root_scale).round();
11231                let top_px = (rect.y * root_scale).round();
11232                let width_px = (rect.width * root_scale).round().max(1.0);
11233                let height_px = (rect.height * root_scale).round().max(1.0);
11234                let snapped_rect = Rect {
11235                    x: left_px / root_scale,
11236                    y: top_px / root_scale,
11237                    width: width_px / root_scale,
11238                    height: height_px / root_scale,
11239                };
11240                let right_px = left_px + width_px;
11241                let bottom_px = top_px + height_px;
11242                (
11243                    [
11244                        [left_px, top_px],
11245                        [right_px, top_px],
11246                        [left_px, bottom_px],
11247                        [right_px, bottom_px],
11248                    ],
11249                    snapped_rect,
11250                )
11251            } else {
11252                (
11253                    rect_to_quad(rect).map(|[x, y]| [x * root_scale, y * root_scale]),
11254                    rect,
11255                )
11256            };
11257
11258        let Some(scissor) = scissor_rect_for_layer(
11259            scissor_rect,
11260            clip,
11261            root_scale,
11262            viewport.width,
11263            viewport.height,
11264        ) else {
11265            return Ok(());
11266        };
11267        let Some(uv_rect) = image_uv_rect(image, None) else {
11268            return Ok(());
11269        };
11270
11271        let base_vertex = image_vertices.len() as u32;
11272        let index_start = image_indices.len() as u32;
11273        image_indices.extend_from_slice(&[
11274            base_vertex,
11275            base_vertex + 1,
11276            base_vertex + 2,
11277            base_vertex + 2,
11278            base_vertex + 1,
11279            base_vertex + 3,
11280        ]);
11281        let color = [1.0, 1.0, 1.0, 1.0];
11282        image_vertices.extend_from_slice(&[
11283            Vertex {
11284                position: device_quad[0],
11285                color,
11286                uv: [uv_rect.min[0], uv_rect.min[1]],
11287                uv_bounds: uv_rect.sample_bounds,
11288            },
11289            Vertex {
11290                position: device_quad[1],
11291                color,
11292                uv: [uv_rect.max[0], uv_rect.min[1]],
11293                uv_bounds: uv_rect.sample_bounds,
11294            },
11295            Vertex {
11296                position: device_quad[2],
11297                color,
11298                uv: [uv_rect.min[0], uv_rect.max[1]],
11299                uv_bounds: uv_rect.sample_bounds,
11300            },
11301            Vertex {
11302                position: device_quad[3],
11303                color,
11304                uv: [uv_rect.max[0], uv_rect.max[1]],
11305                uv_bounds: uv_rect.sample_bounds,
11306            },
11307        ]);
11308        image_cmds.push(ImageDrawCmd {
11309            index_start,
11310            scissor,
11311            image_id: image.id(),
11312            sampling,
11313        });
11314        Ok(())
11315    }
11316
11317    #[allow(clippy::too_many_arguments)]
11318    fn append_text_image_draw_cmds<'a, I>(
11319        &mut self,
11320        layer_texts: I,
11321        viewport: ViewportUniformParams,
11322        root_scale: f32,
11323        image_vertices: &mut Vec<Vertex>,
11324        image_indices: &mut Vec<u32>,
11325        image_cmds: &mut Vec<ImageDrawCmd>,
11326    ) -> Result<(), String>
11327    where
11328        I: Iterator<Item = &'a TextDraw>,
11329    {
11330        let append_start = Instant::now();
11331        let initial_len = image_cmds.len();
11332        let mut visited = 0usize;
11333        let mut hit_count = 0usize;
11334        let mut miss_count = 0usize;
11335        for text_draw in layer_texts {
11336            visited = visited.saturating_add(1);
11337            let _ = text_draw.node_id;
11338            let Some((logical_rect, raster_rect, clip, text_scale, static_text_motion)) =
11339                self.text_raster_geometry(text_draw, root_scale)
11340            else {
11341                continue;
11342            };
11343            if !text_draw_is_visible_in_viewport(logical_rect, clip, viewport, root_scale) {
11344                continue;
11345            }
11346
11347            let raster_source = self.text_image_raster_source(
11348                text_draw,
11349                logical_rect,
11350                raster_rect,
11351                clip,
11352                root_scale,
11353                static_text_motion,
11354            );
11355            let source_draw = raster_source.draw.as_ref();
11356            let source_raster_rect = raster_source.raster_rect;
11357
11358            let cache_key = Self::text_image_cache_key(
11359                source_draw,
11360                source_raster_rect,
11361                text_scale,
11362                static_text_motion,
11363            );
11364            let image = if let Some(cached) = self.text_image_cache.get(&cache_key) {
11365                self.frame_stats
11366                    .record_text_image_cache_hit(cached.image.width(), cached.image.height());
11367                hit_count = hit_count.saturating_add(1);
11368                cached.image.clone()
11369            } else {
11370                let Some(image) =
11371                    self.rasterize_text_draw_to_image(source_draw, source_raster_rect, text_scale)
11372                else {
11373                    continue;
11374                };
11375                self.frame_stats
11376                    .record_text_image_cache_miss(image.width(), image.height());
11377                miss_count = miss_count.saturating_add(1);
11378                self.text_image_cache.put(
11379                    cache_key,
11380                    CachedTextImage {
11381                        image: image.clone(),
11382                    },
11383                );
11384                image
11385            };
11386
11387            let draw_origin = if static_text_motion {
11388                Point::new(
11389                    source_raster_rect.x / root_scale,
11390                    source_raster_rect.y / root_scale,
11391                )
11392            } else {
11393                Point::new(logical_rect.x, logical_rect.y)
11394            };
11395            let draw_rect = Rect {
11396                x: draw_origin.x,
11397                y: draw_origin.y,
11398                width: image.width() as f32 / root_scale,
11399                height: image.height() as f32 / root_scale,
11400            };
11401            self.append_image_bitmap_draw_cmd(
11402                &image,
11403                draw_rect,
11404                clip,
11405                ImageSampling::Nearest,
11406                viewport,
11407                root_scale,
11408                image_vertices,
11409                image_indices,
11410                image_cmds,
11411            )?;
11412        }
11413        let append_end = Instant::now();
11414        if let Some(total_ms) = should_log_wgpu_render_stage(append_start, append_end) {
11415            log::warn!(
11416                "[wgpu-render-stage:text-images] total_ms={total_ms:.2} visited={} emitted={} hits={} misses={}",
11417                visited,
11418                image_cmds.len().saturating_sub(initial_len),
11419                hit_count,
11420                miss_count,
11421            );
11422        }
11423        Ok(())
11424    }
11425
11426    fn text_image_raster_source<'a>(
11427        &mut self,
11428        text_draw: &'a TextDraw,
11429        logical_rect: Rect,
11430        raster_rect: Rect,
11431        clip: Option<Rect>,
11432        root_scale: f32,
11433        static_text_motion: bool,
11434    ) -> TextRasterSource<'a> {
11435        let Some(clip) = clip else {
11436            return TextRasterSource {
11437                draw: Cow::Borrowed(text_draw),
11438                raster_rect,
11439            };
11440        };
11441        if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
11442            return TextRasterSource {
11443                draw: Cow::Borrowed(text_draw),
11444                raster_rect,
11445            };
11446        }
11447
11448        let line_starts = self.text_line_index_cache.line_starts(&text_draw.text);
11449        clipped_text_raster_source_with_line_starts(
11450            text_draw,
11451            logical_rect,
11452            raster_rect,
11453            clip,
11454            root_scale,
11455            line_starts.as_ref(),
11456        )
11457    }
11458
11459    fn prepare_text_image_draw_cmds<'a, I>(
11460        &mut self,
11461        layer_texts: I,
11462        viewport: ViewportUniformParams,
11463        root_scale: f32,
11464        staged_uploads: &mut StagedBufferUploads,
11465    ) -> Result<PreparedImageBatch, String>
11466    where
11467        I: Iterator<Item = &'a TextDraw>,
11468    {
11469        #[cfg(target_arch = "wasm32")]
11470        let _ = staged_uploads;
11471
11472        let mut image_vertices = std::mem::take(&mut self.scratch_image_vertices);
11473        let mut image_indices = std::mem::take(&mut self.scratch_image_indices);
11474        let mut image_cmds = std::mem::take(&mut self.scratch_image_cmds);
11475        image_vertices.clear();
11476        image_indices.clear();
11477        image_cmds.clear();
11478
11479        self.append_text_image_draw_cmds(
11480            layer_texts,
11481            viewport,
11482            root_scale,
11483            &mut image_vertices,
11484            &mut image_indices,
11485            &mut image_cmds,
11486        )?;
11487
11488        #[cfg(not(target_arch = "wasm32"))]
11489        if !image_cmds.is_empty() {
11490            self.stage_native_image_buffers(
11491                staged_uploads,
11492                viewport,
11493                &image_vertices,
11494                &image_indices,
11495            );
11496        }
11497
11498        #[cfg(target_arch = "wasm32")]
11499        let image_slot = if image_cmds.is_empty() {
11500            0
11501        } else {
11502            let slot = self.claim_wasm_image_batch();
11503            {
11504                let buffers = &mut self.wasm_image_batches[slot];
11505                buffers.ensure_capacity(&self.device, image_vertices.len(), image_indices.len());
11506            }
11507            let buffers = &self.wasm_image_batches[slot];
11508            self.write_wasm_buffer(
11509                &buffers.vertex_buffer,
11510                bytemuck::cast_slice(&image_vertices),
11511            );
11512            self.write_wasm_buffer(&buffers.index_buffer, bytemuck::cast_slice(&image_indices));
11513            slot
11514        };
11515
11516        #[cfg(target_arch = "wasm32")]
11517        let uniform_slot = if image_cmds.is_empty() {
11518            0
11519        } else {
11520            self.prepare_wasm_viewport_uniforms(viewport)
11521        };
11522
11523        self.scratch_image_vertices = image_vertices;
11524        self.scratch_image_indices = image_indices;
11525        Ok(PreparedImageBatch {
11526            cmds: image_cmds,
11527            #[cfg(target_arch = "wasm32")]
11528            image_slot,
11529            #[cfg(target_arch = "wasm32")]
11530            uniform_slot,
11531        })
11532    }
11533
11534    fn text_raster_geometry(
11535        &self,
11536        text_draw: &TextDraw,
11537        root_scale: f32,
11538    ) -> Option<(Rect, Rect, Option<Rect>, f32, bool)> {
11539        text_raster_geometry_for_draw(text_draw, root_scale)
11540    }
11541
11542    fn text_image_cache_key(
11543        text_draw: &TextDraw,
11544        raster_rect: Rect,
11545        text_scale: f32,
11546        static_text_motion: bool,
11547    ) -> TextImageCacheKey {
11548        let mut state = default_hash::new();
11549        text_draw.text.render_hash().hash(&mut state);
11550        text_draw.text_style.render_hash().hash(&mut state);
11551        text_draw.color.render_hash().hash(&mut state);
11552        hash_text_raster_geometry_for_cache(raster_rect, static_text_motion, &mut state);
11553        text_draw.font_size.to_bits().hash(&mut state);
11554        text_scale.to_bits().hash(&mut state);
11555        text_draw.layout_options.hash(&mut state);
11556        TextImageCacheKey(state.finish())
11557    }
11558
11559    fn text_glyph_run_cache_key(
11560        text_draw: &TextDraw,
11561        raster_rect: Rect,
11562        text_scale: f32,
11563        static_text_motion: bool,
11564    ) -> TextGlyphRunCacheKey {
11565        TextGlyphRunCacheKey(
11566            Self::text_image_cache_key(text_draw, raster_rect, text_scale, static_text_motion).0,
11567        )
11568    }
11569
11570    fn rasterize_text_draw_to_image(
11571        &mut self,
11572        text_draw: &TextDraw,
11573        raster_rect: Rect,
11574        text_scale: f32,
11575    ) -> Option<ImageBitmap> {
11576        if text_draw.text.span_styles.is_empty() {
11577            let font = self.text_fonts.resolve(&text_draw.text_style)?;
11578            return rasterize_text_to_image_with_glyph_cache(
11579                text_draw.text.text.as_str(),
11580                raster_rect,
11581                &text_draw.text_style,
11582                text_draw.color,
11583                text_draw.font_size,
11584                text_scale,
11585                font,
11586                &mut self.text_glyph_mask_cache,
11587            );
11588        }
11589
11590        if let Some(image) = rasterize_annotated_text_to_image_with_glyph_cache(
11591            text_draw.text.as_ref(),
11592            raster_rect,
11593            &text_draw.text_style,
11594            text_draw.color,
11595            text_draw.font_size,
11596            text_scale,
11597            &self.text_fonts,
11598            &mut self.text_glyph_mask_cache,
11599        ) {
11600            return Some(image);
11601        }
11602
11603        rasterize_spanned_text_to_image(
11604            text_draw,
11605            raster_rect,
11606            text_scale,
11607            &self.text_fonts,
11608            &mut self.text_glyph_mask_cache,
11609        )
11610    }
11611}
11612
11613fn rasterize_spanned_text_to_image(
11614    text_draw: &TextDraw,
11615    raster_rect: Rect,
11616    text_scale: f32,
11617    fonts: &SoftwareTextFontSet,
11618    glyph_cache: &mut SoftwareGlyphRasterCache,
11619) -> Option<ImageBitmap> {
11620    let width = raster_rect.width.ceil().max(1.0) as u32;
11621    let height = raster_rect.height.ceil().max(1.0) as u32;
11622    let mut canvas = vec![0_u8; (width as usize) * (height as usize) * 4];
11623    let boundaries = text_draw.text.span_boundaries();
11624    let base_line_height = text_draw
11625        .text_style
11626        .resolve_line_height(14.0, text_draw.font_size)
11627        .max(1.0);
11628    let mut current_line_height = base_line_height;
11629    let mut cursor_x = raster_rect.x;
11630    let mut cursor_y = raster_rect.y;
11631
11632    for window in boundaries.windows(2) {
11633        let start = window[0];
11634        let end = window[1];
11635        if start == end {
11636            continue;
11637        }
11638
11639        let chunk = &text_draw.text.text[start..end];
11640        let mut merged_span = text_draw.text_style.span_style.clone();
11641        for span in &text_draw.text.span_styles {
11642            if span.range.start <= start && span.range.end >= end {
11643                merged_span = merged_span.merge(&span.item);
11644            }
11645        }
11646
11647        let mut chunk_style = text_draw.text_style.clone();
11648        chunk_style.span_style = merged_span;
11649
11650        for part in chunk.split_inclusive('\n') {
11651            let has_newline = part.ends_with('\n');
11652            let content = if has_newline {
11653                &part[..part.len().saturating_sub(1)]
11654            } else {
11655                part
11656            };
11657
11658            if !content.is_empty() {
11659                let chunk_font_size = chunk_style.resolve_font_size(text_draw.font_size);
11660                let Some(font) = fonts.resolve(&chunk_style) else {
11661                    continue;
11662                };
11663                let metrics = measure_text_with_font(content, &chunk_style, chunk_font_size, font);
11664                let segment_rect = Rect {
11665                    x: cursor_x,
11666                    y: cursor_y,
11667                    width: (metrics.width * text_scale).ceil().max(1.0),
11668                    height: (metrics.height * text_scale).ceil().max(1.0),
11669                };
11670                if let Some(segment_image) = rasterize_text_to_image_with_glyph_cache(
11671                    content,
11672                    segment_rect,
11673                    &chunk_style,
11674                    chunk_style.resolve_text_color(text_draw.color),
11675                    chunk_font_size,
11676                    text_scale,
11677                    font,
11678                    glyph_cache,
11679                ) {
11680                    composite_text_segment(
11681                        &mut canvas,
11682                        width,
11683                        height,
11684                        raster_rect,
11685                        segment_rect,
11686                        &segment_image,
11687                    );
11688                }
11689                cursor_x += metrics.width * text_scale;
11690                current_line_height = current_line_height.max(metrics.line_height.max(1.0));
11691            }
11692
11693            if has_newline {
11694                cursor_x = raster_rect.x;
11695                cursor_y += current_line_height * text_scale;
11696                current_line_height = base_line_height;
11697            }
11698        }
11699    }
11700
11701    ImageBitmap::from_rgba8(width, height, canvas).ok()
11702}
11703
11704struct TextRasterSource<'a> {
11705    draw: Cow<'a, TextDraw>,
11706    raster_rect: Rect,
11707}
11708
11709fn text_glyph_raster_source(text_draw: &TextDraw, raster_rect: Rect) -> TextRasterSource<'_> {
11710    TextRasterSource {
11711        draw: Cow::Borrowed(text_draw),
11712        raster_rect,
11713    }
11714}
11715
11716#[cfg(test)]
11717fn clipped_text_raster_source<'a>(
11718    text_draw: &'a TextDraw,
11719    logical_rect: Rect,
11720    raster_rect: Rect,
11721    clip: Option<Rect>,
11722    root_scale: f32,
11723    static_text_motion: bool,
11724) -> TextRasterSource<'a> {
11725    let Some(clip) = clip else {
11726        return TextRasterSource {
11727            draw: Cow::Borrowed(text_draw),
11728            raster_rect,
11729        };
11730    };
11731    if !static_text_motion || text_draw.text.text.as_str().find('\n').is_none() {
11732        return TextRasterSource {
11733            draw: Cow::Borrowed(text_draw),
11734            raster_rect,
11735        };
11736    }
11737    let line_starts = line_start_offsets(text_draw.text.text.as_str());
11738    clipped_text_raster_source_with_line_starts(
11739        text_draw,
11740        logical_rect,
11741        raster_rect,
11742        clip,
11743        root_scale,
11744        &line_starts,
11745    )
11746}
11747
11748fn clipped_text_raster_source_with_line_starts<'a>(
11749    text_draw: &'a TextDraw,
11750    logical_rect: Rect,
11751    raster_rect: Rect,
11752    clip: Rect,
11753    root_scale: f32,
11754    line_starts: &[usize],
11755) -> TextRasterSource<'a> {
11756    if line_starts.len() < MIN_MULTILINE_TEXT_LINES_FOR_CLIPPED_RASTER {
11757        return TextRasterSource {
11758            draw: Cow::Borrowed(text_draw),
11759            raster_rect,
11760        };
11761    }
11762
11763    let Some(visible_rect) = logical_rect.intersect(clip) else {
11764        return TextRasterSource {
11765            draw: Cow::Borrowed(text_draw),
11766            raster_rect,
11767        };
11768    };
11769
11770    let line_count = line_starts.len().max(1);
11771    let line_height = logical_rect.height / line_count as f32;
11772    if !line_height.is_finite() || line_height <= 0.0 {
11773        return TextRasterSource {
11774            draw: Cow::Borrowed(text_draw),
11775            raster_rect,
11776        };
11777    }
11778
11779    let visible_top = ((visible_rect.y - logical_rect.y) / line_height).floor() as isize;
11780    let visible_bottom =
11781        ((visible_rect.y + visible_rect.height - logical_rect.y) / line_height).ceil() as isize;
11782    let start_line = visible_top.saturating_sub(1).max(0) as usize;
11783    let end_line = (visible_bottom + 1).max(start_line as isize + 1) as usize;
11784    let end_line = end_line.min(line_count);
11785    if start_line == 0 && end_line >= line_count {
11786        return TextRasterSource {
11787            draw: Cow::Borrowed(text_draw),
11788            raster_rect,
11789        };
11790    }
11791
11792    let byte_start = line_starts[start_line];
11793    let byte_end = line_end_offset(text_draw.text.text.as_str(), line_starts, end_line - 1);
11794    if byte_start >= byte_end {
11795        return TextRasterSource {
11796            draw: Cow::Borrowed(text_draw),
11797            raster_rect,
11798        };
11799    }
11800
11801    let slice_y = logical_rect.y + start_line as f32 * line_height;
11802    let slice_height = (end_line - start_line) as f32 * line_height;
11803    let mut slice_raster_rect = Rect {
11804        x: logical_rect.x * root_scale,
11805        y: slice_y * root_scale,
11806        width: logical_rect.width * root_scale,
11807        height: slice_height * root_scale,
11808    };
11809    slice_raster_rect.x = slice_raster_rect.x.round();
11810    slice_raster_rect.y = slice_raster_rect.y.round();
11811    slice_raster_rect.width = slice_raster_rect.width.ceil().max(1.0);
11812    slice_raster_rect.height = slice_raster_rect.height.ceil().max(1.0);
11813
11814    let mut sliced_draw = text_draw.clone();
11815    sliced_draw.rect = Rect {
11816        x: logical_rect.x,
11817        y: slice_y,
11818        width: logical_rect.width,
11819        height: slice_height,
11820    };
11821    sliced_draw.text = Arc::new(text_draw.text.subsequence(byte_start..byte_end));
11822
11823    TextRasterSource {
11824        draw: Cow::Owned(sliced_draw),
11825        raster_rect: slice_raster_rect,
11826    }
11827}
11828
11829fn line_start_offsets(text: &str) -> Vec<usize> {
11830    let mut starts =
11831        Vec::with_capacity(text.as_bytes().iter().filter(|b| **b == b'\n').count() + 1);
11832    starts.push(0);
11833    starts.extend(
11834        text.char_indices()
11835            .filter_map(|(index, ch)| (ch == '\n').then_some(index + ch.len_utf8())),
11836    );
11837    starts
11838}
11839
11840fn line_end_offset(text: &str, line_starts: &[usize], line: usize) -> usize {
11841    line_starts.get(line + 1).copied().unwrap_or(text.len())
11842}
11843
11844fn composite_text_segment(
11845    canvas: &mut [u8],
11846    canvas_width: u32,
11847    canvas_height: u32,
11848    canvas_rect: Rect,
11849    segment_rect: Rect,
11850    segment_image: &ImageBitmap,
11851) {
11852    let offset_x = (segment_rect.x - canvas_rect.x).round() as i32;
11853    let offset_y = (segment_rect.y - canvas_rect.y).round() as i32;
11854    let src = segment_image.pixels();
11855    for sy in 0..segment_image.height() as i32 {
11856        let dy = offset_y + sy;
11857        if dy < 0 || dy >= canvas_height as i32 {
11858            continue;
11859        }
11860        for sx in 0..segment_image.width() as i32 {
11861            let dx = offset_x + sx;
11862            if dx < 0 || dx >= canvas_width as i32 {
11863                continue;
11864            }
11865            let src_index = ((sy as u32 * segment_image.width() + sx as u32) * 4) as usize;
11866            let dst_index = ((dy as u32 * canvas_width + dx as u32) * 4) as usize;
11867            blend_rgba_pixel(
11868                &mut canvas[dst_index..dst_index + 4],
11869                &src[src_index..src_index + 4],
11870            );
11871        }
11872    }
11873}
11874
11875fn blend_rgba_pixel(dst: &mut [u8], src: &[u8]) {
11876    let src_alpha = src[3] as f32 / 255.0;
11877    if src_alpha <= 0.0 {
11878        return;
11879    }
11880    let dst_alpha = dst[3] as f32 / 255.0;
11881    let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha);
11882    if out_alpha <= f32::EPSILON {
11883        dst.copy_from_slice(&[0, 0, 0, 0]);
11884        return;
11885    }
11886
11887    for channel in 0..3 {
11888        let src_channel = src[channel] as f32 / 255.0;
11889        let dst_channel = dst[channel] as f32 / 255.0;
11890        let src_premult = src_channel * src_alpha;
11891        let dst_premult = dst_channel * dst_alpha;
11892        dst[channel] =
11893            (((src_premult + dst_premult * (1.0 - src_alpha)) / out_alpha).clamp(0.0, 1.0) * 255.0)
11894                .round() as u8;
11895    }
11896    dst[3] = (out_alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
11897}
11898
11899fn align_to(value: u32, alignment: u32) -> u32 {
11900    debug_assert!(alignment > 0);
11901    value.div_ceil(alignment) * alignment
11902}
11903
11904#[cfg(not(target_arch = "wasm32"))]
11905fn align_usize_to(value: usize, alignment: usize) -> usize {
11906    debug_assert!(alignment > 0);
11907    value.div_ceil(alignment) * alignment
11908}
11909
11910impl GpuRenderer {
11911    fn convert_surface_pixels_to_rgba(&self, pixels: &mut [u8]) -> Result<(), String> {
11912        match self.surface_format {
11913            wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => Ok(()),
11914            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb => {
11915                for pixel in pixels.chunks_exact_mut(4) {
11916                    pixel.swap(0, 2);
11917                }
11918                Ok(())
11919            }
11920            format => Err(format!(
11921                "Screenshot readback unsupported for texture format: {format:?}"
11922            )),
11923        }
11924    }
11925}
11926
11927fn is_in_effect_range(z_index: usize, effect_z_ranges: &[Range<usize>]) -> bool {
11928    effect_z_ranges.iter().any(|range| range.contains(&z_index))
11929}
11930
11931#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11932enum SegmentDrawItem {
11933    Shape(usize),
11934    Image(usize),
11935    Text(usize),
11936    Shadow(usize),
11937    Composite(usize),
11938    ShaderComposite(usize),
11939    Retained(usize),
11940}
11941
11942#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11943enum SegmentBatchPlan {
11944    Shape {
11945        start: usize,
11946        end: usize,
11947        blend_mode: BlendMode,
11948    },
11949    Image {
11950        start: usize,
11951        end: usize,
11952        blend_mode: BlendMode,
11953    },
11954    Text {
11955        start: usize,
11956        end: usize,
11957    },
11958    Composite {
11959        start: usize,
11960        end: usize,
11961    },
11962    ShaderComposite {
11963        start: usize,
11964        end: usize,
11965    },
11966    /// Retained replay batches: each item is one bind + draw of GPU slots
11967    /// captured on an earlier frame, so they never merge and cost no budget.
11968    Retained {
11969        start: usize,
11970        end: usize,
11971    },
11972}
11973
11974#[derive(Clone, Debug, Default, PartialEq, Eq)]
11975struct SegmentDrawChunkPlan {
11976    batches: Vec<SegmentBatchPlan>,
11977}
11978
11979struct SegmentRenderOutcome {
11980    rendered_any: bool,
11981    pass_count: u32,
11982}
11983
11984struct SegmentCommandEncodeOutcome {
11985    first_batch: bool,
11986}
11987
11988#[cfg(not(target_arch = "wasm32"))]
11989#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11990enum TextGlyphPrewarmDecision {
11991    Candidate,
11992    MissingGeometry,
11993    DynamicMotion,
11994    Visible,
11995    OutsidePrewarmWindow,
11996}
11997
11998#[cfg(not(target_arch = "wasm32"))]
11999#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12000struct NativeSegmentFusionBudget {
12001    shape_count: usize,
12002    gradient_stop_count: usize,
12003}
12004
12005#[cfg(not(target_arch = "wasm32"))]
12006#[derive(Clone, Debug, PartialEq, Eq)]
12007struct NativeSegmentFusionPartition {
12008    chunk: SegmentDrawChunkPlan,
12009    budget: NativeSegmentFusionBudget,
12010}
12011
12012#[cfg(not(target_arch = "wasm32"))]
12013#[derive(Clone, Debug, PartialEq, Eq)]
12014enum FusedSegmentBatch {
12015    Shape {
12016        batch: PreparedShapeBatch,
12017        blend_mode: BlendMode,
12018    },
12019    Image {
12020        cmd_range: Range<usize>,
12021        blend_mode: BlendMode,
12022    },
12023    Text {
12024        image_cmd_range: Range<usize>,
12025        glyph_cmd_range: Range<usize>,
12026    },
12027    Composite {
12028        draw_range: Range<usize>,
12029    },
12030    ShaderComposite {
12031        draw_range: Range<usize>,
12032    },
12033    Retained {
12034        item_range: Range<usize>,
12035    },
12036}
12037
12038struct ShadowSourceRenderOutcome {
12039    rendered_any: bool,
12040    pass_count: u32,
12041}
12042
12043impl SegmentDrawChunkPlan {
12044    fn is_empty(&self) -> bool {
12045        self.batches.is_empty()
12046    }
12047
12048    fn push(&mut self, batch: SegmentBatchPlan) {
12049        self.batches.push(batch);
12050    }
12051
12052    fn iter(&self) -> impl Iterator<Item = SegmentBatchPlan> + '_ {
12053        self.batches.iter().copied()
12054    }
12055}
12056
12057#[derive(Clone, Debug, PartialEq, Eq)]
12058enum SegmentRenderCommand {
12059    DrawChunk(SegmentDrawChunkPlan),
12060    Shadow(usize),
12061}
12062
12063struct SegmentCommandIter<'a> {
12064    ordered_items: &'a [(usize, SegmentDrawItem)],
12065    shapes: &'a [DrawShape],
12066    images: &'a [ImageDraw],
12067    cursor: usize,
12068    batch_limits: ShapeBatchLimits,
12069}
12070
12071impl<'a> SegmentCommandIter<'a> {
12072    fn new(
12073        ordered_items: &'a [(usize, SegmentDrawItem)],
12074        shapes: &'a [DrawShape],
12075        images: &'a [ImageDraw],
12076        batch_limits: ShapeBatchLimits,
12077    ) -> Self {
12078        Self {
12079            ordered_items,
12080            shapes,
12081            images,
12082            cursor: 0,
12083            batch_limits,
12084        }
12085    }
12086}
12087
12088impl Iterator for SegmentCommandIter<'_> {
12089    type Item = SegmentRenderCommand;
12090
12091    fn next(&mut self) -> Option<Self::Item> {
12092        if self.cursor >= self.ordered_items.len() {
12093            return None;
12094        }
12095
12096        if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
12097            self.cursor += 1;
12098            return Some(SegmentRenderCommand::Shadow(index));
12099        }
12100
12101        let mut chunk = SegmentDrawChunkPlan::default();
12102        while self.cursor < self.ordered_items.len() {
12103            if let SegmentDrawItem::Shadow(index) = self.ordered_items[self.cursor].1 {
12104                if chunk.is_empty() {
12105                    self.cursor += 1;
12106                    return Some(SegmentRenderCommand::Shadow(index));
12107                }
12108                break;
12109            }
12110
12111            let Some((batch, next_cursor)) = segment_batch_plan_at_cursor(
12112                self.ordered_items,
12113                self.shapes,
12114                self.images,
12115                self.cursor,
12116                self.batch_limits,
12117            ) else {
12118                break;
12119            };
12120            chunk.push(batch);
12121            self.cursor = next_cursor;
12122        }
12123
12124        Some(SegmentRenderCommand::DrawChunk(chunk))
12125    }
12126}
12127
12128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12129struct PreparedShapeBatch {
12130    /// First vertex and vertex count for the unindexed shape draw; always
12131    /// multiples of 6 so `vs_main`'s `vertex_index / 6` lands on whole shapes.
12132    vertex_start: u32,
12133    vertex_count: u32,
12134    #[cfg(target_arch = "wasm32")]
12135    shape_slot: usize,
12136    #[cfg(target_arch = "wasm32")]
12137    uniform_slot: usize,
12138}
12139
12140struct PreparedImageBatch {
12141    cmds: Vec<ImageDrawCmd>,
12142    #[cfg(target_arch = "wasm32")]
12143    image_slot: usize,
12144    #[cfg(target_arch = "wasm32")]
12145    uniform_slot: usize,
12146}
12147
12148impl PreparedImageBatch {
12149    fn is_empty(&self) -> bool {
12150        self.cmds.is_empty()
12151    }
12152
12153    fn into_cmds(self) -> Vec<ImageDrawCmd> {
12154        self.cmds
12155    }
12156}
12157
12158struct PreparedGlyphBatch {
12159    cmds: Vec<GlyphDrawCmd>,
12160    #[cfg(target_arch = "wasm32")]
12161    image_slot: usize,
12162    #[cfg(target_arch = "wasm32")]
12163    uniform_slot: usize,
12164}
12165
12166impl PreparedGlyphBatch {
12167    fn is_empty(&self) -> bool {
12168        self.cmds.is_empty()
12169    }
12170
12171    fn into_cmds(self) -> Vec<GlyphDrawCmd> {
12172        self.cmds
12173    }
12174}
12175
12176#[cfg(not(target_arch = "wasm32"))]
12177fn gradient_stop_count_for_shape(shape: &DrawShape) -> usize {
12178    match &shape.brush {
12179        Brush::Solid(_) => 0,
12180        Brush::LinearGradient { colors, .. }
12181        | Brush::RadialGradient { colors, .. }
12182        | Brush::SweepGradient { colors, .. } => colors.len(),
12183    }
12184}
12185
12186#[cfg(not(target_arch = "wasm32"))]
12187fn native_segment_fusion_budget(
12188    ordered_items: &[(usize, SegmentDrawItem)],
12189    shapes: &[DrawShape],
12190    chunk: &SegmentDrawChunkPlan,
12191    batch_limits: ShapeBatchLimits,
12192) -> Result<Option<NativeSegmentFusionBudget>, String> {
12193    let mut shape_count = 0usize;
12194    let mut gradient_stop_count = 0usize;
12195
12196    for batch in chunk.iter() {
12197        let SegmentBatchPlan::Shape { start, end, .. } = batch else {
12198            continue;
12199        };
12200        for (_, item) in &ordered_items[start..end] {
12201            let SegmentDrawItem::Shape(shape_index) = item else {
12202                return Err(format!(
12203                    "shape batch contains non-shape draw item: {item:?}"
12204                ));
12205            };
12206            let shape = &shapes[*shape_index];
12207            shape_count = shape_count.saturating_add(1);
12208            gradient_stop_count =
12209                gradient_stop_count.saturating_add(gradient_stop_count_for_shape(shape));
12210        }
12211    }
12212
12213    if shape_count > batch_limits.max_shapes_per_batch
12214        || gradient_stop_count > batch_limits.max_gradient_stops
12215    {
12216        return Ok(None);
12217    }
12218
12219    Ok(Some(NativeSegmentFusionBudget {
12220        shape_count,
12221        gradient_stop_count,
12222    }))
12223}
12224
12225#[cfg(not(target_arch = "wasm32"))]
12226fn push_native_segment_fusion_partition(
12227    partitions: &mut Vec<NativeSegmentFusionPartition>,
12228    current: &mut SegmentDrawChunkPlan,
12229    current_budget: &mut NativeSegmentFusionBudget,
12230) {
12231    if current.is_empty() {
12232        return;
12233    }
12234
12235    partitions.push(NativeSegmentFusionPartition {
12236        chunk: std::mem::take(current),
12237        budget: *current_budget,
12238    });
12239    *current_budget = NativeSegmentFusionBudget {
12240        shape_count: 0,
12241        gradient_stop_count: 0,
12242    };
12243}
12244
12245#[cfg(not(target_arch = "wasm32"))]
12246fn native_segment_fusion_partitions(
12247    ordered_items: &[(usize, SegmentDrawItem)],
12248    shapes: &[DrawShape],
12249    chunk: &SegmentDrawChunkPlan,
12250    batch_limits: ShapeBatchLimits,
12251) -> Result<Option<Vec<NativeSegmentFusionPartition>>, String> {
12252    if let Some(budget) = native_segment_fusion_budget(ordered_items, shapes, chunk, batch_limits)?
12253    {
12254        return Ok(Some(vec![NativeSegmentFusionPartition {
12255            chunk: chunk.clone(),
12256            budget,
12257        }]));
12258    }
12259
12260    let mut partitions = Vec::new();
12261    let mut current = SegmentDrawChunkPlan::default();
12262    let mut current_budget = NativeSegmentFusionBudget {
12263        shape_count: 0,
12264        gradient_stop_count: 0,
12265    };
12266
12267    for batch in chunk.iter() {
12268        let SegmentBatchPlan::Shape {
12269            start,
12270            end,
12271            blend_mode,
12272        } = batch
12273        else {
12274            current.push(batch);
12275            continue;
12276        };
12277
12278        let mut run_start = start;
12279        for (item_cursor, (_, item)) in ordered_items.iter().enumerate().take(end).skip(start) {
12280            let SegmentDrawItem::Shape(shape_index) = *item else {
12281                return Err(format!(
12282                    "shape batch contains non-shape draw item: {:?}",
12283                    item
12284                ));
12285            };
12286            let gradient_stop_count = gradient_stop_count_for_shape(&shapes[shape_index]);
12287            if gradient_stop_count > batch_limits.max_gradient_stops {
12288                return Ok(None);
12289            }
12290
12291            let fits_shape_count =
12292                current_budget.shape_count.saturating_add(1) <= batch_limits.max_shapes_per_batch;
12293            let fits_gradient_count = current_budget
12294                .gradient_stop_count
12295                .saturating_add(gradient_stop_count)
12296                <= batch_limits.max_gradient_stops;
12297            if !fits_shape_count || !fits_gradient_count {
12298                if run_start < item_cursor {
12299                    current.push(SegmentBatchPlan::Shape {
12300                        start: run_start,
12301                        end: item_cursor,
12302                        blend_mode,
12303                    });
12304                }
12305                push_native_segment_fusion_partition(
12306                    &mut partitions,
12307                    &mut current,
12308                    &mut current_budget,
12309                );
12310                run_start = item_cursor;
12311            }
12312
12313            current_budget.shape_count = current_budget.shape_count.saturating_add(1);
12314            current_budget.gradient_stop_count = current_budget
12315                .gradient_stop_count
12316                .saturating_add(gradient_stop_count);
12317        }
12318
12319        if run_start < end {
12320            current.push(SegmentBatchPlan::Shape {
12321                start: run_start,
12322                end,
12323                blend_mode,
12324            });
12325        }
12326    }
12327
12328    push_native_segment_fusion_partition(&mut partitions, &mut current, &mut current_budget);
12329    Ok(Some(partitions))
12330}
12331
12332fn segment_batch_plan_at_cursor(
12333    ordered_items: &[(usize, SegmentDrawItem)],
12334    shapes: &[DrawShape],
12335    images: &[ImageDraw],
12336    start: usize,
12337    batch_limits: ShapeBatchLimits,
12338) -> Option<(SegmentBatchPlan, usize)> {
12339    match ordered_items[start].1 {
12340        SegmentDrawItem::Shape(index) => {
12341            let blend_mode = supported_blend_mode(shapes[index].blend_mode);
12342            let mut end = start + 1;
12343            let shape_limit = (start + batch_limits.max_shapes_per_batch).min(ordered_items.len());
12344            while end < shape_limit {
12345                match ordered_items[end].1 {
12346                    SegmentDrawItem::Shape(next_index)
12347                        if supported_blend_mode(shapes[next_index].blend_mode) == blend_mode =>
12348                    {
12349                        end += 1;
12350                    }
12351                    _ => break,
12352                }
12353            }
12354            Some((
12355                SegmentBatchPlan::Shape {
12356                    start,
12357                    end,
12358                    blend_mode,
12359                },
12360                end,
12361            ))
12362        }
12363        SegmentDrawItem::Image(index) => {
12364            let blend_mode = supported_blend_mode(images[index].blend_mode);
12365            let mut end = start + 1;
12366            while end < ordered_items.len() {
12367                match ordered_items[end].1 {
12368                    SegmentDrawItem::Image(next_index)
12369                        if supported_blend_mode(images[next_index].blend_mode) == blend_mode =>
12370                    {
12371                        end += 1;
12372                    }
12373                    _ => break,
12374                }
12375            }
12376            Some((
12377                SegmentBatchPlan::Image {
12378                    start,
12379                    end,
12380                    blend_mode,
12381                },
12382                end,
12383            ))
12384        }
12385        SegmentDrawItem::Text(_) => {
12386            let mut end = start + 1;
12387            while end < ordered_items.len() {
12388                if matches!(ordered_items[end].1, SegmentDrawItem::Text(_)) {
12389                    end += 1;
12390                } else {
12391                    break;
12392                }
12393            }
12394            Some((SegmentBatchPlan::Text { start, end }, end))
12395        }
12396        SegmentDrawItem::Composite(_) => {
12397            let mut end = start + 1;
12398            while end < ordered_items.len() {
12399                if matches!(ordered_items[end].1, SegmentDrawItem::Composite(_)) {
12400                    end += 1;
12401                } else {
12402                    break;
12403                }
12404            }
12405            Some((SegmentBatchPlan::Composite { start, end }, end))
12406        }
12407        SegmentDrawItem::ShaderComposite(_) => {
12408            let mut end = start + 1;
12409            while end < ordered_items.len() {
12410                if matches!(ordered_items[end].1, SegmentDrawItem::ShaderComposite(_)) {
12411                    end += 1;
12412                } else {
12413                    break;
12414                }
12415            }
12416            Some((SegmentBatchPlan::ShaderComposite { start, end }, end))
12417        }
12418        SegmentDrawItem::Retained(_) => {
12419            let mut end = start + 1;
12420            while end < ordered_items.len() {
12421                if matches!(ordered_items[end].1, SegmentDrawItem::Retained(_)) {
12422                    end += 1;
12423                } else {
12424                    break;
12425                }
12426            }
12427            Some((SegmentBatchPlan::Retained { start, end }, end))
12428        }
12429        SegmentDrawItem::Shadow(_) => None,
12430    }
12431}
12432
12433#[allow(clippy::too_many_arguments)]
12434fn collect_non_effect_segment_items(
12435    shapes: &[DrawShape],
12436    _images: &[ImageDraw],
12437    _texts: &[TextDraw],
12438    _shadow_draws: &[ShadowDraw],
12439    draw_ops: &[DrawOp],
12440    z_start: usize,
12441    z_end: usize,
12442    effect_z_ranges: &[Range<usize>],
12443    width: u32,
12444    height: u32,
12445    root_scale: f32,
12446    scratch: &mut Vec<(usize, SegmentDrawItem)>,
12447) {
12448    scratch.clear();
12449    let viewport = ViewportUniformParams {
12450        width,
12451        height,
12452        offset: [0.0, 0.0],
12453    };
12454
12455    scratch.extend(draw_ops.iter().filter_map(|op| {
12456        if op.z_index < z_start
12457            || op.z_index >= z_end
12458            || is_in_effect_range(op.z_index, effect_z_ranges)
12459        {
12460            return None;
12461        }
12462        let item = match op.kind {
12463            DrawOpKind::Shape(index) => {
12464                let shape = shapes.get(index)?;
12465                if !shape_draw_is_visible_in_viewport(shape, viewport, root_scale) {
12466                    return None;
12467                }
12468                SegmentDrawItem::Shape(index)
12469            }
12470            DrawOpKind::Image(index) => SegmentDrawItem::Image(index),
12471            DrawOpKind::Text(index) => SegmentDrawItem::Text(index),
12472            DrawOpKind::Shadow(index) => SegmentDrawItem::Shadow(index),
12473            DrawOpKind::Retained(index) => SegmentDrawItem::Retained(index),
12474        };
12475        Some((op.z_index, item))
12476    }));
12477}
12478
12479fn retain_renderable_shadow_items(
12480    ordered_items: &mut Vec<(usize, SegmentDrawItem)>,
12481    shadow_draws: &[ShadowDraw],
12482    width: u32,
12483    height: u32,
12484    root_scale: f32,
12485    max_texture_dim: u32,
12486) -> usize {
12487    let original_len = ordered_items.len();
12488    ordered_items.retain(|(_, item)| match item {
12489        SegmentDrawItem::Shadow(index) => shadow_draws.get(*index).is_some_and(|shadow| {
12490            shadow_draw_may_render(shadow, width, height, root_scale, max_texture_dim)
12491        }),
12492        _ => true,
12493    });
12494    original_len.saturating_sub(ordered_items.len())
12495}
12496
12497#[cfg(not(target_arch = "wasm32"))]
12498#[derive(Clone, Copy)]
12499struct SegmentDiagCounts {
12500    raw_shadow_items: usize,
12501    culled_shadow_items: usize,
12502    cached_shadow_composites: usize,
12503    composite_items: usize,
12504    shader_composite_items: usize,
12505}
12506
12507#[cfg(not(target_arch = "wasm32"))]
12508fn maybe_print_segment_diag(
12509    z_range: Range<usize>,
12510    ordered_items: &[(usize, SegmentDrawItem)],
12511    shapes: &[DrawShape],
12512    images: &[ImageDraw],
12513    counts: SegmentDiagCounts,
12514    batch_limits: ShapeBatchLimits,
12515) {
12516    if !cranpose_core::env_flag!("CRANPOSE_SEGMENT_DIAG") {
12517        return;
12518    }
12519    let line = SEGMENT_DIAG_LINES.fetch_add(1, Ordering::Relaxed);
12520    if line >= 64 {
12521        return;
12522    }
12523
12524    let remaining_shadow_items = ordered_items
12525        .iter()
12526        .filter(|(_, item)| matches!(item, SegmentDrawItem::Shadow(_)))
12527        .count();
12528    let commands: Vec<_> =
12529        SegmentCommandIter::new(ordered_items, shapes, images, batch_limits).collect();
12530    let draw_chunks = commands
12531        .iter()
12532        .filter(|command| matches!(command, SegmentRenderCommand::DrawChunk(_)))
12533        .count();
12534    let shadow_commands = commands
12535        .iter()
12536        .filter(|command| matches!(command, SegmentRenderCommand::Shadow(_)))
12537        .count();
12538    let mut native_partitions = 0usize;
12539    let mut native_unfused_chunks = 0usize;
12540    for command in &commands {
12541        let SegmentRenderCommand::DrawChunk(chunk) = command else {
12542            continue;
12543        };
12544        match native_segment_fusion_partitions(ordered_items, shapes, chunk, batch_limits) {
12545            Ok(Some(partitions)) => native_partitions += partitions.len(),
12546            Ok(None) | Err(_) => native_unfused_chunks += 1,
12547        }
12548    }
12549
12550    eprintln!(
12551        "[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={}",
12552        z_range.start,
12553        z_range.end,
12554        ordered_items.len(),
12555        counts.raw_shadow_items,
12556        counts.culled_shadow_items,
12557        counts.cached_shadow_composites,
12558        remaining_shadow_items,
12559        counts.composite_items,
12560        counts.shader_composite_items,
12561        draw_chunks,
12562        shadow_commands,
12563        native_partitions,
12564        native_unfused_chunks,
12565    );
12566}
12567
12568pub(crate) fn has_backdrop_layer_in_range(
12569    backdrop_layers: &[BackdropLayer],
12570    z_start: usize,
12571    z_end: usize,
12572) -> bool {
12573    backdrop_layers
12574        .iter()
12575        .any(|layer| layer.z_index >= z_start && layer.z_index < z_end)
12576}
12577
12578pub(crate) fn scissor_rect_for_rect(
12579    rect: Rect,
12580    root_scale: f32,
12581    width: u32,
12582    height: u32,
12583) -> Option<(u32, u32, u32, u32)> {
12584    let mut left = canonicalize_device_coordinate(rect.x * root_scale);
12585    let mut top = canonicalize_device_coordinate(rect.y * root_scale);
12586    let mut right = canonicalize_device_coordinate((rect.x + rect.width) * root_scale);
12587    let mut bottom = canonicalize_device_coordinate((rect.y + rect.height) * root_scale);
12588
12589    left = left.max(0.0).min(width as f32).floor();
12590    top = top.max(0.0).min(height as f32).floor();
12591    right = right.max(0.0).min(width as f32).ceil();
12592    bottom = bottom.max(0.0).min(height as f32).ceil();
12593
12594    if right <= left || bottom <= top {
12595        return None;
12596    }
12597
12598    Some((
12599        left as u32,
12600        top as u32,
12601        (right - left) as u32,
12602        (bottom - top) as u32,
12603    ))
12604}
12605
12606fn scissor_rect_for_layer(
12607    rect: Rect,
12608    clip: Option<Rect>,
12609    root_scale: f32,
12610    width: u32,
12611    height: u32,
12612) -> Option<(u32, u32, u32, u32)> {
12613    let clipped_rect = match clip {
12614        Some(clip_rect) => rect.intersect(clip_rect)?,
12615        None => rect,
12616    };
12617
12618    scissor_rect_for_rect(clipped_rect, root_scale, width, height)
12619}
12620
12621fn tint_for_image(
12622    color_filter: Option<ColorFilter>,
12623    alpha: f32,
12624) -> ([f32; 4], Option<ColorFilter>) {
12625    let alpha = alpha.clamp(0.0, 1.0);
12626    match color_filter {
12627        Some(filter) if filter.supports_gpu_vertex_modulation() => {
12628            let Some(tint) = filter.gpu_vertex_tint() else {
12629                return ([1.0, 1.0, 1.0, alpha], Some(filter));
12630            };
12631            (
12632                [
12633                    tint[0].clamp(0.0, 1.0),
12634                    tint[1].clamp(0.0, 1.0),
12635                    tint[2].clamp(0.0, 1.0),
12636                    (tint[3] * alpha).clamp(0.0, 1.0),
12637                ],
12638                None,
12639            )
12640        }
12641        Some(filter) => ([1.0, 1.0, 1.0, alpha], Some(filter)),
12642        None => ([1.0, 1.0, 1.0, alpha], None),
12643    }
12644}
12645
12646fn image_uv_rect(image: &ImageBitmap, src_rect: Option<Rect>) -> Option<ImageUvRect> {
12647    let Some(src) = src_rect else {
12648        return Some(ImageUvRect {
12649            min: [0.0, 0.0],
12650            max: [1.0, 1.0],
12651            sample_bounds: [0.0, 0.0, 1.0, 1.0],
12652        });
12653    };
12654
12655    let (u_min, u_max, u_bound_min, u_bound_max) =
12656        source_axis_uv(src.x, src.width, image.width() as f32)?;
12657    let (v_min, v_max, v_bound_min, v_bound_max) =
12658        source_axis_uv(src.y, src.height, image.height() as f32)?;
12659
12660    Some(ImageUvRect {
12661        min: [u_min, v_min],
12662        max: [u_max, v_max],
12663        sample_bounds: [u_bound_min, v_bound_min, u_bound_max, v_bound_max],
12664    })
12665}
12666
12667/// Normalises an atlas entry against `atlas_size`, the side length of the
12668/// texture the entry was placed in. The atlas grows on overflow, so the size
12669/// has to be read from the live atlas rather than a constant — a UV computed
12670/// against the wrong size samples the wrong glyph.
12671fn glyph_atlas_uv_rect(entry: GlyphAtlasEntry, atlas_size: u32) -> ImageUvRect {
12672    let atlas_width = atlas_size as f32;
12673    let atlas_height = atlas_size as f32;
12674    let min = [entry.x as f32 / atlas_width, entry.y as f32 / atlas_height];
12675    let max = [
12676        (entry.x + entry.width) as f32 / atlas_width,
12677        (entry.y + entry.height) as f32 / atlas_height,
12678    ];
12679    let center_min = [
12680        (entry.x as f32 + 0.5) / atlas_width,
12681        (entry.y as f32 + 0.5) / atlas_height,
12682    ];
12683    let center_max = [
12684        (entry.x as f32 + entry.width as f32 - 0.5).max(entry.x as f32 + 0.5) / atlas_width,
12685        (entry.y as f32 + entry.height as f32 - 0.5).max(entry.y as f32 + 0.5) / atlas_height,
12686    ];
12687    ImageUvRect {
12688        min,
12689        max,
12690        sample_bounds: [center_min[0], center_min[1], center_max[0], center_max[1]],
12691    }
12692}
12693
12694fn snap_nearest_image_to_device_pixels(image: &mut ImageDraw, root_scale: f32) {
12695    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
12696        return;
12697    }
12698
12699    let Some(rect) = axis_aligned_quad_rect(image.quad) else {
12700        return;
12701    };
12702
12703    let left_px = (rect.x * root_scale).round();
12704    let top_px = (rect.y * root_scale).round();
12705    let width_px = (rect.width * root_scale).round().max(1.0);
12706    let height_px = (rect.height * root_scale).round().max(1.0);
12707    let snapped = Rect {
12708        x: left_px / root_scale,
12709        y: top_px / root_scale,
12710        width: width_px / root_scale,
12711        height: height_px / root_scale,
12712    };
12713
12714    image.rect = snapped;
12715    image.local_rect = Rect {
12716        x: image.local_rect.x + snapped.x - rect.x,
12717        y: image.local_rect.y + snapped.y - rect.y,
12718        width: snapped.width,
12719        height: snapped.height,
12720    };
12721    image.quad = crate::rect_to_quad(snapped);
12722}
12723
12724fn nearest_image_device_quad(image: &ImageDraw, root_scale: f32) -> Option<[[f32; 2]; 4]> {
12725    if image.sampling != ImageSampling::Nearest || !root_scale.is_finite() || root_scale <= 0.0 {
12726        return None;
12727    }
12728
12729    let rect = axis_aligned_quad_rect(image.quad)?;
12730    let left_px = (rect.x * root_scale).round();
12731    let top_px = (rect.y * root_scale).round();
12732    let width_px = (rect.width * root_scale).round().max(1.0);
12733    let height_px = (rect.height * root_scale).round().max(1.0);
12734    let right_px = left_px + width_px;
12735    let bottom_px = top_px + height_px;
12736    Some([
12737        [left_px, top_px],
12738        [right_px, top_px],
12739        [left_px, bottom_px],
12740        [right_px, bottom_px],
12741    ])
12742}
12743
12744fn source_axis_uv(start: f32, extent: f32, image_extent: f32) -> Option<(f32, f32, f32, f32)> {
12745    if !start.is_finite()
12746        || !extent.is_finite()
12747        || !image_extent.is_finite()
12748        || extent == 0.0
12749        || image_extent <= 0.0
12750    {
12751        return None;
12752    }
12753
12754    let end = start + extent;
12755    let edge_min = start.min(end).clamp(0.0, image_extent);
12756    let edge_max = start.max(end).clamp(0.0, image_extent);
12757    if edge_max <= edge_min {
12758        return None;
12759    }
12760
12761    let center_min = edge_min + 0.5;
12762    let center_max = edge_max - 0.5;
12763    let (bound_min, bound_max) = if center_min <= center_max {
12764        (center_min, center_max)
12765    } else {
12766        let center = (edge_min + edge_max) * 0.5;
12767        (center, center)
12768    };
12769
12770    Some((
12771        edge_min / image_extent,
12772        edge_max / image_extent,
12773        bound_min / image_extent,
12774        bound_max / image_extent,
12775    ))
12776}
12777
12778fn apply_filter_to_bitmap(image: &ImageBitmap, filter: ColorFilter) -> Result<ImageBitmap, String> {
12779    let mut filtered = Vec::with_capacity(image.pixels().len());
12780    for pixel in image.pixels().chunks_exact(4) {
12781        let rgba = [
12782            pixel[0] as f32 / 255.0,
12783            pixel[1] as f32 / 255.0,
12784            pixel[2] as f32 / 255.0,
12785            pixel[3] as f32 / 255.0,
12786        ];
12787        let out = filter.apply_rgba(rgba);
12788        filtered.push((out[0].clamp(0.0, 1.0) * 255.0).round() as u8);
12789        filtered.push((out[1].clamp(0.0, 1.0) * 255.0).round() as u8);
12790        filtered.push((out[2].clamp(0.0, 1.0) * 255.0).round() as u8);
12791        filtered.push((out[3].clamp(0.0, 1.0) * 255.0).round() as u8);
12792    }
12793    ImageBitmap::from_rgba8(image.width(), image.height(), filtered)
12794        .map_err(|error| format!("failed to build filtered bitmap: {error}"))
12795}
12796
12797fn scissor_rect_for_image(
12798    image: &ImageDraw,
12799    root_scale: f32,
12800    width: u32,
12801    height: u32,
12802) -> Option<(u32, u32, u32, u32)> {
12803    scissor_rect_for_layer(image.rect, image.clip, root_scale, width, height)
12804}
12805
12806fn inner_shadow_composite_mask(
12807    shadow: &ShadowDraw,
12808    root_scale: f32,
12809) -> Option<RoundedCompositeMask> {
12810    if !shadow
12811        .shapes
12812        .iter()
12813        .any(|(_, mode)| *mode == BlendMode::DstOut)
12814    {
12815        return None;
12816    }
12817    let (fill, _) = shadow.shapes.first()?;
12818    let rect = fill.local_rect;
12819    if rect.width <= 0.0 || rect.height <= 0.0 {
12820        return None;
12821    }
12822
12823    let radii = fill.shape.map_or([0.0; 4], |rounded| {
12824        let resolved = rounded.resolve(rect.width, rect.height);
12825        [
12826            resolved.top_left * root_scale,
12827            resolved.top_right * root_scale,
12828            resolved.bottom_left * root_scale,
12829            resolved.bottom_right * root_scale,
12830        ]
12831    });
12832
12833    Some(RoundedCompositeMask {
12834        rect: [
12835            rect.x * root_scale,
12836            rect.y * root_scale,
12837            rect.width * root_scale,
12838            rect.height * root_scale,
12839        ],
12840        radii,
12841    })
12842}
12843
12844#[cfg(test)]
12845mod tests {
12846    use super::*;
12847    use crate::normalized_scene::visible_draw_rect;
12848    use cranpose_foundation::lazy::{remember_lazy_list_state, LazyListScope, LazyListState};
12849    use cranpose_render_common::graph::{DrawPrimitiveNode, IsolationReasons, TextPrimitiveNode};
12850    use cranpose_render_common::raster_cache::LayerRasterCacheHashes;
12851    use cranpose_render_common::scene_builder::build_graph_from_applier;
12852    use cranpose_ui::text::{
12853        AnnotatedString, BaselineShift, RangeStyle, Shadow, SpanStyle, TextDecoration,
12854        TextDrawStyle, TextGeometricTransform, TextMotion, TextUnit,
12855    };
12856    use cranpose_ui::{
12857        LayoutEngine, LazyColumn, LazyColumnSpec, Modifier, Size, Text, TextLayoutOptions,
12858        TextStyle,
12859    };
12860    use cranpose_ui_graphics::{
12861        Brush, Color, CornerRadii, DrawPrimitive, Rect, RenderEffect, RoundedCornerShape,
12862        RuntimeShader,
12863    };
12864
12865    fn chunk(batches: &[SegmentBatchPlan]) -> SegmentDrawChunkPlan {
12866        let mut chunk = SegmentDrawChunkPlan::default();
12867        for batch in batches {
12868            chunk.push(*batch);
12869        }
12870        chunk
12871    }
12872
12873    fn with_test_app_context<R>(block: impl FnOnce() -> R) -> R {
12874        let app_context = cranpose_ui::AppContext::new();
12875        app_context.enter(block)
12876    }
12877
12878    fn assert_snap_anchor_close(actual: Option<SnapAnchor>, expected_origin: Point, message: &str) {
12879        let Some(actual) = actual else {
12880            panic!("{message}: missing snap anchor");
12881        };
12882        let expected = SnapAnchor::rigid(expected_origin);
12883        assert_eq!(
12884            actual.device_pixel_step, expected.device_pixel_step,
12885            "{message}: device pixel step changed"
12886        );
12887        assert!(
12888            (actual.origin.x - expected.origin.x).abs() <= 1e-4
12889                && (actual.origin.y - expected.origin.y).abs() <= 1e-4,
12890            "{message}: expected origin {:?}, got {:?}",
12891            expected.origin,
12892            actual.origin
12893        );
12894    }
12895
12896    fn effect_layer(z_start: usize, z_end: usize) -> EffectLayer {
12897        EffectLayer {
12898            rect: Rect {
12899                x: 0.0,
12900                y: 0.0,
12901                width: 10.0,
12902                height: 10.0,
12903            },
12904            clip: None,
12905            snap_anchor: None,
12906            effect: Some(RenderEffect::blur(4.0)),
12907            blend_mode: BlendMode::SrcOver,
12908            composite_alpha: 1.0,
12909            z_start,
12910            z_end,
12911            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
12912        }
12913    }
12914
12915    #[test]
12916    fn direct_shader_composite_accepts_box4_when_viewport_preserves_source_pixels() {
12917        assert_eq!(
12918            direct_shader_composite_viewport(
12919                1.0,
12920                BlendMode::SrcOver,
12921                Some((12.0, 18.0, 64.0, 32.0)),
12922                CompositeSampleMode::Box4,
12923                (64, 32),
12924            ),
12925            Some((12.0, 18.0, 64.0, 32.0))
12926        );
12927    }
12928
12929    #[test]
12930    fn direct_shader_composite_rejects_box4_when_viewport_resamples_source() {
12931        assert_eq!(
12932            direct_shader_composite_viewport(
12933                1.0,
12934                BlendMode::SrcOver,
12935                Some((12.0, 18.0, 64.5, 32.0)),
12936                CompositeSampleMode::Box4,
12937                (64, 32),
12938            ),
12939            None
12940        );
12941        assert_eq!(
12942            direct_shader_composite_viewport(
12943                1.0,
12944                BlendMode::SrcOver,
12945                Some((12.25, 18.0, 64.0, 32.0)),
12946                CompositeSampleMode::Box4,
12947                (64, 32),
12948            ),
12949            None
12950        );
12951    }
12952
12953    fn test_text_draw(rect: Rect, text_motion: TextMotion) -> TextDraw {
12954        let mut text_style = TextStyle::default();
12955        text_style.paragraph_style.text_motion = Some(text_motion);
12956        TextDraw {
12957            node_id: 42,
12958            rect,
12959            snap_anchor: None,
12960            translated_content_context: false,
12961            text: Arc::new(AnnotatedString::new("stable markdown row".to_string()).render_string()),
12962            color: Color::WHITE,
12963            text_style,
12964            font_size: 14.0,
12965            scale: 1.0,
12966            layout_options: TextLayoutOptions::default(),
12967            z_index: 0,
12968            clip: None,
12969        }
12970    }
12971
12972    #[test]
12973    fn static_text_image_cache_key_ignores_absolute_scroll_position() {
12974        let base = test_text_draw(
12975            Rect {
12976                x: 12.25,
12977                y: 40.75,
12978                width: 220.0,
12979                height: 24.0,
12980            },
12981            TextMotion::Static,
12982        );
12983        let scrolled = test_text_draw(
12984            Rect {
12985                x: 12.75,
12986                y: -318.5,
12987                width: 220.0,
12988                height: 24.0,
12989            },
12990            TextMotion::Static,
12991        );
12992
12993        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, true);
12994        let scrolled_key = GpuRenderer::text_image_cache_key(&scrolled, scrolled.rect, 1.0, true);
12995
12996        assert_eq!(
12997            base_key, scrolled_key,
12998            "scrolling static text must reuse the same raster cache entry"
12999        );
13000    }
13001
13002    #[test]
13003    fn static_text_glyph_run_cache_key_ignores_absolute_scroll_position() {
13004        let base = test_text_draw(
13005            Rect {
13006                x: 12.25,
13007                y: 40.75,
13008                width: 220.0,
13009                height: 24.0,
13010            },
13011            TextMotion::Static,
13012        );
13013        let scrolled = test_text_draw(
13014            Rect {
13015                x: 12.75,
13016                y: -318.5,
13017                width: 220.0,
13018                height: 24.0,
13019            },
13020            TextMotion::Static,
13021        );
13022
13023        let base_key = GpuRenderer::text_glyph_run_cache_key(&base, base.rect, 1.0, true);
13024        let scrolled_key =
13025            GpuRenderer::text_glyph_run_cache_key(&scrolled, scrolled.rect, 1.0, true);
13026
13027        assert_eq!(
13028            base_key, scrolled_key,
13029            "scrolling static text must reuse the same retained glyph run"
13030        );
13031    }
13032
13033    #[test]
13034    fn static_multiline_text_glyph_source_keeps_full_text_when_image_source_slices() {
13035        let rect = Rect {
13036            x: 8.0,
13037            y: 100.0,
13038            width: 240.0,
13039            height: 1_000.0,
13040        };
13041        let mut draw = test_text_draw(rect, TextMotion::Static);
13042        let lines = (0..100)
13043            .map(|line| format!("line-{line:03}"))
13044            .collect::<Vec<_>>()
13045            .join("\n");
13046        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
13047
13048        let raster_rect = Rect {
13049            x: 16.0,
13050            y: 200.0,
13051            width: 480.0,
13052            height: 2_000.0,
13053        };
13054        let clipped = clipped_text_raster_source(
13055            &draw,
13056            rect,
13057            raster_rect,
13058            Some(Rect {
13059                x: 0.0,
13060                y: 610.0,
13061                width: 800.0,
13062                height: 40.0,
13063            }),
13064            2.0,
13065            true,
13066        );
13067        let glyph = text_glyph_raster_source(&draw, raster_rect);
13068
13069        assert!(
13070            matches!(clipped.draw, Cow::Owned(_)),
13071            "the image source should still slice large clipped multiline text"
13072        );
13073        assert!(
13074            matches!(glyph.draw, Cow::Borrowed(_)),
13075            "the glyph source must keep a stable full-text run key while scrolling"
13076        );
13077
13078        let clipped_key = GpuRenderer::text_glyph_run_cache_key(
13079            clipped.draw.as_ref(),
13080            clipped.raster_rect,
13081            2.0,
13082            true,
13083        );
13084        let glyph_key = GpuRenderer::text_glyph_run_cache_key(
13085            glyph.draw.as_ref(),
13086            glyph.raster_rect,
13087            2.0,
13088            true,
13089        );
13090
13091        assert_ne!(
13092            clipped_key, glyph_key,
13093            "image slicing must not force glyph rendering onto per-scroll line-window cache keys"
13094        );
13095    }
13096
13097    #[cfg(not(target_arch = "wasm32"))]
13098    #[test]
13099    fn retained_glyph_viewport_offsets_relative_vertices_by_source_origin() {
13100        let viewport = ViewportUniformParams {
13101            width: 800,
13102            height: 600,
13103            offset: [10.0, 20.0],
13104        };
13105        let source = Rect {
13106            x: 40.0,
13107            y: 90.0,
13108            width: 120.0,
13109            height: 48.0,
13110        };
13111
13112        let retained = GpuRenderer::retained_glyph_viewport(viewport, source);
13113
13114        assert_eq!(retained.width, viewport.width);
13115        assert_eq!(retained.height, viewport.height);
13116        assert_eq!(retained.offset, [-30.0, -70.0]);
13117    }
13118
13119    #[cfg(not(target_arch = "wasm32"))]
13120    #[test]
13121    fn tiny_text_glyph_runs_stay_in_shared_uploads() {
13122        assert!(
13123            !should_use_retained_text_glyph_run(8, None),
13124            "tiny labels must stay in the shared fused batch"
13125        );
13126    }
13127
13128    #[cfg(not(target_arch = "wasm32"))]
13129    #[test]
13130    fn line_sized_text_glyph_runs_stay_in_shared_uploads() {
13131        assert!(
13132            !should_use_retained_text_glyph_run(64, None),
13133            "Markdown scroll frames contain many line-sized text runs; retaining each one creates per-run buffer binds instead of one shared glyph batch"
13134        );
13135    }
13136
13137    #[cfg(not(target_arch = "wasm32"))]
13138    #[test]
13139    fn large_clipped_text_glyph_runs_stay_in_shared_uploads() {
13140        assert!(
13141            !should_use_retained_text_glyph_run(
13142                MIN_RETAINED_TEXT_GLYPH_QUADS.saturating_mul(2),
13143                Some(Rect {
13144                    x: 0.0,
13145                    y: 0.0,
13146                    width: 200.0,
13147                    height: 100.0,
13148                }),
13149            ),
13150            "clipped lazy-list text must not draw a full retained run outside the viewport"
13151        );
13152    }
13153
13154    #[test]
13155    fn normal_text_glyph_draw_skips_offscreen_prewarm_candidates() {
13156        assert_eq!(
13157            text_glyph_draw_action(false, true, false),
13158            TextGlyphDrawAction::Skip,
13159            "normal draw traversal must not prepare offscreen text"
13160        );
13161    }
13162
13163    #[test]
13164    fn bounded_text_glyph_prewarm_admits_offscreen_candidates() {
13165        assert_eq!(
13166            text_glyph_draw_action(false, true, true),
13167            TextGlyphDrawAction::PrewarmOffscreen,
13168            "only the bounded prewarm path may prepare offscreen text"
13169        );
13170    }
13171
13172    #[test]
13173    fn visible_text_glyph_draws_are_always_admitted() {
13174        assert_eq!(
13175            text_glyph_draw_action(true, false, false),
13176            TextGlyphDrawAction::DrawVisible
13177        );
13178        assert_eq!(
13179            text_glyph_draw_action(true, true, true),
13180            TextGlyphDrawAction::DrawVisible
13181        );
13182    }
13183
13184    #[cfg(not(target_arch = "wasm32"))]
13185    #[test]
13186    fn offscreen_text_prewarm_skips_large_uncached_text_runs() {
13187        assert!(
13188            !offscreen_text_glyph_prewarm_work_is_bounded(
13189                None,
13190                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS + 1,
13191            ),
13192            "offscreen prewarm must not collect large uncached text runs in an input frame"
13193        );
13194    }
13195
13196    #[cfg(not(target_arch = "wasm32"))]
13197    #[test]
13198    fn offscreen_text_prewarm_admits_small_uncached_text_runs() {
13199        assert!(
13200            offscreen_text_glyph_prewarm_work_is_bounded(
13201                None,
13202                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_UNCACHED_CHARS,
13203            ),
13204            "small labels can be warmed without risking a frame-budget spike"
13205        );
13206    }
13207
13208    #[cfg(not(target_arch = "wasm32"))]
13209    #[test]
13210    fn offscreen_text_prewarm_skips_large_cached_runs_without_quads() {
13211        assert!(
13212            !offscreen_text_glyph_prewarm_work_is_bounded(
13213                Some(MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CACHED_GLYPHS + 1),
13214                0,
13215            ),
13216            "cached glyph placements can still be too large to prepare during input frames"
13217        );
13218    }
13219
13220    #[cfg(not(target_arch = "wasm32"))]
13221    #[test]
13222    fn offscreen_text_prewarm_stops_after_candidate_budget() {
13223        assert!(
13224            offscreen_text_glyph_prewarm_budget_exhausted(
13225                Instant::now(),
13226                MAX_OFFSCREEN_TEXT_GLYPH_PREWARM_CANDIDATES,
13227            ),
13228            "prewarm must be bounded by candidate count even when each candidate is cheap"
13229        );
13230    }
13231
13232    #[test]
13233    fn clipped_cached_glyph_quads_are_filtered_to_viewport() {
13234        fn quad(y: i32) -> CachedTextGlyphQuad {
13235            CachedTextGlyphQuad {
13236                x: 8,
13237                y,
13238                width: 20,
13239                height: 10,
13240                color: (1.0, 1.0, 1.0, 1.0),
13241                uv: ImageUvRect {
13242                    min: [0.0, 0.0],
13243                    max: [1.0, 1.0],
13244                    sample_bounds: [0.0, 0.0, 1.0, 1.0],
13245                },
13246            }
13247        }
13248
13249        let source = Rect {
13250            x: 0.0,
13251            y: 0.0,
13252            width: 320.0,
13253            height: 400.0,
13254        };
13255        let clip = Some(Rect {
13256            x: 0.0,
13257            y: 0.0,
13258            width: 320.0,
13259            height: 80.0,
13260        });
13261        let viewport = ViewportUniformParams {
13262            width: 320,
13263            height: 80,
13264            offset: [0.0, 0.0],
13265        };
13266
13267        assert!(cached_text_glyph_quad_is_visible_in_viewport(
13268            source,
13269            &quad(40),
13270            clip,
13271            viewport,
13272            1.0,
13273        ));
13274        assert!(
13275            !cached_text_glyph_quad_is_visible_in_viewport(source, &quad(140), clip, viewport, 1.0,),
13276            "glyphs outside the effective clip should not enter the frame command stream"
13277        );
13278    }
13279
13280    #[test]
13281    fn small_scene_range_cache_miss_observes_first_render() {
13282        let key = LayerRasterCacheKey::scene_range(
13283            0xCACE,
13284            Rect {
13285                x: 0.0,
13286                y: 0.0,
13287                width: 120.0,
13288                height: 80.0,
13289            },
13290            (120, 80),
13291            ScaleBucket::from_scale(1.0),
13292        );
13293
13294        assert!(
13295            !first_cache_miss_admission(&key),
13296            "a small scene-range miss should render directly first instead of materializing a tiny one-frame retained target"
13297        );
13298        assert!(
13299            repeated_cache_miss_admission(&key),
13300            "a repeated small scene-range miss is stable enough to materialize into the retained cache"
13301        );
13302    }
13303
13304    #[test]
13305    fn large_scene_range_cache_miss_requires_repeated_stable_key() {
13306        let key = LayerRasterCacheKey::scene_range(
13307            0xCACE,
13308            Rect {
13309                x: 0.0,
13310                y: 0.0,
13311                width: 1200.0,
13312                height: 900.0,
13313            },
13314            (1200, 900),
13315            ScaleBucket::from_scale(1.0),
13316        );
13317
13318        assert!(
13319            !first_cache_miss_admission(&key),
13320            "a large first scene-range miss should render directly instead of materializing a multi-MB one-frame cache entry"
13321        );
13322        assert!(
13323            repeated_cache_miss_admission(&key),
13324            "a repeated scene-range miss is stable enough to materialize into the retained cache"
13325        );
13326    }
13327
13328    #[test]
13329    fn renderer_warmup_frame_is_requested_for_cache_miss_stats_only() {
13330        let stats = gpu_stats::FrameStats::default();
13331        let mut snapshot = stats.snapshot();
13332        assert!(
13333            !frame_stats_need_warmup_frame(&snapshot),
13334            "a clean frame must not keep a static scene redrawing"
13335        );
13336
13337        snapshot.layer_cache_misses = 1;
13338        assert!(frame_stats_need_warmup_frame(&snapshot));
13339        snapshot.layer_cache_misses = 0;
13340
13341        snapshot.shadow_shape_cache_misses = 1;
13342        assert!(frame_stats_need_warmup_frame(&snapshot));
13343        snapshot.shadow_shape_cache_misses = 0;
13344
13345        snapshot.text_image_cache_misses = 1;
13346        assert!(frame_stats_need_warmup_frame(&snapshot));
13347        snapshot.text_image_cache_misses = 0;
13348
13349        snapshot.text_glyph_atlas_misses = 1;
13350        assert!(frame_stats_need_warmup_frame(&snapshot));
13351    }
13352
13353    #[test]
13354    fn renderer_warmup_budget_is_consumed_by_a_repeated_cache_miss() {
13355        let stats = gpu_stats::FrameStats::default();
13356        let mut snapshot = stats.snapshot();
13357        snapshot.layer_cache_misses = 1;
13358        let mut pending_frames = 0;
13359
13360        update_frame_warmup_budget(&mut pending_frames, &snapshot);
13361        assert_eq!(pending_frames, CACHE_MISS_WARMUP_FRAMES);
13362
13363        update_frame_warmup_budget(&mut pending_frames, &snapshot);
13364        assert_eq!(
13365            pending_frames, 0,
13366            "a cache miss during the warmup frame must not replenish its budget"
13367        );
13368    }
13369
13370    #[test]
13371    fn non_scene_layer_surface_cache_miss_admits_first_render() {
13372        let key = LayerRasterCacheKey::new(
13373            Some(77),
13374            0xC0FFEE,
13375            0,
13376            Rect {
13377                x: 0.0,
13378                y: 0.0,
13379                width: 120.0,
13380                height: 80.0,
13381            },
13382            (120, 80),
13383            ScaleBucket::from_scale(1.0),
13384        );
13385
13386        assert!(
13387            first_cache_miss_admission(&key),
13388            "ordinary retained layer surfaces should still cache on first miss"
13389        );
13390    }
13391
13392    #[test]
13393    fn text_image_cache_key_is_content_addressed_not_node_addressed() {
13394        let first = test_text_draw(
13395            Rect {
13396                x: 12.25,
13397                y: 40.75,
13398                width: 220.0,
13399                height: 24.0,
13400            },
13401            TextMotion::Static,
13402        );
13403        let mut second = first.clone();
13404        second.node_id = first.node_id + 1;
13405
13406        let first_key = GpuRenderer::text_image_cache_key(&first, first.rect, 1.0, true);
13407        let second_key = GpuRenderer::text_image_cache_key(&second, second.rect, 1.0, true);
13408
13409        assert_eq!(
13410            first_key, second_key,
13411            "text raster cache keys must be based on rendered pixels, not node identity"
13412        );
13413    }
13414
13415    #[test]
13416    fn animated_text_image_cache_key_keeps_fractional_phase_only() {
13417        let base = test_text_draw(
13418            Rect {
13419                x: 12.25,
13420                y: 40.75,
13421                width: 220.0,
13422                height: 24.0,
13423            },
13424            TextMotion::Animated,
13425        );
13426        let integer_translated = test_text_draw(
13427            Rect {
13428                x: 44.25,
13429                y: 88.75,
13430                width: 220.0,
13431                height: 24.0,
13432            },
13433            TextMotion::Animated,
13434        );
13435        let phase_shifted = test_text_draw(
13436            Rect {
13437                x: 44.5,
13438                y: 88.75,
13439                width: 220.0,
13440                height: 24.0,
13441            },
13442            TextMotion::Animated,
13443        );
13444
13445        let base_key = GpuRenderer::text_image_cache_key(&base, base.rect, 1.0, false);
13446        let translated_key = GpuRenderer::text_image_cache_key(
13447            &integer_translated,
13448            integer_translated.rect,
13449            1.0,
13450            false,
13451        );
13452        let phase_shifted_key =
13453            GpuRenderer::text_image_cache_key(&phase_shifted, phase_shifted.rect, 1.0, false);
13454
13455        assert_eq!(
13456            base_key, translated_key,
13457            "integer translation should not invalidate animated text raster cache entries"
13458        );
13459        assert_ne!(
13460            base_key, phase_shifted_key,
13461            "fractional phase affects animated text rasterization and must stay in the key"
13462        );
13463    }
13464
13465    #[test]
13466    fn animated_translated_text_raster_geometry_applies_snap_anchor() {
13467        let mut base = test_text_draw(
13468            Rect {
13469                x: 14.25,
13470                y: 16.50,
13471                width: 220.0,
13472                height: 24.0,
13473            },
13474            TextMotion::Animated,
13475        );
13476        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.50)));
13477
13478        let mut scrolled = test_text_draw(
13479            Rect {
13480                x: 14.25,
13481                y: 15.80,
13482                width: 220.0,
13483                height: 24.0,
13484            },
13485            TextMotion::Animated,
13486        );
13487        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 15.80)));
13488
13489        let (base_logical, base_raster, _, _, base_static) =
13490            text_raster_geometry_for_draw(&base, 1.0).expect("base text geometry");
13491        let (scrolled_logical, scrolled_raster, _, _, scrolled_static) =
13492            text_raster_geometry_for_draw(&scrolled, 1.0).expect("scrolled text geometry");
13493
13494        assert!(!base_static);
13495        assert!(!scrolled_static);
13496        assert!((base_logical.x - 14.0).abs() < f32::EPSILON);
13497        assert!((base_logical.y - 17.0).abs() < f32::EPSILON);
13498        assert!((scrolled_logical.x - 14.0).abs() < f32::EPSILON);
13499        assert!((scrolled_logical.y - 16.0).abs() < f32::EPSILON);
13500        assert_eq!(base_raster.x.fract(), 0.0);
13501        assert_eq!(base_raster.y.fract(), 0.0);
13502        assert_eq!(scrolled_raster.x.fract(), 0.0);
13503        assert_eq!(scrolled_raster.y.fract(), 0.0);
13504
13505        let base_key = GpuRenderer::text_image_cache_key(&base, base_raster, 1.0, false);
13506        let scrolled_key =
13507            GpuRenderer::text_image_cache_key(&scrolled, scrolled_raster, 1.0, false);
13508        assert_eq!(
13509            base_key, scrolled_key,
13510            "translated animated text should keep a stable raster phase while scrolling"
13511        );
13512    }
13513
13514    #[test]
13515    fn translated_static_text_moves_one_device_pixel_at_half_pixel_phase() {
13516        let root_scale = 1.25;
13517        let mut base = test_text_draw(
13518            Rect {
13519                x: 14.0,
13520                y: 276.0,
13521                width: 220.0,
13522                height: 24.0,
13523            },
13524            TextMotion::Static,
13525        );
13526        base.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.600_006)));
13527
13528        let mut scrolled = test_text_draw(
13529            Rect {
13530                x: 14.0,
13531                y: 275.2,
13532                width: 220.0,
13533                height: 24.0,
13534            },
13535            TextMotion::Static,
13536        );
13537        scrolled.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 126.799_99)));
13538
13539        let (_, base_raster, _, _, _) =
13540            text_raster_geometry_for_draw(&base, root_scale).expect("base text geometry");
13541        let (_, scrolled_raster, _, _, _) =
13542            text_raster_geometry_for_draw(&scrolled, root_scale).expect("scrolled text geometry");
13543
13544        assert_eq!(
13545            base_raster.y - scrolled_raster.y,
13546            1.0,
13547            "one physical pixel of rigid scrolling must move static text by one raster pixel"
13548        );
13549    }
13550
13551    #[test]
13552    fn translated_text_snap_does_not_move_its_fixed_ancestor_clip() {
13553        let root_scale = 1.25;
13554        let fixed_clip = Rect {
13555            x: 8.0,
13556            y: 20.0,
13557            width: 300.0,
13558            height: 680.0,
13559        };
13560        let mut draw = test_text_draw(
13561            Rect {
13562                x: 14.0,
13563                y: 276.0,
13564                width: 220.0,
13565                height: 24.0,
13566            },
13567            TextMotion::Static,
13568        );
13569        draw.snap_anchor = Some(SnapAnchor::rigid(Point::new(0.0, 127.4)));
13570        draw.clip = Some(fixed_clip);
13571
13572        let (_, _, clip, _, _) =
13573            text_raster_geometry_for_draw(&draw, root_scale).expect("clipped text geometry");
13574
13575        assert_eq!(
13576            clip,
13577            Some(fixed_clip),
13578            "content pixel snapping must not translate a fixed ancestor clip"
13579        );
13580    }
13581
13582    #[test]
13583    fn clipped_static_multiline_text_raster_source_limits_visible_line_window() {
13584        let rect = Rect {
13585            x: 8.0,
13586            y: 100.0,
13587            width: 240.0,
13588            height: 1_000.0,
13589        };
13590        let mut draw = test_text_draw(rect, TextMotion::Static);
13591        let lines = (0..100)
13592            .map(|line| format!("line-{line:03}"))
13593            .collect::<Vec<_>>()
13594            .join("\n");
13595        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
13596
13597        let raster_rect = Rect {
13598            x: 16.0,
13599            y: 200.0,
13600            width: 480.0,
13601            height: 2_000.0,
13602        };
13603        let source = clipped_text_raster_source(
13604            &draw,
13605            rect,
13606            raster_rect,
13607            Some(Rect {
13608                x: 0.0,
13609                y: 610.0,
13610                width: 800.0,
13611                height: 40.0,
13612            }),
13613            2.0,
13614            true,
13615        );
13616
13617        let Cow::Owned(sliced_draw) = source.draw else {
13618            panic!("clipped static multiline text should rasterize only the visible line window");
13619        };
13620        let sliced_text = sliced_draw.text.text.as_str();
13621        assert!(sliced_text.contains("line-050"));
13622        assert!(sliced_text.contains("line-055"));
13623        assert!(!sliced_text.contains("line-000"));
13624        assert!(!sliced_text.contains("line-099"));
13625        assert_eq!(source.raster_rect.x, raster_rect.x);
13626        assert!(source.raster_rect.y > raster_rect.y);
13627        assert!(source.raster_rect.height < raster_rect.height);
13628    }
13629
13630    #[test]
13631    fn clipped_static_multiline_text_raster_source_slices_short_multiline_text() {
13632        let rect = Rect {
13633            x: 8.0,
13634            y: 100.0,
13635            width: 240.0,
13636            height: 320.0,
13637        };
13638        let mut draw = test_text_draw(rect, TextMotion::Static);
13639        let lines = (0..24)
13640            .map(|line| format!("code-line-{line:02}"))
13641            .collect::<Vec<_>>()
13642            .join("\n");
13643        draw.text = Arc::new(AnnotatedString::from(lines).render_string());
13644
13645        let raster_rect = Rect {
13646            x: 16.0,
13647            y: 200.0,
13648            width: 480.0,
13649            height: 640.0,
13650        };
13651        let source = clipped_text_raster_source(
13652            &draw,
13653            rect,
13654            raster_rect,
13655            Some(Rect {
13656                x: 0.0,
13657                y: 190.0,
13658                width: 800.0,
13659                height: 120.0,
13660            }),
13661            2.0,
13662            true,
13663        );
13664
13665        let Cow::Owned(sliced_draw) = source.draw else {
13666            panic!("clipped multiline text should rasterize only the visible line window");
13667        };
13668        assert!(sliced_draw.text.text.as_str().contains("code-line-06"));
13669        assert!(!sliced_draw.text.text.as_str().contains("code-line-00"));
13670        assert!(!sliced_draw.text.text.as_str().contains("code-line-23"));
13671        assert_eq!(source.raster_rect.x, raster_rect.x);
13672        assert!(source.raster_rect.y > raster_rect.y);
13673        assert!(source.raster_rect.height < raster_rect.height);
13674    }
13675
13676    #[test]
13677    fn text_line_index_cache_reuses_retained_index_for_same_text_instance() {
13678        let mut cache = TextLineIndexCache::new(4);
13679        let text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
13680
13681        let first = cache.line_starts(&text);
13682        let second = cache.line_starts(&text);
13683
13684        assert_eq!(first.as_ref(), &[0, 2, 4]);
13685        assert!(
13686            Rc::ptr_eq(&first, &second),
13687            "retained text should not rebuild its line index on every clipped frame"
13688        );
13689    }
13690
13691    #[test]
13692    fn text_line_index_cache_is_retained_text_instance_local() {
13693        let mut cache = TextLineIndexCache::new(4);
13694        let first_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
13695        let second_text = Arc::new(AnnotatedString::from("a\nb\nc").render_string());
13696
13697        let first = cache.line_starts(&first_text);
13698        let second = cache.line_starts(&second_text);
13699
13700        assert_eq!(first.as_ref(), second.as_ref());
13701        assert!(
13702            !Rc::ptr_eq(&first, &second),
13703            "line index lookup should not hash large text contents to find unrelated retained nodes"
13704        );
13705    }
13706
13707    #[test]
13708    fn device_pixel_bounds_for_rect_snaps_origin_and_extents() {
13709        let bounds = device_pixel_bounds_for_rect(
13710            Rect {
13711                x: 10.25,
13712                y: 14.6,
13713                width: 20.1,
13714                height: 9.2,
13715            },
13716            200,
13717            120,
13718            2.0,
13719        )
13720        .expect("rect should intersect the viewport");
13721
13722        assert_eq!(
13723            bounds,
13724            DevicePixelBounds {
13725                x: 20.0,
13726                y: 29.0,
13727                width: 41,
13728                height: 19,
13729            }
13730        );
13731    }
13732
13733    #[test]
13734    fn visible_layer_rect_intersects_clip_and_viewport() {
13735        let visible = visible_layer_rect(
13736            Rect {
13737                x: -10.0,
13738                y: 5.0,
13739                width: 80.0,
13740                height: 40.0,
13741            },
13742            Some(Rect {
13743                x: 4.0,
13744                y: 8.0,
13745                width: 20.0,
13746                height: 50.0,
13747            }),
13748            2.0,
13749            60,
13750            40,
13751        )
13752        .expect("visible rect");
13753
13754        assert_eq!(
13755            visible,
13756            Rect {
13757                x: 4.0,
13758                y: 8.0,
13759                width: 20.0,
13760                height: 12.0,
13761            }
13762        );
13763    }
13764
13765    #[test]
13766    fn clamp_effect_surface_scale_caps_large_surfaces_but_keeps_base_scale() {
13767        let clamped = clamp_effect_surface_scale(
13768            Rect {
13769                x: 0.0,
13770                y: 0.0,
13771                width: 1200.0,
13772                height: 900.0,
13773            },
13774            1.0,
13775            8.0,
13776            16_384,
13777        );
13778
13779        assert!(
13780            clamped < 8.0,
13781            "large translated effect layers must be capped to avoid OOM, got {clamped}"
13782        );
13783        assert!(
13784            clamped >= 1.0,
13785            "effect surfaces must not fall below destination resolution, got {clamped}"
13786        );
13787    }
13788
13789    #[test]
13790    fn clamp_effect_surface_scale_keeps_decorated_text_capture_scale() {
13791        let clamped = clamp_effect_surface_scale(
13792            Rect {
13793                x: 0.0,
13794                y: 0.0,
13795                width: 446.0,
13796                height: 44.0,
13797            },
13798            1.0,
13799            9.0,
13800            16_384,
13801        );
13802
13803        assert_eq!(
13804            clamped, 9.0,
13805            "decorated text motion-stable captures must keep full scale"
13806        );
13807    }
13808
13809    fn backdrop_layer(z_index: usize) -> BackdropLayer {
13810        BackdropLayer {
13811            node_id: Some(700 + z_index),
13812            rect: Rect {
13813                x: 0.0,
13814                y: 0.0,
13815                width: 10.0,
13816                height: 10.0,
13817            },
13818            clip: None,
13819            snap_anchor: None,
13820            effect: RenderEffect::blur(2.0),
13821            z_index,
13822        }
13823    }
13824
13825    fn test_shape(z_index: usize, blend_mode: BlendMode) -> DrawShape {
13826        DrawShape {
13827            rect: Rect {
13828                x: 0.0,
13829                y: 0.0,
13830                width: 8.0,
13831                height: 8.0,
13832            },
13833            local_rect: Rect {
13834                x: 0.0,
13835                y: 0.0,
13836                width: 8.0,
13837                height: 8.0,
13838            },
13839            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
13840            snap_anchor: None,
13841            brush: Brush::solid(Color::BLACK),
13842            shape: None,
13843            stroke: None,
13844            arc: None,
13845            z_index,
13846            clip: None,
13847            blend_mode,
13848            motion_context_animated: false,
13849        }
13850    }
13851
13852    #[test]
13853    fn shape_shadow_content_hash_ignores_viewport_translation() {
13854        fn translate_shape(shape: &DrawShape, dx: f32, dy: f32) -> DrawShape {
13855            let mut translated = shape.clone();
13856            translated.rect.x += dx;
13857            translated.rect.y += dy;
13858            translated.local_rect.x += dx;
13859            translated.local_rect.y += dy;
13860            for point in &mut translated.quad {
13861                point[0] += dx;
13862                point[1] += dy;
13863            }
13864            translated.snap_anchor = translated.snap_anchor.map(|anchor| {
13865                SnapAnchor::rigid(Point::new(anchor.origin.x + dx, anchor.origin.y + dy))
13866            });
13867            translated.clip = translated.clip.map(|mut clip| {
13868                clip.x += dx;
13869                clip.y += dy;
13870                clip
13871            });
13872            translated
13873        }
13874
13875        let mut first = test_shape(1, BlendMode::SrcOver);
13876        first.rect = Rect {
13877            x: 10.0,
13878            y: 20.0,
13879            width: 80.0,
13880            height: 40.0,
13881        };
13882        first.local_rect = first.rect;
13883        first.quad = [[10.0, 20.0], [90.0, 20.0], [10.0, 60.0], [90.0, 60.0]];
13884        first.snap_anchor = Some(SnapAnchor::rigid(Point::new(7.0, 11.0)));
13885        first.shape = Some(RoundedCornerShape::uniform(8.0));
13886        first.clip = Some(Rect {
13887            x: 8.0,
13888            y: 18.0,
13889            width: 86.0,
13890            height: 44.0,
13891        });
13892        let mut cutout = test_shape(2, BlendMode::DstOut);
13893        cutout.rect = Rect {
13894            x: 18.0,
13895            y: 26.0,
13896            width: 62.0,
13897            height: 22.0,
13898        };
13899        cutout.local_rect = cutout.rect;
13900        cutout.quad = [[18.0, 26.0], [80.0, 26.0], [18.0, 48.0], [80.0, 48.0]];
13901        cutout.shape = Some(RoundedCornerShape::uniform(4.0));
13902
13903        let dx = 37.0;
13904        let dy = -11.5;
13905        let translated = translate_shape(&first, dx, dy);
13906        let translated_cutout = translate_shape(&cutout, dx, dy);
13907
13908        let root_scale = 1.25;
13909        let first_shapes = vec![
13910            (first.clone(), BlendMode::SrcOver),
13911            (cutout, BlendMode::DstOut),
13912        ];
13913        let translated_shapes = vec![
13914            (translated.clone(), BlendMode::SrcOver),
13915            (translated_cutout, BlendMode::DstOut),
13916        ];
13917
13918        let first_hash = shape_shadow_content_hash(&first_shapes, root_scale);
13919        let translated_hash = shape_shadow_content_hash(&translated_shapes, root_scale);
13920
13921        assert_eq!(first_hash, translated_hash);
13922
13923        let mut changed_shapes = translated_shapes;
13924        changed_shapes[0].0.rect.width += 1.0;
13925        let changed_hash = shape_shadow_content_hash(&changed_shapes, root_scale);
13926
13927        assert_ne!(first_hash, changed_hash);
13928    }
13929
13930    #[test]
13931    fn shape_shadow_content_hash_is_stable_under_fractional_scale_scroll() {
13932        // Regression: scrolling a shadowed panel on a fractional-scale display
13933        // (e.g. Xft.dpi 130 → scale ≈ 1.354) must not re-render the shadow blur
13934        // every frame. The production cache key derives its viewport offset from
13935        // FLOORED device-pixel bounds, so the residual subpixel phase used to leak
13936        // into the content hash and miss the cache on every scroll step.
13937        fn shadow_shapes_at(y: f32) -> Vec<(DrawShape, BlendMode)> {
13938            let mut shape = test_shape(1, BlendMode::SrcOver);
13939            shape.rect = Rect {
13940                x: 24.0,
13941                y,
13942                width: 180.0,
13943                height: 90.0,
13944            };
13945            shape.local_rect = shape.rect;
13946            shape.quad = crate::rect_to_quad(shape.rect);
13947            shape.shape = Some(RoundedCornerShape::uniform(14.0));
13948            vec![(shape, BlendMode::SrcOver)]
13949        }
13950
13951        let root_scale = 130.0f32 / 96.0;
13952        let blur_radius = 18.0f32;
13953        let pixel_radius = blur_radius * root_scale;
13954
13955        let key_at = |y: f32| {
13956            let shapes = shadow_shapes_at(y);
13957            let plan =
13958                shape_shadow_surface_plan(&shapes, None, blur_radius, 1600, 1600, root_scale, 8192)
13959                    .expect("surface plan");
13960            shape_shadow_surface_cache_key(
13961                &shapes,
13962                plan.source_device_bounds,
13963                pixel_radius,
13964                root_scale,
13965            )
13966            .expect("cache key")
13967        };
13968
13969        // Wheel scroll translates the panel by whole logical pixels; the device
13970        // subpixel phase changes on every step at fractional scale. The whole
13971        // cache key (content hash AND surface pixel size) must stay stable, or
13972        // every scroll frame re-renders the shadow blur.
13973        let base = key_at(640.0);
13974        for step in 1..=12 {
13975            let scrolled = key_at(640.0 - step as f32 * 4.0);
13976            assert_eq!(
13977                base, scrolled,
13978                "scrolled shadow cache key must stay stable at fractional scale (step {step})"
13979            );
13980        }
13981    }
13982
13983    #[test]
13984    fn shape_shadow_cache_key_uses_unclipped_source_bounds_for_scrolled_clip() {
13985        fn translated_card_shadow(y: f32) -> Vec<(DrawShape, BlendMode)> {
13986            let mut shape = test_shape(1, BlendMode::SrcOver);
13987            shape.rect = Rect {
13988                x: 24.0,
13989                y,
13990                width: 280.0,
13991                height: 120.0,
13992            };
13993            shape.local_rect = shape.rect;
13994            shape.quad = [[24.0, y], [304.0, y], [24.0, y + 120.0], [304.0, y + 120.0]];
13995            shape.shape = Some(RoundedCornerShape::uniform(18.0));
13996            vec![(shape, BlendMode::SrcOver)]
13997        }
13998
13999        let root_scale = 1.0;
14000        let blur_radius = 18.0;
14001        let viewport_clip = Rect {
14002            x: 0.0,
14003            y: 96.0,
14004            width: 360.0,
14005            height: 720.0,
14006        };
14007        let key_for = |y: f32| {
14008            let shapes = translated_card_shadow(y);
14009            let plan = shape_shadow_surface_plan(
14010                &shapes,
14011                Some(viewport_clip),
14012                blur_radius,
14013                360,
14014                900,
14015                root_scale,
14016                4096,
14017            )
14018            .expect("surface plan");
14019            shape_shadow_surface_cache_key(
14020                &shapes,
14021                plan.source_device_bounds,
14022                plan.pixel_radius,
14023                root_scale,
14024            )
14025            .expect("cache key")
14026        };
14027
14028        // The card scrolls under a fixed viewport clip; the visible portion
14029        // changes but the cache key must stay anchored to the unclipped source.
14030        assert_eq!(key_for(740.0), key_for(756.0));
14031    }
14032
14033    #[test]
14034    fn shape_visibility_uses_nonzero_viewport_offset_for_cropped_offscreen() {
14035        let mut shape = test_shape(1, BlendMode::SrcOver);
14036        shape.rect = Rect {
14037            x: 24.0,
14038            y: 740.0,
14039            width: 280.0,
14040            height: 120.0,
14041        };
14042        shape.local_rect = shape.rect;
14043        shape.quad = [[24.0, 740.0], [304.0, 740.0], [24.0, 860.0], [304.0, 860.0]];
14044        let viewport = ViewportUniformParams {
14045            width: 316,
14046            height: 228,
14047            offset: [6.0, 686.0],
14048        };
14049
14050        assert!(shape_draw_is_visible_in_viewport(&shape, viewport, 1.0));
14051    }
14052
14053    #[test]
14054    fn text_prewarm_uses_nonzero_viewport_offset_for_cropped_offscreen() {
14055        let viewport = ViewportUniformParams {
14056            width: 316,
14057            height: 228,
14058            offset: [6.0, 686.0],
14059        };
14060        let text_rect = Rect {
14061            x: 24.0,
14062            y: 740.0,
14063            width: 280.0,
14064            height: 40.0,
14065        };
14066
14067        assert!(text_draw_is_visible_in_viewport(
14068            text_rect, None, viewport, 1.0
14069        ));
14070        assert!(text_draw_should_prewarm_in_viewport(
14071            text_rect, None, viewport, 1.0
14072        ));
14073    }
14074
14075    fn test_shadow_draw(shapes: Vec<(DrawShape, BlendMode)>) -> ShadowDraw {
14076        ShadowDraw {
14077            shapes,
14078            texts: vec![],
14079            blur_radius: 8.0,
14080            clip: None,
14081            z_index: 0,
14082        }
14083    }
14084
14085    fn test_image(z_index: usize, blend_mode: BlendMode) -> ImageDraw {
14086        ImageDraw {
14087            rect: Rect {
14088                x: 0.0,
14089                y: 0.0,
14090                width: 8.0,
14091                height: 8.0,
14092            },
14093            local_rect: Rect {
14094                x: 0.0,
14095                y: 0.0,
14096                width: 8.0,
14097                height: 8.0,
14098            },
14099            quad: [[0.0, 0.0], [8.0, 0.0], [0.0, 8.0], [8.0, 8.0]],
14100            snap_anchor: None,
14101            image: ImageBitmap::from_rgba8(1, 1, vec![255, 255, 255, 255]).expect("image"),
14102            alpha: 1.0,
14103            color_filter: None,
14104            sampling: ImageSampling::Nearest,
14105            z_index,
14106            clip: None,
14107            blend_mode,
14108            src_rect: None,
14109            motion_context_animated: false,
14110        }
14111    }
14112
14113    #[test]
14114    fn image_sampler_descriptors_match_requested_sampling() {
14115        let nearest = image_sampler_descriptor(ImageSampling::Nearest);
14116        assert_eq!(nearest.mag_filter, wgpu::FilterMode::Nearest);
14117        assert_eq!(nearest.min_filter, wgpu::FilterMode::Nearest);
14118
14119        let linear = image_sampler_descriptor(ImageSampling::Linear);
14120        assert_eq!(linear.mag_filter, wgpu::FilterMode::Linear);
14121        assert_eq!(linear.min_filter, wgpu::FilterMode::Linear);
14122    }
14123
14124    #[test]
14125    fn image_uv_rect_clamps_source_rect_to_texel_centers() {
14126        let image = ImageBitmap::from_rgba8(24, 16, vec![0; 24 * 16 * 4]).expect("image");
14127        let uv = image_uv_rect(
14128            &image,
14129            Some(Rect {
14130                x: 0.0,
14131                y: 0.0,
14132                width: 16.0,
14133                height: 16.0,
14134            }),
14135        )
14136        .expect("uv rect");
14137
14138        assert_eq!(uv.min, [0.0, 0.0]);
14139        assert_eq!(uv.max, [16.0 / 24.0, 1.0]);
14140        assert_eq!(
14141            uv.sample_bounds,
14142            [0.5 / 24.0, 0.5 / 16.0, 15.5 / 24.0, 15.5 / 16.0]
14143        );
14144    }
14145
14146    #[test]
14147    fn image_uv_rect_keeps_full_image_unclamped() {
14148        let image = ImageBitmap::from_rgba8(2, 2, vec![0; 16]).expect("image");
14149        let uv = image_uv_rect(&image, None).expect("uv rect");
14150
14151        assert_eq!(uv.min, [0.0, 0.0]);
14152        assert_eq!(uv.max, [1.0, 1.0]);
14153        assert_eq!(uv.sample_bounds, [0.0, 0.0, 1.0, 1.0]);
14154    }
14155
14156    fn test_text(z_index: usize) -> TextDraw {
14157        TextDraw {
14158            node_id: 0,
14159            rect: Rect {
14160                x: 0.0,
14161                y: 0.0,
14162                width: 8.0,
14163                height: 8.0,
14164            },
14165            snap_anchor: None,
14166            translated_content_context: false,
14167            text: Arc::new(cranpose_ui::text::AnnotatedString::from("t").render_string()),
14168            color: Color::WHITE,
14169            text_style: cranpose_ui::TextStyle::default(),
14170            font_size: 12.0,
14171            scale: 1.0,
14172            layout_options: cranpose_ui::TextLayoutOptions::default(),
14173            z_index,
14174            clip: None,
14175        }
14176    }
14177
14178    #[test]
14179    fn text_draw_visibility_rejects_text_outside_clip_before_rasterization() {
14180        let viewport = ViewportUniformParams {
14181            width: 320,
14182            height: 240,
14183            offset: [0.0, 0.0],
14184        };
14185        let text_rect = Rect {
14186            x: 0.0,
14187            y: 260.0,
14188            width: 200.0,
14189            height: 40.0,
14190        };
14191        let clip = Some(Rect {
14192            x: 0.0,
14193            y: 0.0,
14194            width: 320.0,
14195            height: 200.0,
14196        });
14197
14198        assert!(
14199            !text_draw_is_visible_in_viewport(text_rect, clip, viewport, 1.0),
14200            "lazy-list beyond-bound text outside the clip must not be rasterized"
14201        );
14202    }
14203
14204    #[test]
14205    fn text_draw_prewarm_accepts_clipped_text_near_viewport() {
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: 260.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_is_visible_in_viewport(
14225            text_rect, clip, viewport, 1.0
14226        ));
14227        assert!(text_draw_should_prewarm_in_viewport(
14228            text_rect, clip, viewport, 1.0
14229        ));
14230    }
14231
14232    #[test]
14233    fn text_draw_prewarm_rejects_far_clipped_text() {
14234        let viewport = ViewportUniformParams {
14235            width: 320,
14236            height: 240,
14237            offset: [0.0, 0.0],
14238        };
14239        let text_rect = Rect {
14240            x: 0.0,
14241            y: 1600.0,
14242            width: 200.0,
14243            height: 40.0,
14244        };
14245        let clip = Some(Rect {
14246            x: 0.0,
14247            y: 0.0,
14248            width: 320.0,
14249            height: 200.0,
14250        });
14251
14252        assert!(!text_draw_should_prewarm_in_viewport(
14253            text_rect, clip, viewport, 1.0
14254        ));
14255    }
14256
14257    #[test]
14258    fn text_draw_visibility_rejects_unclipped_text_outside_viewport() {
14259        let viewport = ViewportUniformParams {
14260            width: 320,
14261            height: 240,
14262            offset: [0.0, 0.0],
14263        };
14264        let text_rect = Rect {
14265            x: 0.0,
14266            y: 241.0,
14267            width: 200.0,
14268            height: 40.0,
14269        };
14270
14271        assert!(
14272            !text_draw_is_visible_in_viewport(text_rect, None, viewport, 1.0),
14273            "unclipped text outside the target viewport must not be rasterized"
14274        );
14275    }
14276
14277    #[test]
14278    fn text_draw_visibility_keeps_partially_visible_text() {
14279        let viewport = ViewportUniformParams {
14280            width: 320,
14281            height: 240,
14282            offset: [0.0, 0.0],
14283        };
14284        let text_rect = Rect {
14285            x: 0.0,
14286            y: 220.0,
14287            width: 200.0,
14288            height: 40.0,
14289        };
14290
14291        assert!(text_draw_is_visible_in_viewport(
14292            text_rect, None, viewport, 1.0
14293        ));
14294    }
14295
14296    fn test_draw_ops(
14297        shapes: &[DrawShape],
14298        images: &[ImageDraw],
14299        texts: &[TextDraw],
14300        shadows: &[ShadowDraw],
14301    ) -> Vec<DrawOp> {
14302        let mut ops = Vec::new();
14303        ops.extend(shapes.iter().enumerate().map(|(index, shape)| DrawOp {
14304            z_index: shape.z_index,
14305            kind: DrawOpKind::Shape(index),
14306        }));
14307        ops.extend(images.iter().enumerate().map(|(index, image)| DrawOp {
14308            z_index: image.z_index,
14309            kind: DrawOpKind::Image(index),
14310        }));
14311        ops.extend(texts.iter().enumerate().map(|(index, text)| DrawOp {
14312            z_index: text.z_index,
14313            kind: DrawOpKind::Text(index),
14314        }));
14315        ops.extend(shadows.iter().enumerate().map(|(index, shadow)| DrawOp {
14316            z_index: shadow.z_index,
14317            kind: DrawOpKind::Shadow(index),
14318        }));
14319        ops.sort_by_key(|op| op.z_index);
14320        ops
14321    }
14322
14323    fn test_layer(local_bounds: Rect, children: Vec<RenderNode>) -> LayerNode {
14324        crate::test_support::layer_node(
14325            local_bounds,
14326            ProjectiveTransform::identity(),
14327            GraphicsLayer::default(),
14328            children,
14329        )
14330    }
14331
14332    fn cacheable_layer(
14333        node_id: cranpose_core::NodeId,
14334        local_bounds: Rect,
14335        children: Vec<RenderNode>,
14336    ) -> LayerNode {
14337        let mut layer = test_layer(local_bounds, children);
14338        layer.node_id = Some(node_id);
14339        layer.cache_policy = cranpose_render_common::graph::CachePolicy::Auto;
14340        layer.recompute_raster_cache_hashes();
14341        layer
14342    }
14343
14344    fn text_layer_with_style(text: AnnotatedString, text_style: TextStyle) -> LayerNode {
14345        test_layer(
14346            Rect {
14347                x: 0.0,
14348                y: 0.0,
14349                width: 64.0,
14350                height: 32.0,
14351            },
14352            vec![RenderNode::Primitive(PrimitiveEntry {
14353                phase: PrimitivePhase::BeforeChildren,
14354                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
14355                    node_id: 1,
14356                    rect: Rect {
14357                        x: 2.0,
14358                        y: 3.0,
14359                        width: 48.0,
14360                        height: 18.0,
14361                    },
14362                    text: std::rc::Rc::new(text),
14363                    text_style,
14364                    font_size: 14.0,
14365                    layout_options: TextLayoutOptions::default(),
14366                    clip: None,
14367                })),
14368            })],
14369        )
14370    }
14371
14372    fn snapped_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
14373        LayerNode {
14374            node_id: Some(77),
14375            local_bounds: Rect {
14376                x: 0.0,
14377                y: 0.0,
14378                width: 48.0,
14379                height: 24.0,
14380            },
14381            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
14382            motion_context_animated: animated,
14383            translated_content_context,
14384            translated_content_offset: Point::default(),
14385            content_offset: Point::default(),
14386            scene_children_origin: cranpose_ui_graphics::Point::default(),
14387            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
14388            graphics_layer: GraphicsLayer::default(),
14389            clip_to_bounds: false,
14390            shadow_clip: None,
14391            hit_test: None,
14392            has_hit_targets: false,
14393            isolation: IsolationReasons::default(),
14394            cache_policy: CachePolicy::None,
14395            cache_hashes: LayerRasterCacheHashes::default(),
14396            cache_hashes_valid: false,
14397            children: vec![
14398                RenderNode::Primitive(PrimitiveEntry {
14399                    phase: PrimitivePhase::BeforeChildren,
14400                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
14401                        primitive: DrawPrimitive::RoundRect {
14402                            rect: Rect {
14403                                x: 0.0,
14404                                y: 0.0,
14405                                width: 48.0,
14406                                height: 24.0,
14407                            },
14408                            brush: Brush::solid(Color(0.28, 0.30, 0.46, 0.88)),
14409                            radii: CornerRadii::uniform(6.0),
14410                            stroke: None,
14411                        },
14412                        clip: None,
14413                    }),
14414                }),
14415                RenderNode::Primitive(PrimitiveEntry {
14416                    phase: PrimitivePhase::BeforeChildren,
14417                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
14418                        primitive: DrawPrimitive::Image {
14419                            rect: Rect {
14420                                x: 2.0,
14421                                y: 2.0,
14422                                width: 12.0,
14423                                height: 12.0,
14424                            },
14425                            image: ImageBitmap::from_rgba8(
14426                                2,
14427                                2,
14428                                vec![
14429                                    255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255,
14430                                    255,
14431                                ],
14432                            )
14433                            .expect("image"),
14434                            alpha: 1.0,
14435                            color_filter: None,
14436                            sampling: ImageSampling::Linear,
14437                            src_rect: None,
14438                        },
14439                        clip: None,
14440                    }),
14441                }),
14442                RenderNode::Primitive(PrimitiveEntry {
14443                    phase: PrimitivePhase::BeforeChildren,
14444                    node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
14445                        node_id: 77,
14446                        rect: Rect {
14447                            x: 6.0,
14448                            y: 4.0,
14449                            width: 36.0,
14450                            height: 16.0,
14451                        },
14452                        text: std::rc::Rc::new(AnnotatedString::from("48 px")),
14453                        text_style: TextStyle::default(),
14454                        font_size: 14.0,
14455                        layout_options: TextLayoutOptions::default(),
14456                        clip: None,
14457                    })),
14458                }),
14459            ],
14460        }
14461    }
14462
14463    fn snapped_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
14464        let text_leaf = snapped_text_leaf(animated, translated_content_context);
14465        test_layer(
14466            Rect {
14467                x: 0.0,
14468                y: 0.0,
14469                width: 96.0,
14470                height: 64.0,
14471            },
14472            vec![RenderNode::Layer(Box::new(text_leaf))],
14473        )
14474    }
14475
14476    fn translated_content_local_surface_root() -> LayerNode {
14477        let mut effectful_text = text_layer_with_style(
14478            AnnotatedString::from("shadow"),
14479            TextStyle::from_span_style(SpanStyle {
14480                shadow: Some(Shadow {
14481                    color: Color::BLACK,
14482                    offset: Point::new(1.0, 2.0),
14483                    blur_radius: 3.0,
14484                }),
14485                ..SpanStyle::default()
14486            }),
14487        );
14488        effectful_text.translated_content_context = true;
14489
14490        let translated_content = LayerNode {
14491            node_id: Some(78),
14492            local_bounds: Rect {
14493                x: 0.0,
14494                y: 0.0,
14495                width: 96.0,
14496                height: 64.0,
14497            },
14498            transform_to_parent: ProjectiveTransform::translation(14.25, 16.5),
14499            motion_context_animated: false,
14500            translated_content_context: true,
14501            translated_content_offset: Point::default(),
14502            content_offset: Point::default(),
14503            scene_children_origin: cranpose_ui_graphics::Point::default(),
14504            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
14505            graphics_layer: GraphicsLayer::default(),
14506            clip_to_bounds: false,
14507            shadow_clip: None,
14508            hit_test: None,
14509            has_hit_targets: false,
14510            isolation: IsolationReasons::default(),
14511            cache_policy: CachePolicy::None,
14512            cache_hashes: LayerRasterCacheHashes::default(),
14513            cache_hashes_valid: false,
14514            children: vec![RenderNode::Layer(Box::new(effectful_text))],
14515        };
14516
14517        test_layer(
14518            Rect {
14519                x: 0.0,
14520                y: 0.0,
14521                width: 160.0,
14522                height: 120.0,
14523            },
14524            vec![RenderNode::Layer(Box::new(translated_content))],
14525        )
14526    }
14527
14528    #[test]
14529    fn scissor_rect_for_layer_intersects_with_clip() {
14530        let rect = Rect {
14531            x: 10.0,
14532            y: 10.0,
14533            width: 30.0,
14534            height: 20.0,
14535        };
14536        let clip = Rect {
14537            x: 20.0,
14538            y: 15.0,
14539            width: 100.0,
14540            height: 100.0,
14541        };
14542
14543        let scissor = scissor_rect_for_layer(rect, Some(clip), 1.0, 200, 200);
14544        assert_eq!(scissor, Some((20, 15, 20, 15)));
14545    }
14546
14547    #[test]
14548    fn visible_draw_rect_no_clip_returns_original() {
14549        let rect = Rect {
14550            x: 100.0,
14551            y: 200.0,
14552            width: 300.0,
14553            height: 400.0,
14554        };
14555        assert_eq!(visible_draw_rect(rect, None), Some(rect));
14556    }
14557
14558    #[test]
14559    fn visible_draw_rect_with_clip_intersects() {
14560        let rect = Rect {
14561            x: 0.0,
14562            y: 0.0,
14563            width: 2000.0,
14564            height: 5000.0,
14565        };
14566        let clip = Rect {
14567            x: 0.0,
14568            y: 0.0,
14569            width: 800.0,
14570            height: 600.0,
14571        };
14572        let visible = visible_draw_rect(rect, Some(clip)).expect("should have visible area");
14573        assert_eq!(visible.width, 800.0);
14574        assert_eq!(visible.height, 600.0);
14575    }
14576
14577    #[test]
14578    fn visible_draw_rect_fully_clipped_returns_none() {
14579        let rect = Rect {
14580            x: 1000.0,
14581            y: 1000.0,
14582            width: 200.0,
14583            height: 200.0,
14584        };
14585        let clip = Rect {
14586            x: 0.0,
14587            y: 0.0,
14588            width: 800.0,
14589            height: 600.0,
14590        };
14591        assert!(visible_draw_rect(rect, Some(clip)).is_none());
14592    }
14593
14594    #[test]
14595    fn scene_bounds_respects_clip_on_shapes() {
14596        let mut scene = CompositorScene::new();
14597        // Shape inside viewport — visible
14598        scene.shapes.push(DrawShape {
14599            rect: Rect {
14600                x: 10.0,
14601                y: 10.0,
14602                width: 100.0,
14603                height: 50.0,
14604            },
14605            clip: Some(Rect {
14606                x: 0.0,
14607                y: 0.0,
14608                width: 800.0,
14609                height: 600.0,
14610            }),
14611            ..test_shape(0, BlendMode::SrcOver)
14612        });
14613        // Shape far outside viewport — clipped away entirely
14614        scene.shapes.push(DrawShape {
14615            rect: Rect {
14616                x: 0.0,
14617                y: 3000.0,
14618                width: 100.0,
14619                height: 50.0,
14620            },
14621            clip: Some(Rect {
14622                x: 0.0,
14623                y: 0.0,
14624                width: 800.0,
14625                height: 600.0,
14626            }),
14627            ..test_shape(1, BlendMode::SrcOver)
14628        });
14629        let bounds = scene_bounds(&scene).expect("should have bounds");
14630        // Bounds should only cover the first shape's visible area,
14631        // NOT extend to y=3050 from the clipped second shape.
14632        assert!(bounds.y + bounds.height <= 600.0);
14633    }
14634
14635    #[test]
14636    fn scene_bounds_scroll_content_clipped_to_viewport() {
14637        // Simulates a scroll container: many items with large y offsets,
14638        // all clipped to a viewport-sized clip rect.
14639        let mut scene = CompositorScene::new();
14640        let viewport_clip = Rect {
14641            x: 0.0,
14642            y: 0.0,
14643            width: 800.0,
14644            height: 600.0,
14645        };
14646        for i in 0..20 {
14647            scene.shapes.push(DrawShape {
14648                rect: Rect {
14649                    x: 0.0,
14650                    y: i as f32 * 300.0,
14651                    width: 800.0,
14652                    height: 200.0,
14653                },
14654                clip: Some(viewport_clip),
14655                ..test_shape(i, BlendMode::SrcOver)
14656            });
14657        }
14658        let bounds = scene_bounds(&scene).expect("should have bounds");
14659        // All shapes are clipped to viewport — bounds should be viewport-sized,
14660        // NOT 20*300 = 6000 dp tall.
14661        assert_eq!(bounds.x, 0.0);
14662        assert_eq!(bounds.y, 0.0);
14663        assert!(bounds.width <= 800.0);
14664        assert!(bounds.height <= 600.0);
14665    }
14666
14667    #[test]
14668    fn scene_bounds_stable_across_scroll_offsets() {
14669        // Simulates horizontal scroll at different offsets —
14670        // bounds should be identical regardless of scroll position.
14671        let viewport_clip = Rect {
14672            x: 0.0,
14673            y: 0.0,
14674            width: 400.0,
14675            height: 50.0,
14676        };
14677        let compute_bounds_at_offset = |scroll_x: f32| {
14678            let mut scene = CompositorScene::new();
14679            for i in 0..10 {
14680                scene.shapes.push(DrawShape {
14681                    rect: Rect {
14682                        x: i as f32 * 100.0 - scroll_x,
14683                        y: 0.0,
14684                        width: 80.0,
14685                        height: 40.0,
14686                    },
14687                    clip: Some(viewport_clip),
14688                    ..test_shape(i, BlendMode::SrcOver)
14689                });
14690            }
14691            scene_bounds(&scene).expect("bounds")
14692        };
14693        let bounds_at_0 = compute_bounds_at_offset(0.0);
14694        let bounds_at_300 = compute_bounds_at_offset(300.0);
14695        let bounds_at_600 = compute_bounds_at_offset(600.0);
14696        // Width should be stable (clipped to viewport) regardless of scroll offset
14697        assert!(
14698            (bounds_at_0.width - bounds_at_300.width).abs() < 1.0,
14699            "bounds width changed with scroll: {} vs {}",
14700            bounds_at_0.width,
14701            bounds_at_300.width
14702        );
14703        assert!(
14704            (bounds_at_0.width - bounds_at_600.width).abs() < 1.0,
14705            "bounds width changed with scroll: {} vs {}",
14706            bounds_at_0.width,
14707            bounds_at_600.width
14708        );
14709    }
14710
14711    #[test]
14712    fn collect_effect_ranges_respects_excluded_effect() {
14713        let layers = vec![effect_layer(10, 40), effect_layer(20, 30)];
14714        let mut ranges = Vec::new();
14715        collect_effect_ranges(&layers, 10, 40, Some(0), &mut ranges);
14716        assert_eq!(ranges.len(), 1);
14717        assert_eq!(ranges[0], 20..30);
14718    }
14719
14720    #[test]
14721    fn collect_layer_events_includes_nested_when_parent_excluded() {
14722        let effects = vec![effect_layer(10, 40), effect_layer(20, 30)];
14723        let backdrops = vec![backdrop_layer(25)];
14724        let mut events = Vec::new();
14725        collect_layer_events(&effects, &backdrops, 10, 40, Some(0), &mut events);
14726        assert_eq!(events.len(), 2);
14727
14728        match events[0].kind {
14729            LayerEventKind::Effect(index) => assert_eq!(index, 1),
14730            LayerEventKind::Backdrop(_) => panic!("expected nested effect as first event"),
14731        }
14732        match events[1].kind {
14733            LayerEventKind::Backdrop(index) => assert_eq!(index, 0),
14734            LayerEventKind::Effect(_) => panic!("expected backdrop as second event"),
14735        }
14736    }
14737
14738    fn pure_text_leaf(animated: bool, translated_content_context: bool) -> LayerNode {
14739        LayerNode {
14740            node_id: Some(177),
14741            local_bounds: Rect {
14742                x: 0.0,
14743                y: 0.0,
14744                width: 96.0,
14745                height: 32.0,
14746            },
14747            transform_to_parent: ProjectiveTransform::translation(11.4, 23.6),
14748            motion_context_animated: animated,
14749            translated_content_context,
14750            translated_content_offset: Point::default(),
14751            content_offset: Point::default(),
14752            scene_children_origin: cranpose_ui_graphics::Point::default(),
14753            scene_children_layer_translation: cranpose_ui_graphics::Point::default(),
14754            graphics_layer: GraphicsLayer::default(),
14755            clip_to_bounds: false,
14756            shadow_clip: None,
14757            hit_test: None,
14758            has_hit_targets: false,
14759            isolation: IsolationReasons::default(),
14760            cache_policy: CachePolicy::None,
14761            cache_hashes: LayerRasterCacheHashes::default(),
14762            cache_hashes_valid: false,
14763            children: vec![RenderNode::Primitive(PrimitiveEntry {
14764                phase: PrimitivePhase::BeforeChildren,
14765                node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
14766                    node_id: 177,
14767                    rect: Rect {
14768                        x: 0.0,
14769                        y: 0.0,
14770                        width: 96.0,
14771                        height: 24.0,
14772                    },
14773                    clip: None,
14774                    text: std::rc::Rc::new(AnnotatedString::from("Pure text")),
14775                    text_style: TextStyle::default(),
14776                    font_size: 14.0,
14777                    layout_options: TextLayoutOptions::default(),
14778                })),
14779            })],
14780        }
14781    }
14782
14783    fn pure_text_leaf_root(animated: bool, translated_content_context: bool) -> LayerNode {
14784        let text_leaf = pure_text_leaf(animated, translated_content_context);
14785        test_layer(
14786            Rect {
14787                x: 0.0,
14788                y: 0.0,
14789                width: 160.0,
14790                height: 96.0,
14791            },
14792            vec![RenderNode::Layer(Box::new(text_leaf))],
14793        )
14794    }
14795
14796    #[test]
14797    fn collect_layer_events_sorts_backdrop_before_effect_at_same_z() {
14798        let effects = vec![effect_layer(10, 20)];
14799        let backdrops = vec![backdrop_layer(10)];
14800        let mut events = Vec::new();
14801        collect_layer_events(&effects, &backdrops, 0, 30, None, &mut events);
14802        assert_eq!(events.len(), 2);
14803
14804        match events[0].kind {
14805            LayerEventKind::Backdrop(_) => {}
14806            LayerEventKind::Effect(_) => panic!("expected backdrop to run before effect"),
14807        }
14808        match events[1].kind {
14809            LayerEventKind::Effect(_) => {}
14810            LayerEventKind::Backdrop(_) => panic!("expected effect as second event"),
14811        }
14812    }
14813
14814    #[test]
14815    fn collect_layer_events_prefers_outer_effect_when_same_start_z() {
14816        // Child emitted before parent (matching scene collection order where a
14817        // parent effect is recorded after recursively processing children).
14818        let effects = vec![effect_layer(10, 20), effect_layer(10, 40)];
14819        let mut events = Vec::new();
14820        collect_layer_events(&effects, &[], 0, 50, None, &mut events);
14821
14822        assert_eq!(events.len(), 2);
14823        match events[0].kind {
14824            LayerEventKind::Effect(index) => assert_eq!(index, 1),
14825            LayerEventKind::Backdrop(_) => panic!("expected outer effect first"),
14826        }
14827        match events[1].kind {
14828            LayerEventKind::Effect(index) => assert_eq!(index, 0),
14829            LayerEventKind::Backdrop(_) => panic!("expected child effect second"),
14830        }
14831    }
14832
14833    #[test]
14834    fn collect_layer_events_prefers_later_effect_when_ranges_match() {
14835        let effects = vec![effect_layer(10, 20), effect_layer(10, 20)];
14836        let mut events = Vec::new();
14837        collect_layer_events(&effects, &[], 0, 30, None, &mut events);
14838
14839        assert_eq!(events.len(), 2);
14840        match events[0].kind {
14841            LayerEventKind::Effect(index) => assert_eq!(index, 1),
14842            LayerEventKind::Backdrop(_) => panic!("expected later effect first"),
14843        }
14844        match events[1].kind {
14845            LayerEventKind::Effect(index) => assert_eq!(index, 0),
14846            LayerEventKind::Backdrop(_) => panic!("expected earlier effect second"),
14847        }
14848    }
14849
14850    #[test]
14851    fn has_backdrop_layer_in_range_detects_nested_layers() {
14852        let backdrops = vec![backdrop_layer(5), backdrop_layer(15), backdrop_layer(25)];
14853        assert!(has_backdrop_layer_in_range(&backdrops, 10, 20));
14854        assert!(has_backdrop_layer_in_range(&backdrops, 0, 6));
14855        assert!(!has_backdrop_layer_in_range(&backdrops, 20, 25));
14856    }
14857
14858    #[test]
14859    fn layer_contains_descendant_backdrop_ignores_self_backdrop() {
14860        let mut self_backdrop = test_layer(
14861            Rect {
14862                x: 0.0,
14863                y: 0.0,
14864                width: 10.0,
14865                height: 10.0,
14866            },
14867            vec![],
14868        );
14869        self_backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
14870        assert!(!layer_contains_descendant_backdrop(&self_backdrop));
14871
14872        let mut child = test_layer(
14873            Rect {
14874                x: 0.0,
14875                y: 0.0,
14876                width: 8.0,
14877                height: 8.0,
14878            },
14879            vec![],
14880        );
14881        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
14882
14883        let parent = test_layer(
14884            Rect {
14885                x: 0.0,
14886                y: 0.0,
14887                width: 20.0,
14888                height: 20.0,
14889            },
14890            vec![RenderNode::Layer(Box::new(child))],
14891        );
14892        assert!(layer_contains_descendant_backdrop(&parent));
14893    }
14894
14895    fn child_layer_composite(
14896        layer: &LayerNode,
14897        z_index: usize,
14898        rect: Rect,
14899        needs_nested_underlay: bool,
14900    ) -> crate::normalized_scene::ChildLayerComposite {
14901        let mut requirements_cache = cranpose_core::collections::map::HashMap::new();
14902        let surface_requirements =
14903            crate::surface_plan::layer_surface_requirements_cached(layer, &mut requirements_cache);
14904        crate::normalized_scene::ChildLayerComposite {
14905            z_index,
14906            logical_rect: Rect {
14907                x: 0.0,
14908                y: 0.0,
14909                width: rect.width,
14910                height: rect.height,
14911            },
14912            dest_quad: rect_to_quad(rect),
14913            snap_anchor: None,
14914            composite_snap_origin: None,
14915            backdrop_rect: rect,
14916            visual_clip: None,
14917            surface_clip: None,
14918            shadow_draws: Vec::new(),
14919            needs_nested_underlay,
14920            node_id: layer.node_id,
14921            backdrop: layer.backdrop().cloned(),
14922            has_effect: layer.effect().is_some(),
14923            effect_contains_runtime_shader: layer
14924                .effect()
14925                .is_some_and(|effect| effect.contains_runtime_shader()),
14926            target_content_hash: layer.target_content_hash(),
14927            effect_hash: layer.effect_hash(),
14928            motion_source_content_hash: Some(layer.motion_source_content_hash()),
14929            contains_descendant_backdrop: layer_contains_descendant_backdrop(layer),
14930            cache_policy: layer.cache_policy,
14931            surface_requirements,
14932            rounded_clip: crate::surface_executor::backend::LayerSurfaceRoundedClip::from_layer(
14933                layer,
14934            ),
14935            isolation: cranpose_render_common::layer_composition::effective_layer_isolation(
14936                &layer.graphics_layer,
14937            ),
14938            translated_content_context: layer.translated_content_context,
14939            own_translated_content_axes: crate::surface_plan::translated_content_axes_for_layer(
14940                layer,
14941            ),
14942            clip_rect: layer.clip_rect(),
14943            local_bounds: layer.local_bounds,
14944            surface_scale: crate::surface_plan::layer_surface_scale(layer),
14945            source: crate::normalized_scene::LoweredChildSource::default(),
14946        }
14947    }
14948
14949    #[test]
14950    fn root_direct_preflight_allows_first_translated_child_underlay() {
14951        let child = test_layer(
14952            Rect {
14953                x: 0.0,
14954                y: 0.0,
14955                width: 400.0,
14956                height: 280.0,
14957            },
14958            vec![],
14959        );
14960        let collected = CollectedLayer {
14961            scene: CompositorScene::new(),
14962            child_layers: vec![child_layer_composite(
14963                &child,
14964                3,
14965                Rect {
14966                    x: 48.0,
14967                    y: 96.0,
14968                    width: 400.0,
14969                    height: 280.0,
14970                },
14971                true,
14972            )],
14973        };
14974
14975        assert!(direct_root_child_underlays_are_supported(&collected));
14976    }
14977
14978    #[test]
14979    fn root_direct_preflight_allows_axis_aligned_prior_child_underlay() {
14980        let first = test_layer(
14981            Rect {
14982                x: 0.0,
14983                y: 0.0,
14984                width: 80.0,
14985                height: 40.0,
14986            },
14987            vec![],
14988        );
14989        let backdrop_child = test_layer(
14990            Rect {
14991                x: 0.0,
14992                y: 0.0,
14993                width: 400.0,
14994                height: 280.0,
14995            },
14996            vec![],
14997        );
14998        let collected = CollectedLayer {
14999            scene: CompositorScene::new(),
15000            child_layers: vec![
15001                child_layer_composite(
15002                    &first,
15003                    1,
15004                    Rect {
15005                        x: 8.0,
15006                        y: 16.0,
15007                        width: 80.0,
15008                        height: 40.0,
15009                    },
15010                    false,
15011                ),
15012                child_layer_composite(
15013                    &backdrop_child,
15014                    4,
15015                    Rect {
15016                        x: 48.0,
15017                        y: 96.0,
15018                        width: 400.0,
15019                        height: 280.0,
15020                    },
15021                    true,
15022                ),
15023            ],
15024        };
15025
15026        assert!(direct_root_child_underlays_are_supported(&collected));
15027    }
15028
15029    #[test]
15030    fn root_direct_preflight_rejects_effectful_prior_child_underlay() {
15031        let mut first = test_layer(
15032            Rect {
15033                x: 0.0,
15034                y: 0.0,
15035                width: 80.0,
15036                height: 40.0,
15037            },
15038            vec![],
15039        );
15040        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
15041        let backdrop_child = test_layer(
15042            Rect {
15043                x: 0.0,
15044                y: 0.0,
15045                width: 400.0,
15046                height: 280.0,
15047            },
15048            vec![],
15049        );
15050        let collected = CollectedLayer {
15051            scene: CompositorScene::new(),
15052            child_layers: vec![
15053                child_layer_composite(
15054                    &first,
15055                    1,
15056                    Rect {
15057                        x: 64.0,
15058                        y: 112.0,
15059                        width: 80.0,
15060                        height: 40.0,
15061                    },
15062                    false,
15063                ),
15064                child_layer_composite(
15065                    &backdrop_child,
15066                    4,
15067                    Rect {
15068                        x: 48.0,
15069                        y: 96.0,
15070                        width: 400.0,
15071                        height: 280.0,
15072                    },
15073                    true,
15074                ),
15075            ],
15076        };
15077
15078        assert!(!direct_root_child_underlays_are_supported(&collected));
15079    }
15080
15081    #[test]
15082    fn root_direct_preflight_ignores_non_overlapping_effectful_prior_child_underlay() {
15083        let mut first = test_layer(
15084            Rect {
15085                x: 0.0,
15086                y: 0.0,
15087                width: 80.0,
15088                height: 40.0,
15089            },
15090            vec![],
15091        );
15092        first.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
15093        let backdrop_child = test_layer(
15094            Rect {
15095                x: 0.0,
15096                y: 0.0,
15097                width: 400.0,
15098                height: 280.0,
15099            },
15100            vec![],
15101        );
15102        let collected = CollectedLayer {
15103            scene: CompositorScene::new(),
15104            child_layers: vec![
15105                child_layer_composite(
15106                    &first,
15107                    1,
15108                    Rect {
15109                        x: 8.0,
15110                        y: 16.0,
15111                        width: 80.0,
15112                        height: 40.0,
15113                    },
15114                    false,
15115                ),
15116                child_layer_composite(
15117                    &backdrop_child,
15118                    4,
15119                    Rect {
15120                        x: 48.0,
15121                        y: 96.0,
15122                        width: 400.0,
15123                        height: 280.0,
15124                    },
15125                    true,
15126                ),
15127            ],
15128        };
15129
15130        assert!(direct_root_child_underlays_are_supported(&collected));
15131    }
15132
15133    #[test]
15134    fn root_direct_preflight_rejects_underlay_that_would_replay_prior_scene_effects() {
15135        let backdrop_child = test_layer(
15136            Rect {
15137                x: 0.0,
15138                y: 0.0,
15139                width: 400.0,
15140                height: 280.0,
15141            },
15142            vec![],
15143        );
15144        let mut scene = CompositorScene::new();
15145        scene.next_z = 1;
15146        scene.push_effect_layer(
15147            Rect {
15148                x: 0.0,
15149                y: 0.0,
15150                width: 120.0,
15151                height: 120.0,
15152            },
15153            None,
15154            Some(RenderEffect::blur(2.0)),
15155            BlendMode::SrcOver,
15156            1.0,
15157            0,
15158            1,
15159        );
15160        let collected = CollectedLayer {
15161            scene,
15162            child_layers: vec![child_layer_composite(
15163                &backdrop_child,
15164                4,
15165                Rect {
15166                    x: 48.0,
15167                    y: 96.0,
15168                    width: 400.0,
15169                    height: 280.0,
15170                },
15171                true,
15172            )],
15173        };
15174
15175        assert!(!direct_root_child_underlays_are_supported(&collected));
15176    }
15177
15178    #[test]
15179    fn root_direct_eligibility_does_not_reject_descendant_backdrop() {
15180        let mut backdrop = test_layer(
15181            Rect {
15182                x: 0.0,
15183                y: 0.0,
15184                width: 40.0,
15185                height: 40.0,
15186            },
15187            vec![],
15188        );
15189        backdrop.graphics_layer.backdrop_effect = Some(RenderEffect::blur(4.0));
15190        let child = test_layer(
15191            Rect {
15192                x: 0.0,
15193                y: 0.0,
15194                width: 120.0,
15195                height: 96.0,
15196            },
15197            vec![RenderNode::Layer(Box::new(backdrop))],
15198        );
15199        let root = test_layer(
15200            Rect {
15201                x: 0.0,
15202                y: 0.0,
15203                width: 240.0,
15204                height: 160.0,
15205            },
15206            vec![RenderNode::Layer(Box::new(child))],
15207        );
15208        let mut cache = HashMap::new();
15209
15210        assert!(root_can_render_directly_cached(&root, &mut cache));
15211    }
15212
15213    #[test]
15214    fn root_direct_scene_events_allow_root_local_effects() {
15215        let mut scene = CompositorScene::new();
15216        scene.effect_layers.push(EffectLayer {
15217            rect: Rect {
15218                x: 20.0,
15219                y: 30.0,
15220                width: 120.0,
15221                height: 80.0,
15222            },
15223            clip: None,
15224            snap_anchor: None,
15225            effect: Some(RenderEffect::blur(6.0)),
15226            blend_mode: BlendMode::SrcOver,
15227            composite_alpha: 1.0,
15228            z_start: 0,
15229            z_end: 1,
15230            requirements: SurfaceRequirementSet::default().with(SurfaceRequirement::RenderEffect),
15231        });
15232
15233        assert!(root_direct_scene_events_are_supported(&scene));
15234    }
15235
15236    #[test]
15237    fn root_direct_scene_events_reject_root_local_backdrops() {
15238        let mut scene = CompositorScene::new();
15239        scene.backdrop_layers.push(BackdropLayer {
15240            node_id: Some(99),
15241            rect: Rect {
15242                x: 20.0,
15243                y: 30.0,
15244                width: 120.0,
15245                height: 80.0,
15246            },
15247            clip: None,
15248            snap_anchor: None,
15249            effect: RenderEffect::blur(6.0),
15250            z_index: 1,
15251        });
15252
15253        assert!(!root_direct_scene_events_are_supported(&scene));
15254    }
15255
15256    #[test]
15257    fn estimate_layer_surface_rect_includes_transformed_child_bounds() {
15258        let mut child = test_layer(
15259            Rect {
15260                x: 0.0,
15261                y: 0.0,
15262                width: 10.0,
15263                height: 6.0,
15264            },
15265            vec![RenderNode::Primitive(PrimitiveEntry {
15266                phase: PrimitivePhase::BeforeChildren,
15267                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15268                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15269                        rect: Rect {
15270                            x: 0.0,
15271                            y: 0.0,
15272                            width: 10.0,
15273                            height: 6.0,
15274                        },
15275                        brush: Brush::solid(Color::WHITE),
15276                        stroke: None,
15277                    },
15278                    clip: None,
15279                }),
15280            })],
15281        );
15282        child.transform_to_parent = ProjectiveTransform::translation(18.0, 7.0);
15283
15284        let parent = test_layer(
15285            Rect {
15286                x: 0.0,
15287                y: 0.0,
15288                width: 4.0,
15289                height: 4.0,
15290            },
15291            vec![RenderNode::Layer(Box::new(child))],
15292        );
15293
15294        assert_eq!(
15295            estimate_layer_surface_rect(&parent),
15296            Rect {
15297                x: 18.0,
15298                y: 7.0,
15299                width: 10.0,
15300                height: 6.0,
15301            }
15302        );
15303    }
15304
15305    #[test]
15306    fn estimate_layer_surface_rect_clips_translated_clip_layers_without_hidden_leading_content() {
15307        let mut layer = test_layer(
15308            Rect {
15309                x: 0.0,
15310                y: 0.0,
15311                width: 120.0,
15312                height: 72.0,
15313            },
15314            vec![RenderNode::Primitive(PrimitiveEntry {
15315                phase: PrimitivePhase::BeforeChildren,
15316                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15317                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15318                        rect: Rect {
15319                            x: 24.0,
15320                            y: 0.0,
15321                            width: 200.0,
15322                            height: 480.0,
15323                        },
15324                        brush: Brush::solid(Color::WHITE),
15325                        stroke: None,
15326                    },
15327                    clip: None,
15328                }),
15329            })],
15330        );
15331        layer.translated_content_context = true;
15332        layer.motion_context_animated = true;
15333        layer.clip_to_bounds = true;
15334
15335        assert_eq!(
15336            estimate_layer_surface_rect(&layer),
15337            Rect {
15338                x: 24.0,
15339                y: 0.0,
15340                width: 96.0,
15341                height: 72.0,
15342            }
15343        );
15344    }
15345
15346    #[test]
15347    fn estimate_layer_surface_rect_clips_active_horizontal_scroll_content() {
15348        let mut layer = test_layer(
15349            Rect {
15350                x: 0.0,
15351                y: 0.0,
15352                width: 120.0,
15353                height: 72.0,
15354            },
15355            vec![RenderNode::Primitive(PrimitiveEntry {
15356                phase: PrimitivePhase::BeforeChildren,
15357                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15358                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15359                        rect: Rect {
15360                            x: -24.0,
15361                            y: 0.0,
15362                            width: 200.0,
15363                            height: 480.0,
15364                        },
15365                        brush: Brush::solid(Color::WHITE),
15366                        stroke: None,
15367                    },
15368                    clip: None,
15369                }),
15370            })],
15371        );
15372        layer.translated_content_context = true;
15373        layer.motion_context_animated = true;
15374        layer.clip_to_bounds = true;
15375
15376        assert_eq!(
15377            estimate_layer_surface_rect(&layer),
15378            Rect {
15379                x: 0.0,
15380                y: 0.0,
15381                width: 120.0,
15382                height: 72.0,
15383            }
15384        );
15385    }
15386
15387    #[test]
15388    fn estimate_layer_surface_rect_clips_active_vertical_scroll_content() {
15389        let mut layer = test_layer(
15390            Rect {
15391                x: 0.0,
15392                y: 0.0,
15393                width: 120.0,
15394                height: 72.0,
15395            },
15396            vec![RenderNode::Primitive(PrimitiveEntry {
15397                phase: PrimitivePhase::BeforeChildren,
15398                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15399                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15400                        rect: Rect {
15401                            x: 0.0,
15402                            y: -24.0,
15403                            width: 120.0,
15404                            height: 200.0,
15405                        },
15406                        brush: Brush::solid(Color::WHITE),
15407                        stroke: None,
15408                    },
15409                    clip: None,
15410                }),
15411            })],
15412        );
15413        layer.translated_content_context = true;
15414        layer.motion_context_animated = true;
15415        layer.clip_to_bounds = true;
15416
15417        assert_eq!(
15418            estimate_layer_surface_rect(&layer),
15419            Rect {
15420                x: 0.0,
15421                y: 0.0,
15422                width: 120.0,
15423                height: 72.0,
15424            }
15425        );
15426    }
15427
15428    #[test]
15429    fn estimate_layer_surface_rect_keeps_shallow_scroll_capture_origin_stable() {
15430        fn shallow_scroll_surface_rect(content_y: f32) -> Rect {
15431            let mut layer = test_layer(
15432                Rect {
15433                    x: 0.0,
15434                    y: 0.0,
15435                    width: 120.0,
15436                    height: 72.0,
15437                },
15438                vec![RenderNode::Primitive(PrimitiveEntry {
15439                    phase: PrimitivePhase::BeforeChildren,
15440                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
15441                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15442                            rect: Rect {
15443                                x: 0.0,
15444                                y: content_y,
15445                                width: 120.0,
15446                                height: 200.0,
15447                            },
15448                            brush: Brush::solid(Color::WHITE),
15449                            stroke: None,
15450                        },
15451                        clip: None,
15452                    }),
15453                })],
15454            );
15455            layer.translated_content_context = true;
15456            layer.motion_context_animated = true;
15457            layer.clip_to_bounds = true;
15458            estimate_layer_surface_rect(&layer)
15459        }
15460
15461        assert_eq!(
15462            shallow_scroll_surface_rect(-24.0),
15463            shallow_scroll_surface_rect(-25.0),
15464            "shallow scroll capture bounds must not move the offscreen surface origin on adjacent scroll positions"
15465        );
15466    }
15467
15468    #[test]
15469    fn estimate_layer_surface_rect_clips_active_xy_scroll_content() {
15470        let mut layer = test_layer(
15471            Rect {
15472                x: 0.0,
15473                y: 0.0,
15474                width: 120.0,
15475                height: 72.0,
15476            },
15477            vec![RenderNode::Primitive(PrimitiveEntry {
15478                phase: PrimitivePhase::BeforeChildren,
15479                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15480                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15481                        rect: Rect {
15482                            x: -16.0,
15483                            y: -24.0,
15484                            width: 180.0,
15485                            height: 240.0,
15486                        },
15487                        brush: Brush::solid(Color::WHITE),
15488                        stroke: None,
15489                    },
15490                    clip: None,
15491                }),
15492            })],
15493        );
15494        layer.translated_content_context = true;
15495        layer.motion_context_animated = true;
15496        layer.clip_to_bounds = true;
15497
15498        assert_eq!(
15499            estimate_layer_surface_rect(&layer),
15500            Rect {
15501                x: 0.0,
15502                y: 0.0,
15503                width: 120.0,
15504                height: 72.0,
15505            }
15506        );
15507    }
15508
15509    #[test]
15510    fn estimate_layer_surface_rect_clips_deep_hidden_active_scroll_content() {
15511        let mut layer = test_layer(
15512            Rect {
15513                x: 0.0,
15514                y: 0.0,
15515                width: 120.0,
15516                height: 72.0,
15517            },
15518            vec![RenderNode::Primitive(PrimitiveEntry {
15519                phase: PrimitivePhase::BeforeChildren,
15520                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15521                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15522                        rect: Rect {
15523                            x: 0.0,
15524                            y: -1200.0,
15525                            width: 120.0,
15526                            height: 1400.0,
15527                        },
15528                        brush: Brush::solid(Color::WHITE),
15529                        stroke: None,
15530                    },
15531                    clip: None,
15532                }),
15533            })],
15534        );
15535        layer.translated_content_context = true;
15536        layer.motion_context_animated = true;
15537        layer.clip_to_bounds = true;
15538
15539        assert_eq!(
15540            estimate_layer_surface_rect(&layer),
15541            Rect {
15542                x: 0.0,
15543                y: 0.0,
15544                width: 120.0,
15545                height: 72.0,
15546            }
15547        );
15548    }
15549
15550    #[test]
15551    fn estimate_layer_surface_rect_keeps_deep_scroll_capture_origin_stable() {
15552        fn deep_scroll_surface_rect(content_y: f32) -> Rect {
15553            let mut layer = test_layer(
15554                Rect {
15555                    x: 0.0,
15556                    y: 0.0,
15557                    width: 120.0,
15558                    height: 72.0,
15559                },
15560                vec![RenderNode::Primitive(PrimitiveEntry {
15561                    phase: PrimitivePhase::BeforeChildren,
15562                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
15563                        primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15564                            rect: Rect {
15565                                x: 0.0,
15566                                y: content_y,
15567                                width: 120.0,
15568                                height: 1400.0,
15569                            },
15570                            brush: Brush::solid(Color::WHITE),
15571                            stroke: None,
15572                        },
15573                        clip: None,
15574                    }),
15575                })],
15576            );
15577            layer.translated_content_context = true;
15578            layer.motion_context_animated = true;
15579            layer.clip_to_bounds = true;
15580            estimate_layer_surface_rect(&layer)
15581        }
15582
15583        assert_eq!(
15584            deep_scroll_surface_rect(-1200.0),
15585            deep_scroll_surface_rect(-1201.0),
15586            "deep scroll capture bounds must not re-phase the offscreen surface origin on adjacent scroll positions"
15587        );
15588    }
15589
15590    #[test]
15591    fn motion_stable_capture_bounds_bounds_shadows_for_clipped_effect_layer() {
15592        let mut layer = test_layer(
15593            Rect {
15594                x: 0.0,
15595                y: 0.0,
15596                width: 120.0,
15597                height: 72.0,
15598            },
15599            vec![],
15600        );
15601        layer.clip_to_bounds = true;
15602        layer.graphics_layer.clip = true;
15603        layer.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
15604
15605        let mut shadow_shape = test_shape(0, BlendMode::SrcOver);
15606        shadow_shape.rect = Rect {
15607            x: -24.0,
15608            y: -1200.0,
15609            width: 180.0,
15610            height: 1400.0,
15611        };
15612        let mut scene = CompositorScene::new();
15613        scene
15614            .shadow_draws
15615            .push(test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]));
15616
15617        let requirements = SurfaceRequirementSet::default()
15618            .with(SurfaceRequirement::RenderEffect)
15619            .with(SurfaceRequirement::MotionStableCapture);
15620
15621        assert_eq!(
15622            motion_stable_capture_bounds(
15623                &layer,
15624                &scene,
15625                &[],
15626                requirements,
15627                TranslatedContentAxes::default(),
15628                None,
15629            ),
15630            Some(Rect {
15631                x: -360.0,
15632                y: -216.0,
15633                width: 480.0,
15634                height: 288.0,
15635            })
15636        );
15637    }
15638
15639    #[test]
15640    fn vertical_motion_stable_capture_uses_viewport_cross_axis_bounds() {
15641        let mut layer = test_layer(
15642            Rect {
15643                x: 0.0,
15644                y: 0.0,
15645                width: 200.0,
15646                height: 100.0,
15647            },
15648            vec![],
15649        );
15650        layer.clip_to_bounds = true;
15651        layer.graphics_layer.clip = true;
15652
15653        let mut shape = test_shape(0, BlendMode::SrcOver);
15654        shape.rect = Rect {
15655            x: 60.0,
15656            y: -80.0,
15657            width: 80.0,
15658            height: 220.0,
15659        };
15660        let mut scene = CompositorScene::new();
15661        scene.shapes.push(shape);
15662
15663        let requirements =
15664            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
15665
15666        assert_eq!(
15667            motion_stable_capture_bounds(
15668                &layer,
15669                &scene,
15670                &[],
15671                requirements,
15672                TranslatedContentAxes { x: false, y: true },
15673                None,
15674            ),
15675            Some(Rect {
15676                x: -96.0,
15677                y: -64.0,
15678                width: 296.0,
15679                height: 164.0,
15680            })
15681        );
15682    }
15683
15684    #[test]
15685    fn vertical_motion_stable_capture_uses_external_surface_clip() {
15686        let layer = test_layer(
15687            Rect {
15688                x: 0.0,
15689                y: 0.0,
15690                width: 200.0,
15691                height: 100.0,
15692            },
15693            vec![],
15694        );
15695
15696        let mut shape = test_shape(0, BlendMode::SrcOver);
15697        shape.rect = Rect {
15698            x: 60.0,
15699            y: -80.0,
15700            width: 80.0,
15701            height: 220.0,
15702        };
15703        let mut scene = CompositorScene::new();
15704        scene.shapes.push(shape);
15705
15706        let requirements =
15707            SurfaceRequirementSet::default().with(SurfaceRequirement::MotionStableCapture);
15708
15709        assert_eq!(
15710            motion_stable_capture_bounds(
15711                &layer,
15712                &scene,
15713                &[],
15714                requirements,
15715                TranslatedContentAxes { x: false, y: true },
15716                Some(Rect {
15717                    x: 0.0,
15718                    y: 0.0,
15719                    width: 200.0,
15720                    height: 100.0,
15721                }),
15722            ),
15723            Some(Rect {
15724                x: -96.0,
15725                y: -64.0,
15726                width: 296.0,
15727                height: 164.0,
15728            })
15729        );
15730    }
15731
15732    #[test]
15733    fn estimate_layer_surface_rect_expands_for_child_layer_shadow() {
15734        let mut child = test_layer(
15735            Rect {
15736                x: 0.0,
15737                y: 0.0,
15738                width: 12.0,
15739                height: 8.0,
15740            },
15741            vec![],
15742        );
15743        child.transform_to_parent = ProjectiveTransform::translation(20.0, 9.0);
15744        child.graphics_layer.shadow_elevation = 6.0;
15745
15746        let parent = test_layer(
15747            Rect {
15748                x: 0.0,
15749                y: 0.0,
15750                width: 4.0,
15751                height: 4.0,
15752            },
15753            vec![RenderNode::Layer(Box::new(child))],
15754        );
15755
15756        let rect = estimate_layer_surface_rect(&parent);
15757        assert!(rect.x < 20.0);
15758        assert!(rect.y < 9.0);
15759        assert!(rect.width > 12.0);
15760        assert!(rect.height > 8.0);
15761    }
15762
15763    #[test]
15764    fn estimate_layer_surface_rect_respects_local_bounds_for_effect_layers() {
15765        let mut layer = test_layer(
15766            Rect {
15767                x: 0.0,
15768                y: 0.0,
15769                width: 28.0,
15770                height: 28.0,
15771            },
15772            vec![RenderNode::Primitive(PrimitiveEntry {
15773                phase: PrimitivePhase::BeforeChildren,
15774                node: PrimitiveNode::Draw(DrawPrimitiveNode {
15775                    primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15776                        rect: Rect {
15777                            x: 10.0,
15778                            y: 10.0,
15779                            width: 10.0,
15780                            height: 10.0,
15781                        },
15782                        brush: Brush::solid(Color::WHITE),
15783                        stroke: None,
15784                    },
15785                    clip: None,
15786                }),
15787            })],
15788        );
15789        layer.graphics_layer.render_effect = Some(RenderEffect::blur(12.0));
15790
15791        assert_eq!(
15792            estimate_layer_surface_rect(&layer),
15793            Rect {
15794                x: 0.0,
15795                y: 0.0,
15796                width: 28.0,
15797                height: 28.0,
15798            }
15799        );
15800    }
15801
15802    #[test]
15803    fn layer_raster_cache_candidate_ignores_parent_transform() {
15804        let primitive = PrimitiveEntry {
15805            phase: PrimitivePhase::BeforeChildren,
15806            node: PrimitiveNode::Draw(DrawPrimitiveNode {
15807                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15808                    rect: Rect {
15809                        x: 2.0,
15810                        y: 3.0,
15811                        width: 6.0,
15812                        height: 4.0,
15813                    },
15814                    brush: Brush::solid(Color::BLACK),
15815                    stroke: None,
15816                },
15817                clip: None,
15818            }),
15819        };
15820        let base = cacheable_layer(
15821            41,
15822            Rect {
15823                x: 0.0,
15824                y: 0.0,
15825                width: 20.0,
15826                height: 20.0,
15827            },
15828            vec![RenderNode::Primitive(primitive.clone())],
15829        );
15830        let mut moved = base.clone();
15831        moved.transform_to_parent = ProjectiveTransform::translation(32.0, 18.0);
15832
15833        assert_eq!(
15834            layer_raster_cache_candidate(&base, 1.25, false, false),
15835            layer_raster_cache_candidate(&moved, 1.25, false, false)
15836        );
15837    }
15838
15839    #[test]
15840    fn layer_raster_cache_candidate_changes_for_translated_content_offset() {
15841        let primitive = PrimitiveEntry {
15842            phase: PrimitivePhase::BeforeChildren,
15843            node: PrimitiveNode::Draw(DrawPrimitiveNode {
15844                primitive: cranpose_ui_graphics::DrawPrimitive::Rect {
15845                    rect: Rect {
15846                        x: 2.0,
15847                        y: 3.0,
15848                        width: 6.0,
15849                        height: 4.0,
15850                    },
15851                    brush: Brush::solid(Color::BLACK),
15852                    stroke: None,
15853                },
15854                clip: None,
15855            }),
15856        };
15857        let mut base = cacheable_layer(
15858            42,
15859            Rect {
15860                x: 0.0,
15861                y: 0.0,
15862                width: 20.0,
15863                height: 20.0,
15864            },
15865            vec![RenderNode::Primitive(primitive)],
15866        );
15867        base.translated_content_context = true;
15868        base.translated_content_offset = Point::new(0.0, -8.0);
15869        base.recompute_raster_cache_hashes();
15870
15871        let mut moved = base.clone();
15872        moved.translated_content_offset = Point::new(0.0, -16.0);
15873        moved.recompute_raster_cache_hashes();
15874
15875        assert_ne!(
15876            layer_raster_cache_candidate(&base, 1.25, false, false),
15877            layer_raster_cache_candidate(&moved, 1.25, false, false),
15878            "full-surface layer cache candidates must not alias different scroll offsets"
15879        );
15880    }
15881
15882    #[test]
15883    fn layer_raster_cache_candidate_changes_for_child_transform() {
15884        let mut child = cacheable_layer(
15885            8,
15886            Rect {
15887                x: 0.0,
15888                y: 0.0,
15889                width: 12.0,
15890                height: 10.0,
15891            },
15892            vec![],
15893        );
15894        child.transform_to_parent = ProjectiveTransform::translation(4.0, 6.0);
15895        let base = cacheable_layer(
15896            7,
15897            Rect {
15898                x: 0.0,
15899                y: 0.0,
15900                width: 20.0,
15901                height: 20.0,
15902            },
15903            vec![RenderNode::Layer(Box::new(child.clone()))],
15904        );
15905        let mut moved_child = child;
15906        moved_child.transform_to_parent = ProjectiveTransform::translation(9.0, 6.0);
15907        let moved = cacheable_layer(
15908            7,
15909            Rect {
15910                x: 0.0,
15911                y: 0.0,
15912                width: 20.0,
15913                height: 20.0,
15914            },
15915            vec![RenderNode::Layer(Box::new(moved_child))],
15916        );
15917
15918        assert_ne!(
15919            layer_raster_cache_candidate(&base, 1.0, false, false),
15920            layer_raster_cache_candidate(&moved, 1.0, false, false)
15921        );
15922    }
15923
15924    #[test]
15925    fn layer_raster_cache_candidate_rejects_external_backdrop_dependency() {
15926        let mut child = cacheable_layer(
15927            12,
15928            Rect {
15929                x: 0.0,
15930                y: 0.0,
15931                width: 8.0,
15932                height: 8.0,
15933            },
15934            vec![],
15935        );
15936        child.graphics_layer.backdrop_effect = Some(RenderEffect::blur(2.0));
15937        let parent = cacheable_layer(
15938            11,
15939            Rect {
15940                x: 0.0,
15941                y: 0.0,
15942                width: 16.0,
15943                height: 16.0,
15944            },
15945            vec![RenderNode::Layer(Box::new(child))],
15946        );
15947
15948        assert!(layer_raster_cache_candidate(&parent, 1.0, false, false).is_some());
15949        assert!(layer_raster_cache_candidate(&parent, 1.0, true, false).is_none());
15950    }
15951
15952    #[test]
15953    fn layer_raster_cache_candidate_does_not_force_translation_only_text_surfaces() {
15954        let text = RenderNode::Primitive(PrimitiveEntry {
15955            phase: PrimitivePhase::BeforeChildren,
15956            node: PrimitiveNode::Text(Box::new(TextPrimitiveNode {
15957                node_id: 77,
15958                rect: Rect {
15959                    x: 2.0,
15960                    y: 3.0,
15961                    width: 48.0,
15962                    height: 18.0,
15963                },
15964                text: std::rc::Rc::new(AnnotatedString::from("runtime cache")),
15965                text_style: TextStyle::default(),
15966                font_size: 14.0,
15967                layout_options: TextLayoutOptions::default(),
15968                clip: None,
15969            })),
15970        });
15971        let mut layer = test_layer(
15972            Rect {
15973                x: 0.0,
15974                y: 0.0,
15975                width: 64.0,
15976                height: 32.0,
15977            },
15978            vec![text],
15979        );
15980        layer.node_id = Some(77);
15981        layer.recompute_raster_cache_hashes();
15982
15983        assert!(
15984            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
15985            "root path should not isolate plain translation-only text layers"
15986        );
15987        assert!(
15988            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
15989            "child path should also render plain translation-only text layers directly"
15990        );
15991    }
15992
15993    #[test]
15994    fn layer_raster_cache_candidate_allows_stable_runtime_child_effect_surfaces() {
15995        let mut layer = test_layer(
15996            Rect {
15997                x: 0.0,
15998                y: 0.0,
15999                width: 64.0,
16000                height: 32.0,
16001            },
16002            vec![RenderNode::Primitive(PrimitiveEntry {
16003                phase: PrimitivePhase::BeforeChildren,
16004                node: PrimitiveNode::Draw(DrawPrimitiveNode {
16005                    primitive: DrawPrimitive::Rect {
16006                        rect: Rect {
16007                            x: 0.0,
16008                            y: 0.0,
16009                            width: 64.0,
16010                            height: 32.0,
16011                        },
16012                        brush: Brush::solid(Color::WHITE),
16013                        stroke: None,
16014                    },
16015                    clip: None,
16016                }),
16017            })],
16018        );
16019        layer.node_id = Some(78);
16020        layer.graphics_layer.render_effect = Some(RenderEffect::blur(4.0));
16021        layer.recompute_raster_cache_hashes();
16022
16023        assert!(
16024            layer_raster_cache_candidate(&layer, 1.0, false, false).is_none(),
16025            "root direct path should not force-cache ordinary stable effects"
16026        );
16027        assert!(
16028            layer_raster_cache_candidate(&layer, 1.0, false, true).is_some(),
16029            "child surface rendering should retain stable non-runtime effects"
16030        );
16031    }
16032
16033    #[test]
16034    fn layer_raster_cache_candidate_rejects_runtime_shader_child_effect_surfaces() {
16035        let mut layer = test_layer(
16036            Rect {
16037                x: 0.0,
16038                y: 0.0,
16039                width: 64.0,
16040                height: 32.0,
16041            },
16042            vec![],
16043        );
16044        layer.node_id = Some(79);
16045        layer.graphics_layer.render_effect = Some(RenderEffect::runtime_shader(
16046            RuntimeShader::new("runtime shader"),
16047        ));
16048        layer.recompute_raster_cache_hashes();
16049
16050        assert!(
16051            layer_raster_cache_candidate(&layer, 1.0, false, true).is_none(),
16052            "runtime shaders must not fill the retained layer cache with per-frame uniform variants"
16053        );
16054    }
16055
16056    #[test]
16057    fn layer_surface_requirements_keep_plain_text_on_direct_path() {
16058        let layer = text_layer_with_style(AnnotatedString::from("plain"), TextStyle::default());
16059
16060        let requirements = layer_surface_requirements(&layer);
16061
16062        assert_eq!(requirements.direct_translation, Some(Point::default()));
16063        assert!(requirements
16064            .surface_requirements
16065            .contains(SurfaceRequirement::PixelStableComposite));
16066        assert!(!requirements
16067            .surface_requirements
16068            .has_isolating_requirement());
16069    }
16070
16071    #[test]
16072    fn layer_surface_requirements_keep_translated_plain_text_leaf_on_direct_path() {
16073        let layer = pure_text_leaf(false, true);
16074
16075        let requirements = layer_surface_requirements(&layer);
16076
16077        assert_eq!(
16078            requirements.direct_translation,
16079            Some(Point::new(11.4, 23.6))
16080        );
16081        assert!(
16082            requirements
16083                .surface_requirements
16084                .contains(SurfaceRequirement::PixelStableComposite)
16085                && !requirements
16086                    .surface_requirements
16087                    .has_isolating_requirement(),
16088            "translated plain text should stay on the direct path and isolate only the glyph draw"
16089        );
16090    }
16091
16092    #[test]
16093    fn layer_surface_requirements_keep_translated_text_leaf_with_background_on_direct_path() {
16094        let layer = snapped_text_leaf(false, true);
16095
16096        let requirements = layer_surface_requirements(&layer);
16097
16098        assert_eq!(
16099            requirements.direct_translation,
16100            Some(Point::new(14.25, 16.5))
16101        );
16102        assert!(
16103            requirements
16104                .surface_requirements
16105                .contains(SurfaceRequirement::PixelStableComposite)
16106                && !requirements
16107                    .surface_requirements
16108                    .has_isolating_requirement(),
16109            "translated text with direct sibling decoration/background should keep the layer direct"
16110        );
16111    }
16112
16113    #[test]
16114    fn translated_plain_text_uses_bounded_snap_surface() {
16115        let root = pure_text_leaf_root(true, true);
16116        let mut rect_cache = HashMap::new();
16117        let mut requirements_cache = HashMap::new();
16118        let collected =
16119            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16120
16121        assert_eq!(collected.child_layers.len(), 1);
16122        assert!(collected.scene.texts.is_empty());
16123        assert!(collected.scene.effect_layers.is_empty());
16124        assert_snap_anchor_close(
16125            collected.child_layers[0].snap_anchor,
16126            Point::new(11.4, 23.6),
16127            "translated plain text's bounded local surface should composite at the content-origin snap phase",
16128        );
16129    }
16130
16131    /// Not a correctness test: a local timing harness for the shape-run
16132    /// collect path. Run manually with
16133    /// `cargo test --release -p cranpose-render-wgpu -- --ignored collect_timing --nocapture`.
16134    #[test]
16135    #[ignore]
16136    fn shape_run_collect_timing_harness() {
16137        use cranpose_render_common::graph::DrawPrimitiveNode;
16138        use cranpose_render_common::layer_composition::local_content_layer_for;
16139        use cranpose_ui_graphics::Stroke;
16140
16141        let bounds = Rect {
16142            x: 0.0,
16143            y: 0.0,
16144            width: 1080.0,
16145            height: 2244.0,
16146        };
16147        let graphics_layer = GraphicsLayer::default();
16148
16149        // A MEGA-BOSS-shaped workload: thousands of consecutive arcs, most
16150        // solid, some gradient, one text-free layer.
16151        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
16152        for i in 0..3000u32 {
16153            let f = i as f32;
16154            let brush = if i % 8 == 0 {
16155                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
16156            } else {
16157                Brush::Solid(Color(0.5, 0.2, 0.8, 1.0))
16158            };
16159            let center = Point::new(540.0 + (f % 400.0), 1122.0 + (f % 350.0));
16160            let radius = 8.0 + (i % 23) as f32;
16161            let half = radius + 4.0;
16162            nodes.push(DrawPrimitiveNode {
16163                primitive: DrawPrimitive::Arc {
16164                    rect: Rect {
16165                        x: center.x - half,
16166                        y: center.y - half,
16167                        width: half * 2.0,
16168                        height: half * 2.0,
16169                    },
16170                    brush,
16171                    center,
16172                    radius,
16173                    start_angle: f * 0.07,
16174                    sweep_angle: 0.5 + (i % 5) as f32,
16175                    stroke: (i % 3 != 0).then(|| Stroke::new(4.0)),
16176                    inner_radius: if i % 3 == 0 { radius * 0.6 } else { 0.0 },
16177                },
16178                clip: None,
16179            });
16180        }
16181
16182        let children: Vec<RenderNode> = nodes
16183            .iter()
16184            .map(|node| {
16185                RenderNode::Primitive(PrimitiveEntry {
16186                    phase: PrimitivePhase::BeforeChildren,
16187                    node: PrimitiveNode::Draw(node.clone()),
16188                })
16189            })
16190            .collect();
16191        let layer = crate::test_support::layer_node(
16192            bounds,
16193            ProjectiveTransform::identity(),
16194            graphics_layer,
16195            children,
16196        );
16197
16198        const ITERS: usize = 300;
16199
16200        // Reference: the pre-run per-primitive path.
16201        let local_layer = local_content_layer_for(&layer.graphics_layer);
16202        let start = Instant::now();
16203        let mut sink_shapes = 0usize;
16204        for _ in 0..ITERS {
16205            let mut scene = CompositorScene::new();
16206            for node in &nodes {
16207                crate::pipeline::push_draw_primitive(
16208                    &node.primitive,
16209                    bounds,
16210                    &local_layer,
16211                    None,
16212                    &mut scene,
16213                    None,
16214                    false,
16215                );
16216            }
16217            sink_shapes = scene.shapes.len();
16218        }
16219        let serial = start.elapsed();
16220
16221        let mut rect_cache = HashMap::new();
16222        let mut requirements_cache = HashMap::new();
16223        let start = Instant::now();
16224        let mut run_shapes = 0usize;
16225        for _ in 0..ITERS {
16226            let collected = collect_layer_contents(
16227                &layer,
16228                None,
16229                None,
16230                &mut rect_cache,
16231                &mut requirements_cache,
16232            );
16233            run_shapes = collected.scene.shapes.len();
16234        }
16235        let run = start.elapsed();
16236
16237        println!(
16238            "per-primitive: {:?}/iter ({sink_shapes} shapes)  shape-run: {:?}/iter ({run_shapes} shapes)",
16239            serial / ITERS as u32,
16240            run / ITERS as u32,
16241        );
16242    }
16243
16244    /// Shared body for the serial and forced-parallel equivalence tests:
16245    fn assert_shape_run_collect_matches_per_primitive_emission() {
16246        use cranpose_render_common::graph::DrawPrimitiveNode;
16247        use cranpose_render_common::layer_composition::local_content_layer_for;
16248        use cranpose_render_common::primitive_emit::{resolve_primitive_clip, PrimitiveClipSpace};
16249        use cranpose_ui_graphics::{CornerRadii, Stroke};
16250
16251        let bounds = Rect {
16252            x: 0.0,
16253            y: 0.0,
16254            width: 800.0,
16255            height: 800.0,
16256        };
16257        // Rotation keeps rigid snapping off, so both paths agree on
16258        // `snap_anchor: None` without replicating the anchor computation here.
16259        let graphics_layer = GraphicsLayer {
16260            scale: 1.25,
16261            translation_x: 3.5,
16262            translation_y: -2.0,
16263            alpha: 0.9,
16264            rotation_z: 0.35,
16265            ..GraphicsLayer::default()
16266        };
16267
16268        let mut nodes: Vec<DrawPrimitiveNode> = Vec::new();
16269        for i in 0..600u32 {
16270            let f = i as f32;
16271            let brush = if i % 11 == 0 {
16272                Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])
16273            } else {
16274                Brush::Solid(Color(0.1 + (i % 7) as f32 * 0.1, 0.5, 0.9, 1.0))
16275            };
16276            let stroke = (i % 5 == 0).then(|| Stroke::new(1.0 + (i % 3) as f32));
16277            let primitive = match i % 3 {
16278                0 => DrawPrimitive::Rect {
16279                    rect: Rect {
16280                        x: f % 37.0,
16281                        y: f % 53.0,
16282                        width: 8.0 + f % 9.0,
16283                        height: 6.0 + f % 5.0,
16284                    },
16285                    brush,
16286                    stroke,
16287                },
16288                1 => DrawPrimitive::RoundRect {
16289                    rect: Rect {
16290                        x: f % 41.0,
16291                        y: f % 43.0,
16292                        width: 12.0,
16293                        height: 10.0,
16294                    },
16295                    brush,
16296                    radii: CornerRadii::uniform(2.0 + (i % 4) as f32),
16297                    stroke,
16298                },
16299                _ => {
16300                    let center = Point::new(60.0 + f % 71.0, 60.0 + f % 67.0);
16301                    let radius = 5.0 + (i % 13) as f32;
16302                    // One degenerate sweep proves dropped draws stay dropped.
16303                    let sweep_angle = if i == 302 { 0.0 } else { 0.4 + (i % 6) as f32 };
16304                    let half = radius + 4.0;
16305                    DrawPrimitive::Arc {
16306                        rect: Rect {
16307                            x: center.x - half,
16308                            y: center.y - half,
16309                            width: half * 2.0,
16310                            height: half * 2.0,
16311                        },
16312                        brush,
16313                        center,
16314                        radius,
16315                        start_angle: f * 0.11,
16316                        sweep_angle,
16317                        stroke: (i % 2 == 0).then(|| Stroke::new(3.0)),
16318                        inner_radius: if i % 4 == 2 { radius * 0.5 } else { 0.0 },
16319                    }
16320                }
16321            };
16322            let primitive = if i == 300 {
16323                // A nested blend disqualifies the run view and forces a
16324                // mid-run flush through the serial path, splitting 600 draws
16325                // into two runs that are both long enough to fan out.
16326                DrawPrimitive::Blend {
16327                    primitive: Box::new(DrawPrimitive::Blend {
16328                        primitive: Box::new(primitive),
16329                        blend_mode: BlendMode::SrcOver,
16330                    }),
16331                    blend_mode: BlendMode::DstOut,
16332                }
16333            } else if i % 7 == 3 {
16334                DrawPrimitive::Blend {
16335                    primitive: Box::new(primitive),
16336                    blend_mode: BlendMode::DstOut,
16337                }
16338            } else {
16339                primitive
16340            };
16341            let clip = (i % 31 == 7).then_some(Rect {
16342                x: 0.0,
16343                y: 0.0,
16344                width: 30.0,
16345                height: 30.0,
16346            });
16347            nodes.push(DrawPrimitiveNode { primitive, clip });
16348        }
16349
16350        let children: Vec<RenderNode> = nodes
16351            .iter()
16352            .map(|node| {
16353                RenderNode::Primitive(PrimitiveEntry {
16354                    phase: PrimitivePhase::BeforeChildren,
16355                    node: PrimitiveNode::Draw(node.clone()),
16356                })
16357            })
16358            .collect();
16359        let layer = crate::test_support::layer_node(
16360            bounds,
16361            ProjectiveTransform::identity(),
16362            graphics_layer,
16363            children,
16364        );
16365
16366        let mut rect_cache = HashMap::new();
16367        let mut requirements_cache = HashMap::new();
16368        let collected =
16369            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
16370
16371        // The reference scene: every primitive through the per-primitive
16372        // emission path, exactly as the pre-run collect loop ran it.
16373        let local_layer = local_content_layer_for(&layer.graphics_layer);
16374        let mut expected = CompositorScene::new();
16375        for node in &nodes {
16376            let clip = resolve_primitive_clip(
16377                node.clip,
16378                bounds,
16379                &local_layer,
16380                None,
16381                PrimitiveClipSpace::Local,
16382            );
16383            if node.clip.is_some() && clip.is_none() {
16384                continue;
16385            }
16386            crate::pipeline::push_draw_primitive(
16387                &node.primitive,
16388                bounds,
16389                &local_layer,
16390                clip,
16391                &mut expected,
16392                None,
16393                false,
16394            );
16395        }
16396
16397        assert!(
16398            collected.scene.shapes.len() >= 590,
16399            "the runs should engage the parallel branch: got {} shapes",
16400            collected.scene.shapes.len()
16401        );
16402        assert_eq!(collected.scene.shapes.len(), expected.shapes.len());
16403        assert_eq!(collected.scene.draw_ops, expected.draw_ops);
16404        assert_eq!(collected.scene.next_z, expected.next_z);
16405        assert!(
16406            collected
16407                .scene
16408                .shapes
16409                .iter()
16410                .all(|s| s.snap_anchor.is_none()),
16411            "a rotated layer must not rigid-snap; the reference scene assumes it"
16412        );
16413        for (index, (got, want)) in collected
16414            .scene
16415            .shapes
16416            .iter()
16417            .zip(&expected.shapes)
16418            .enumerate()
16419        {
16420            assert_eq!(got.rect, want.rect, "shape {index} rect");
16421            assert_eq!(got.local_rect, want.local_rect, "shape {index} local_rect");
16422            assert_eq!(got.quad, want.quad, "shape {index} quad");
16423            assert_eq!(got.snap_anchor, want.snap_anchor, "shape {index} snap");
16424            assert_eq!(got.brush, want.brush, "shape {index} brush");
16425            assert_eq!(got.shape, want.shape, "shape {index} shape");
16426            assert_eq!(got.stroke, want.stroke, "shape {index} stroke");
16427            assert_eq!(got.arc, want.arc, "shape {index} arc");
16428            assert_eq!(got.z_index, want.z_index, "shape {index} z");
16429            assert_eq!(got.clip, want.clip, "shape {index} clip");
16430            assert_eq!(got.blend_mode, want.blend_mode, "shape {index} blend");
16431            assert_eq!(
16432                got.motion_context_animated, want.motion_context_animated,
16433                "shape {index} motion flag"
16434            );
16435        }
16436    }
16437
16438    /// The run collector must emit exactly what per-primitive emission does,
16439    /// on BOTH flush paths: the serial drain and the scoped-thread fan-out
16440    /// (forced via the tuning override, since a test-sized scene would never
16441    /// cross the size gate on its own).
16442    #[test]
16443    fn shape_run_collect_matches_per_primitive_emission_exactly() {
16444        assert_shape_run_collect_matches_per_primitive_emission();
16445        crate::normalized_scene::force_shape_run_parallel_for_tests(true);
16446        let outcome =
16447            std::panic::catch_unwind(assert_shape_run_collect_matches_per_primitive_emission);
16448        crate::normalized_scene::force_shape_run_parallel_for_tests(false);
16449        if let Err(payload) = outcome {
16450            std::panic::resume_unwind(payload);
16451        }
16452    }
16453
16454    #[test]
16455    fn non_translated_text_local_surface_keeps_linear_composite_resolve() {
16456        let layer = text_layer_with_style(
16457            AnnotatedString::from("gradient"),
16458            TextStyle::from_span_style(SpanStyle {
16459                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
16460                ..SpanStyle::default()
16461            }),
16462        );
16463        let requirements = layer_surface_requirements(&layer);
16464
16465        assert!(requirements
16466            .surface_requirements
16467            .contains(SurfaceRequirement::TextMaterialMask));
16468        assert_eq!(
16469            composite_sample_mode_for_requirements(false, false, requirements),
16470            CompositeSampleMode::Linear
16471        );
16472    }
16473
16474    #[test]
16475    fn inherited_translated_text_local_surface_uses_box4_layer_surface() {
16476        let layer = text_layer_with_style(
16477            AnnotatedString::from("shadow"),
16478            TextStyle::from_span_style(SpanStyle {
16479                shadow: Some(Shadow {
16480                    color: Color::BLACK,
16481                    offset: Point::new(1.0, 2.0),
16482                    blur_radius: 3.0,
16483                }),
16484                ..SpanStyle::default()
16485            }),
16486        );
16487        let requirements = layer_surface_requirements(&layer);
16488
16489        assert!(requirements
16490            .surface_requirements
16491            .contains(SurfaceRequirement::TextMaterialMask));
16492        assert_eq!(
16493            composite_sample_mode_for_requirements(true, false, requirements),
16494            CompositeSampleMode::Box4
16495        );
16496        assert_eq!(
16497            layer_surface_target_scale(
16498                true,
16499                false,
16500                requirements,
16501                1.25,
16502                layer_surface_scale(&layer)
16503            ),
16504            SurfaceRequirementSet::default()
16505                .with(SurfaceRequirement::TextMaterialMask)
16506                .with(SurfaceRequirement::MotionStableCapture)
16507                .target_scale(1.25, 1.0)
16508        );
16509    }
16510
16511    #[test]
16512    fn translated_text_local_surface_inside_capture_keeps_parent_scale() {
16513        let layer = text_layer_with_style(
16514            AnnotatedString::from("shadow"),
16515            TextStyle::from_span_style(SpanStyle {
16516                shadow: Some(Shadow {
16517                    color: Color::BLACK,
16518                    offset: Point::new(1.0, 2.0),
16519                    blur_radius: 3.0,
16520                }),
16521                ..SpanStyle::default()
16522            }),
16523        );
16524        let requirements = layer_surface_requirements(&layer);
16525
16526        assert_eq!(
16527            composite_sample_mode_for_requirements(true, true, requirements),
16528            CompositeSampleMode::Linear
16529        );
16530        assert_eq!(
16531            layer_surface_target_scale(true, true, requirements, 10.0, layer_surface_scale(&layer)),
16532            SurfaceRequirementSet::default()
16533                .with(SurfaceRequirement::TextMaterialMask)
16534                .target_scale(10.0, 1.0)
16535        );
16536    }
16537
16538    #[test]
16539    fn layer_surface_requirements_use_local_surface_for_gradient_and_stroke_text() {
16540        let cases = [
16541            (
16542                "draw_style",
16543                AnnotatedString::from("draw_style"),
16544                TextStyle::from_span_style(SpanStyle {
16545                    draw_style: Some(TextDrawStyle::Stroke { width: 2.0 }),
16546                    ..SpanStyle::default()
16547                }),
16548            ),
16549            (
16550                "gradient_brush",
16551                AnnotatedString::from("gradient"),
16552                TextStyle::from_span_style(SpanStyle {
16553                    brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
16554                    ..SpanStyle::default()
16555                }),
16556            ),
16557        ];
16558
16559        for (label, text, text_style) in cases {
16560            let layer = text_layer_with_style(text, text_style);
16561            let requirements = layer_surface_requirements(&layer);
16562            assert!(
16563                requirements
16564                    .surface_requirements
16565                    .contains(SurfaceRequirement::TextMaterialMask),
16566                "{label} text should use a bounded local surface: {requirements:?}"
16567            );
16568        }
16569    }
16570
16571    #[test]
16572    fn layer_surface_requirements_use_local_surface_for_complex_text_effects() {
16573        let cases = [
16574            (
16575                "shadow",
16576                AnnotatedString::from("shadow"),
16577                TextStyle::from_span_style(SpanStyle {
16578                    shadow: Some(Shadow {
16579                        color: Color::BLACK,
16580                        offset: Point::new(1.0, 2.0),
16581                        blur_radius: 3.0,
16582                    }),
16583                    ..SpanStyle::default()
16584                }),
16585            ),
16586            (
16587                "background",
16588                AnnotatedString::from("background"),
16589                TextStyle::from_span_style(SpanStyle {
16590                    background: Some(Color::BLACK),
16591                    ..SpanStyle::default()
16592                }),
16593            ),
16594            (
16595                "baseline_shift",
16596                AnnotatedString::from("baseline_shift"),
16597                TextStyle::from_span_style(SpanStyle {
16598                    baseline_shift: Some(BaselineShift::SUPERSCRIPT),
16599                    ..SpanStyle::default()
16600                }),
16601            ),
16602            (
16603                "geometric_transform",
16604                AnnotatedString::from("geometric_transform"),
16605                TextStyle::from_span_style(SpanStyle {
16606                    text_geometric_transform: Some(TextGeometricTransform {
16607                        scale_x: 1.2,
16608                        skew_x: 0.15,
16609                    }),
16610                    ..SpanStyle::default()
16611                }),
16612            ),
16613            (
16614                "letter_spacing",
16615                AnnotatedString::from("letter_spacing"),
16616                TextStyle::from_span_style(SpanStyle {
16617                    letter_spacing: TextUnit::Em(0.2),
16618                    ..SpanStyle::default()
16619                }),
16620            ),
16621        ];
16622
16623        for (label, text, text_style) in cases {
16624            let layer = text_layer_with_style(text, text_style);
16625            let requirements = layer_surface_requirements(&layer);
16626            assert!(
16627                requirements
16628                    .surface_requirements
16629                    .contains(SurfaceRequirement::TextMaterialMask),
16630                "{label} text should use a bounded local surface: {requirements:?}"
16631            );
16632            assert_eq!(
16633                requirements.direct_translation,
16634                Some(Point::default()),
16635                "{label} text should still classify as a direct translation"
16636            );
16637        }
16638    }
16639
16640    #[test]
16641    fn layer_surface_requirements_color_only_span_styles_use_direct_path() {
16642        let layer = text_layer_with_style(
16643            AnnotatedString {
16644                text: "styled".to_string(),
16645                span_styles: vec![RangeStyle {
16646                    item: SpanStyle {
16647                        color: Some(Color::BLACK),
16648                        ..SpanStyle::default()
16649                    },
16650                    range: 0..3,
16651                }],
16652                ..AnnotatedString::default()
16653            },
16654            TextStyle::default(),
16655        );
16656        let requirements = layer_surface_requirements(&layer);
16657        assert!(
16658            !requirements
16659                .surface_requirements
16660                .contains(SurfaceRequirement::TextMaterialMask),
16661            "color-only span styles should render directly via software text raster colors"
16662        );
16663    }
16664
16665    #[test]
16666    fn layer_surface_requirements_keep_decoration_only_text_on_direct_path() {
16667        let layer = text_layer_with_style(
16668            AnnotatedString::from("decoration"),
16669            TextStyle::from_span_style(SpanStyle {
16670                text_decoration: Some(TextDecoration::UNDERLINE),
16671                ..SpanStyle::default()
16672            }),
16673        );
16674
16675        let requirements = layer_surface_requirements(&layer);
16676
16677        assert_eq!(requirements.direct_translation, Some(Point::default()));
16678        assert!(
16679            requirements
16680                .surface_requirements
16681                .contains(SurfaceRequirement::PixelStableComposite)
16682                && !requirements
16683                    .surface_requirements
16684                    .has_isolating_requirement(),
16685            "decoration-only text should not force an isolating layer surface: {requirements:?}"
16686        );
16687    }
16688
16689    #[test]
16690    fn direct_text_leaf_snaps_modifier_background_and_text_with_one_anchor() {
16691        let root = snapped_text_leaf_root(false, false);
16692        let mut rect_cache = HashMap::new();
16693        let mut requirements_cache = HashMap::new();
16694
16695        let collected =
16696            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16697
16698        assert_eq!(collected.scene.shapes.len(), 1);
16699        assert_eq!(collected.scene.images.len(), 1);
16700        assert_eq!(collected.scene.texts.len(), 1);
16701        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
16702        assert_eq!(collected.scene.shapes[0].snap_anchor, expected_anchor);
16703        assert_eq!(collected.scene.images[0].snap_anchor, expected_anchor);
16704        assert_eq!(collected.scene.texts[0].snap_anchor, expected_anchor);
16705    }
16706
16707    #[test]
16708    fn animated_translated_content_text_leaf_uses_bounded_content_snap() {
16709        let root = snapped_text_leaf_root(true, true);
16710        let mut rect_cache = HashMap::new();
16711        let mut requirements_cache = HashMap::new();
16712
16713        let collected =
16714            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16715
16716        assert_eq!(collected.child_layers.len(), 1);
16717        assert!(collected.scene.shapes.is_empty());
16718        assert!(collected.scene.images.is_empty());
16719        assert!(collected.scene.texts.is_empty());
16720        assert!(collected.scene.effect_layers.is_empty());
16721        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
16722        assert_eq!(
16723            collected.child_layers[0].snap_anchor, expected_anchor,
16724            "active translated leaf surface should keep the content-origin snap phase"
16725        );
16726    }
16727
16728    #[test]
16729    fn translated_content_assigns_motion_anchor_to_rotated_child_surface() {
16730        let mut child = snapped_text_leaf(false, false);
16731        child.graphics_layer.rotation_z = 5.0;
16732        child.transform_to_parent =
16733            cranpose_render_common::layer_transform::layer_transform_to_parent(
16734                child.local_bounds,
16735                Point::new(108.0, 3.0),
16736                &child.graphics_layer,
16737            );
16738        child.recompute_raster_cache_hashes();
16739        let mut root = test_layer(
16740            Rect {
16741                x: 0.0,
16742                y: 0.0,
16743                width: 320.0,
16744                height: 180.0,
16745            },
16746            vec![RenderNode::Layer(Box::new(child))],
16747        );
16748        root.translated_content_context = true;
16749        root.translated_content_offset = Point::new(0.0, -80.8);
16750        root.recompute_raster_cache_hashes();
16751        let mut rect_cache = HashMap::new();
16752        let mut requirements_cache = HashMap::new();
16753
16754        let collected =
16755            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16756
16757        assert_eq!(collected.child_layers.len(), 1);
16758        assert!(
16759            collected.child_layers[0].snap_anchor.is_some(),
16760            "a projective child still translates rigidly with its scrolling parent"
16761        );
16762    }
16763
16764    #[test]
16765    fn rested_translated_content_context_text_leaf_snaps_for_crisp_scroll_rest() {
16766        let root = snapped_text_leaf_root(false, true);
16767        let mut rect_cache = HashMap::new();
16768        let mut requirements_cache = HashMap::new();
16769
16770        let collected =
16771            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16772
16773        assert_eq!(collected.child_layers.len(), 0);
16774        assert_eq!(collected.scene.shapes.len(), 1);
16775        assert_eq!(collected.scene.images.len(), 1);
16776        assert_eq!(collected.scene.texts.len(), 1);
16777        assert_eq!(collected.scene.effect_layers.len(), 0);
16778        let expected_anchor = Some(SnapAnchor::rigid(Point::new(14.25, 16.5)));
16779        assert_eq!(
16780            collected.scene.shapes[0].snap_anchor, expected_anchor,
16781            "rested scroll content should snap back to device pixels"
16782        );
16783        assert_eq!(
16784            collected.scene.images[0].snap_anchor, expected_anchor,
16785            "rested scroll images should snap back to device pixels"
16786        );
16787        assert_eq!(
16788            collected.scene.texts[0].snap_anchor, expected_anchor,
16789            "rested scroll text should snap back to device pixels"
16790        );
16791    }
16792
16793    #[test]
16794    fn complex_text_uses_local_surface() {
16795        let root = translated_content_local_surface_root();
16796        let mut rect_cache = HashMap::new();
16797        let mut requirements_cache = HashMap::new();
16798
16799        let collected =
16800            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16801
16802        assert!(
16803            !collected.child_layers.is_empty(),
16804            "translated-content effectful text should render through a bounded local surface"
16805        );
16806        assert!(collected.scene.texts.is_empty());
16807        assert!(collected.scene.shadow_draws.is_empty());
16808    }
16809
16810    #[test]
16811    fn translated_content_surface_composite_uses_scroll_content_snap_anchor() {
16812        let mut root = translated_content_local_surface_root();
16813        let scroll_offset = Point::new(0.0, -18.5);
16814        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
16815            panic!("expected translated content layer");
16816        };
16817        translated_content.translated_content_offset = scroll_offset;
16818        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
16819            panic!("expected effectful text layer");
16820        };
16821        effectful_text.transform_to_parent =
16822            effectful_text
16823                .transform_to_parent
16824                .then(ProjectiveTransform::translation(
16825                    scroll_offset.x,
16826                    scroll_offset.y,
16827                ));
16828
16829        let mut rect_cache = HashMap::new();
16830        let mut requirements_cache = HashMap::new();
16831        let collected =
16832            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16833
16834        assert_eq!(collected.child_layers.len(), 1);
16835        assert_eq!(
16836            collected.child_layers[0].snap_anchor,
16837            Some(SnapAnchor::rigid(Point::new(14.25, -2.0))),
16838            "isolated scrolled descendants must composite with the same content-origin snap phase"
16839        );
16840    }
16841
16842    #[test]
16843    fn animated_translated_content_surface_composite_uses_scroll_content_snap_anchor() {
16844        let mut root = translated_content_local_surface_root();
16845        let scroll_offset = Point::new(0.0, -18.5);
16846        let Some(RenderNode::Layer(translated_content)) = root.children.get_mut(0) else {
16847            panic!("expected translated content layer");
16848        };
16849        translated_content.motion_context_animated = true;
16850        translated_content.translated_content_offset = scroll_offset;
16851        let Some(RenderNode::Layer(effectful_text)) = translated_content.children.get_mut(0) else {
16852            panic!("expected effectful text layer");
16853        };
16854        effectful_text.transform_to_parent =
16855            effectful_text
16856                .transform_to_parent
16857                .then(ProjectiveTransform::translation(
16858                    scroll_offset.x,
16859                    scroll_offset.y,
16860                ));
16861
16862        let mut rect_cache = HashMap::new();
16863        let mut requirements_cache = HashMap::new();
16864        let collected =
16865            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
16866
16867        assert_eq!(collected.child_layers.len(), 1);
16868        assert_eq!(
16869            collected.child_layers[0].snap_anchor,
16870            Some(SnapAnchor::rigid(Point::new(14.25, 16.5))),
16871            "animated translated content should composite the stable local surface at the viewport-origin snap phase"
16872        );
16873    }
16874
16875    #[test]
16876    fn translated_text_material_effect_layer_uses_scroll_content_snap_anchor() {
16877        let mut layer = text_layer_with_style(
16878            AnnotatedString::from("gradient"),
16879            TextStyle::from_span_style(SpanStyle {
16880                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
16881                ..SpanStyle::default()
16882            }),
16883        );
16884        layer.translated_content_context = true;
16885        layer.translated_content_offset = Point::new(0.0, -18.5);
16886        let mut rect_cache = HashMap::new();
16887        let mut requirements_cache = HashMap::new();
16888
16889        let collected =
16890            collect_layer_contents(&layer, None, None, &mut rect_cache, &mut requirements_cache);
16891
16892        assert_eq!(collected.scene.effect_layers.len(), 1);
16893        assert_eq!(
16894            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
16895            CompositeSampleMode::Box4
16896        );
16897        assert_eq!(
16898            collected.scene.effect_layers[0].snap_anchor,
16899            Some(SnapAnchor::rigid(Point::new(0.0, -18.5))),
16900            "text material surfaces must composite with the scroll content-origin snap phase"
16901        );
16902    }
16903
16904    #[test]
16905    fn translated_layer_surface_capture_does_not_restart_local_picture_for_shadow_text() {
16906        let mut layer = text_layer_with_style(
16907            AnnotatedString::from("shadow"),
16908            TextStyle::from_span_style(SpanStyle {
16909                shadow: Some(Shadow {
16910                    color: Color::BLACK,
16911                    offset: Point::new(1.0, 2.0),
16912                    blur_radius: 3.0,
16913                }),
16914                ..SpanStyle::default()
16915            }),
16916        );
16917        layer.translated_content_context = true;
16918        let mut rect_cache = HashMap::new();
16919        let mut requirements_cache = HashMap::new();
16920
16921        let collected = collect_layer_contents_with_translation_context(
16922            &layer,
16923            None,
16924            None,
16925            TranslationRenderContext {
16926                inherited_content_translation: false,
16927                surface_capture_active: true,
16928                local_picture_capture_active: true,
16929                ..TranslationRenderContext::default()
16930            },
16931            &mut rect_cache,
16932            &mut requirements_cache,
16933        );
16934
16935        assert!(
16936            collected.scene.effect_layers.is_empty(),
16937            "a translated layer surface already provides the stable local capture"
16938        );
16939        assert_eq!(collected.scene.shadow_draws.len(), 1);
16940        assert_eq!(collected.scene.texts.len(), 1);
16941        assert!(
16942            !collected.scene.texts[0].translated_content_context,
16943            "text inside an active motion-stable capture must raster in capture-local coordinates"
16944        );
16945    }
16946
16947    #[test]
16948    fn translated_layer_surface_capture_keeps_only_material_effect_layers() {
16949        let mut layer = text_layer_with_style(
16950            AnnotatedString::from("gradient"),
16951            TextStyle::from_span_style(SpanStyle {
16952                brush: Some(Brush::linear_gradient(vec![Color::WHITE, Color::BLACK])),
16953                ..SpanStyle::default()
16954            }),
16955        );
16956        layer.translated_content_context = true;
16957        let mut rect_cache = HashMap::new();
16958        let mut requirements_cache = HashMap::new();
16959
16960        let collected = collect_layer_contents_with_translation_context(
16961            &layer,
16962            None,
16963            None,
16964            TranslationRenderContext {
16965                inherited_content_translation: false,
16966                surface_capture_active: true,
16967                local_picture_capture_active: true,
16968                ..TranslationRenderContext::default()
16969            },
16970            &mut rect_cache,
16971            &mut requirements_cache,
16972        );
16973
16974        assert_eq!(collected.scene.effect_layers.len(), 1);
16975        assert!(
16976            collected.scene.effect_layers[0]
16977                .requirements
16978                .contains(SurfaceRequirement::MotionStableCapture),
16979            "translated text materials still need motion-stable resolve semantics inside a stable capture"
16980        );
16981        assert_eq!(
16982            composite_sample_mode_for_effect_layer(&collected.scene.effect_layers[0]),
16983            CompositeSampleMode::Box4
16984        );
16985        assert_eq!(
16986            effect_layer_target_scale(&collected.scene.effect_layers[0], 10.0),
16987            10.0
16988        );
16989        assert!(collected.scene.effect_layers[0].effect.is_some());
16990    }
16991
16992    #[test]
16993    fn translated_viewport_surface_does_not_add_plain_local_picture_capture() {
16994        let mut layer = text_layer_with_style(
16995            AnnotatedString::from("shadow"),
16996            TextStyle::from_span_style(SpanStyle {
16997                shadow: Some(Shadow {
16998                    color: Color::BLACK,
16999                    offset: Point::new(1.0, 2.0),
17000                    blur_radius: 3.0,
17001                }),
17002                ..SpanStyle::default()
17003            }),
17004        );
17005        layer.translated_content_context = true;
17006        layer.motion_context_animated = true;
17007        let mut rect_cache = HashMap::new();
17008        let mut requirements_cache = HashMap::new();
17009
17010        let collected = collect_layer_contents_with_translation_context(
17011            &layer,
17012            None,
17013            None,
17014            TranslationRenderContext {
17015                surface_capture_active: true,
17016                ..TranslationRenderContext::default()
17017            },
17018            &mut rect_cache,
17019            &mut requirements_cache,
17020        );
17021
17022        assert_eq!(
17023            collected.scene.effect_layers.len(),
17024            0,
17025            "plain translated content inside a viewport surface should not be captured again"
17026        );
17027        assert_eq!(collected.scene.shadow_draws.len(), 1);
17028        assert_eq!(collected.scene.texts.len(), 1);
17029    }
17030
17031    #[test]
17032    fn static_pure_text_leaf_snaps_without_sibling_draw_primitives() {
17033        let root = pure_text_leaf_root(false, false);
17034        let mut rect_cache = HashMap::new();
17035        let mut requirements_cache = HashMap::new();
17036
17037        let collected =
17038            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17039
17040        assert_eq!(collected.scene.texts.len(), 1);
17041        assert!(
17042            collected.scene.texts[0].snap_anchor.is_some(),
17043            "idle pure text leaves should participate in rigid snap anchoring"
17044        );
17045    }
17046
17047    #[test]
17048    fn animated_pure_text_leaf_stays_unsnapped() {
17049        let root = pure_text_leaf_root(true, false);
17050        let mut rect_cache = HashMap::new();
17051        let mut requirements_cache = HashMap::new();
17052
17053        let collected =
17054            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17055
17056        assert_eq!(collected.scene.texts.len(), 1);
17057        assert_eq!(collected.scene.texts[0].snap_anchor, None);
17058    }
17059
17060    #[test]
17061    fn animated_translated_pure_text_uses_bounded_content_snap() {
17062        let root = pure_text_leaf_root(true, true);
17063        let mut rect_cache = HashMap::new();
17064        let mut requirements_cache = HashMap::new();
17065
17066        let collected =
17067            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17068
17069        assert_eq!(collected.child_layers.len(), 1);
17070        assert!(collected.scene.texts.is_empty());
17071        assert!(collected.scene.effect_layers.is_empty());
17072        assert_snap_anchor_close(
17073            collected.child_layers[0].snap_anchor,
17074            Point::new(11.4, 23.6),
17075            "animated translated pure text should use the bounded content snap phase",
17076        );
17077    }
17078
17079    #[test]
17080    fn rested_translated_pure_text_leaf_snaps_for_crisp_scroll_rest() {
17081        let root = pure_text_leaf_root(false, true);
17082        let mut rect_cache = HashMap::new();
17083        let mut requirements_cache = HashMap::new();
17084
17085        let collected =
17086            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17087
17088        assert_eq!(collected.child_layers.len(), 0);
17089        assert_eq!(collected.scene.texts.len(), 1);
17090        assert_eq!(collected.scene.effect_layers.len(), 0);
17091        assert_snap_anchor_close(
17092            collected.scene.texts[0].snap_anchor,
17093            Point::new(11.4, 23.6),
17094            "rested translated text should snap to device pixels",
17095        );
17096    }
17097
17098    #[test]
17099    fn static_gpu_effect_text_leaf_stays_unsnapped() {
17100        let root = text_layer_with_style(
17101            AnnotatedString::from("Gradient"),
17102            TextStyle::from_span_style(SpanStyle {
17103                brush: Some(Brush::linear_gradient(vec![
17104                    Color(0.2, 0.8, 1.0, 1.0),
17105                    Color(1.0, 0.7, 0.4, 1.0),
17106                ])),
17107                draw_style: Some(TextDrawStyle::Stroke { width: 2.5 }),
17108                ..SpanStyle::default()
17109            }),
17110        );
17111        let mut rect_cache = HashMap::new();
17112        let mut requirements_cache = HashMap::new();
17113
17114        let collected =
17115            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
17116
17117        assert_eq!(collected.scene.texts.len(), 1);
17118        assert_eq!(
17119            collected.scene.texts[0].snap_anchor, None,
17120            "gpu text-effect leaves must not take the rigid text snap path"
17121        );
17122        assert_eq!(
17123            collected.scene.effect_layers.len(),
17124            1,
17125            "gradient stroke text should still emit a runtime shader effect layer"
17126        );
17127    }
17128
17129    #[test]
17130    fn layer_surface_requirements_keep_shape_plus_direct_child_on_direct_path() {
17131        let mut child = test_layer(
17132            Rect {
17133                x: 0.0,
17134                y: 0.0,
17135                width: 40.0,
17136                height: 20.0,
17137            },
17138            vec![RenderNode::Primitive(PrimitiveEntry {
17139                phase: PrimitivePhase::BeforeChildren,
17140                node: PrimitiveNode::Draw(DrawPrimitiveNode {
17141                    primitive: DrawPrimitive::Rect {
17142                        rect: Rect {
17143                            x: 0.0,
17144                            y: 0.0,
17145                            width: 40.0,
17146                            height: 20.0,
17147                        },
17148                        brush: Brush::solid(Color::WHITE),
17149                        stroke: None,
17150                    },
17151                    clip: None,
17152                }),
17153            })],
17154        );
17155        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
17156
17157        let layer = test_layer(
17158            Rect {
17159                x: 0.0,
17160                y: 0.0,
17161                width: 64.0,
17162                height: 32.0,
17163            },
17164            vec![
17165                RenderNode::Primitive(PrimitiveEntry {
17166                    phase: PrimitivePhase::BeforeChildren,
17167                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
17168                        primitive: DrawPrimitive::Rect {
17169                            rect: Rect {
17170                                x: 0.0,
17171                                y: 0.0,
17172                                width: 64.0,
17173                                height: 32.0,
17174                            },
17175                            brush: Brush::solid(Color::BLACK),
17176                            stroke: None,
17177                        },
17178                        clip: None,
17179                    }),
17180                }),
17181                RenderNode::Layer(Box::new(child)),
17182            ],
17183        );
17184
17185        let requirements = layer_surface_requirements(&layer);
17186
17187        assert_eq!(requirements.direct_translation, Some(Point::default()));
17188        assert!(!requirements
17189            .surface_requirements
17190            .contains(SurfaceRequirement::MixedDirectContent));
17191        assert!(!requirements
17192            .surface_requirements
17193            .has_isolating_requirement());
17194    }
17195
17196    #[test]
17197    fn collect_layer_contents_translates_direct_text_rects_into_parent_space() {
17198        let mut child = text_layer_with_style(
17199            AnnotatedString::from("direct"),
17200            TextStyle::from_span_style(SpanStyle {
17201                text_decoration: Some(TextDecoration::UNDERLINE),
17202                ..SpanStyle::default()
17203            }),
17204        );
17205        child.transform_to_parent = ProjectiveTransform::translation(9.0, 7.0);
17206
17207        let parent = test_layer(
17208            Rect {
17209                x: 0.0,
17210                y: 0.0,
17211                width: 64.0,
17212                height: 32.0,
17213            },
17214            vec![RenderNode::Layer(Box::new(child))],
17215        );
17216
17217        let mut rect_cache = HashMap::new();
17218        let mut requirements_cache = HashMap::new();
17219        let collected = with_test_app_context(|| {
17220            collect_layer_contents(
17221                &parent,
17222                None,
17223                None,
17224                &mut rect_cache,
17225                &mut requirements_cache,
17226            )
17227        });
17228
17229        assert!(
17230            collected.child_layers.is_empty(),
17231            "decoration-only text child should collapse directly into the parent scene"
17232        );
17233        assert_eq!(collected.scene.texts.len(), 1, "expected one text draw");
17234        let text = &collected.scene.texts[0];
17235        assert!(
17236            text.rect.x >= 9.0 && text.rect.y >= 7.0,
17237            "collapsed text rect should be translated into parent space, got {:?}",
17238            text.rect
17239        );
17240        assert!(
17241            collected
17242                .scene
17243                .shapes
17244                .iter()
17245                .any(|shape| shape.rect.y >= 7.0),
17246            "collapsed underline geometry should also be translated into parent space"
17247        );
17248    }
17249
17250    #[test]
17251    fn normalized_scene_keeps_lazy_after_bound_text_for_prewarm() {
17252        use std::cell::RefCell;
17253
17254        fn collect_graph_text_labels(layer: &LayerNode, labels: &mut Vec<String>) {
17255            for child in &layer.children {
17256                match child {
17257                    RenderNode::Primitive(PrimitiveEntry {
17258                        node: PrimitiveNode::Text(text),
17259                        ..
17260                    }) => labels.push(text.text.text.clone()),
17261                    RenderNode::Layer(child_layer) => {
17262                        collect_graph_text_labels(child_layer, labels)
17263                    }
17264                    RenderNode::Primitive(_) | RenderNode::DrawRun(_) => {}
17265                }
17266            }
17267        }
17268
17269        let state_holder: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
17270        let state_holder_for_comp = state_holder.clone();
17271        let mut composition = cranpose_ui::run_test_composition(move || {
17272            let list_state = remember_lazy_list_state();
17273            *state_holder_for_comp.borrow_mut() = Some(list_state);
17274            let mut spec = LazyColumnSpec::new()
17275                .vertical_arrangement(cranpose_ui::LinearArrangement::SpacedBy(6.0));
17276            spec.beyond_bounds_item_count = 0;
17277            LazyColumn(Modifier::empty().height(96.0), list_state, spec, |scope| {
17278                scope.items(
17279                    12,
17280                    None::<fn(usize) -> u64>,
17281                    None::<fn(usize) -> u64>,
17282                    |index| {
17283                        Text(
17284                            format!("WarmRow {index}"),
17285                            Modifier::empty().height(32.0),
17286                            TextStyle::default(),
17287                        );
17288                    },
17289                );
17290            });
17291        });
17292
17293        let list_state = (*state_holder.borrow()).expect("lazy list state should be captured");
17294        list_state.scroll_to_item(4, 0.0);
17295
17296        let root = composition.root().expect("lazy column root");
17297        let handle = composition.runtime_handle();
17298        let mut applier = composition.applier_mut();
17299        applier.set_runtime_handle(handle);
17300        let _ = applier
17301            .compute_layout(
17302                root,
17303                Size {
17304                    width: 240.0,
17305                    height: 240.0,
17306                },
17307            )
17308            .expect("lazy column layout");
17309        let graph = build_graph_from_applier(&mut applier, root, 1.0).expect("lazy column graph");
17310        applier.clear_runtime_handle();
17311        let mut graph_labels = Vec::new();
17312        collect_graph_text_labels(&graph.root, &mut graph_labels);
17313
17314        let visible_indices: Vec<_> = list_state
17315            .layout_info()
17316            .visible_items_info
17317            .iter()
17318            .map(|item| item.index)
17319            .collect();
17320        assert_eq!(
17321            visible_indices,
17322            vec![4, 5, 6],
17323            "test setup expects exactly three viewport-visible rows"
17324        );
17325
17326        let mut rect_cache = HashMap::new();
17327        let mut requirements_cache = HashMap::new();
17328        let collected = with_test_app_context(|| {
17329            collect_layer_contents(
17330                &graph.root,
17331                None,
17332                None,
17333                &mut rect_cache,
17334                &mut requirements_cache,
17335            )
17336        });
17337        let root_text_labels: Vec<_> = collected
17338            .scene
17339            .texts
17340            .iter()
17341            .map(|text| text.text.text.clone())
17342            .collect();
17343        let child_layer_count = collected.child_layers.len();
17344        let warm_text = collected
17345            .scene
17346            .texts
17347            .iter()
17348            .find(|text| text.text.text == "WarmRow 7")
17349            .unwrap_or_else(|| {
17350                panic!(
17351                    "after-bound lazy text should reach WGPU scene collection; graph_texts={graph_labels:?} root_texts={root_text_labels:?} child_layers={child_layer_count}"
17352                )
17353            });
17354
17355        assert!(
17356            warm_text.rect.y >= 96.0,
17357            "after-bound text should be below the viewport, got {:?}",
17358            warm_text.rect
17359        );
17360        assert_eq!(
17361            visible_draw_rect(warm_text.rect, warm_text.clip),
17362            None,
17363            "after-bound text should remain clipped away for drawing while staying available for glyph prewarm"
17364        );
17365        assert!(
17366            text_draw_should_prewarm_in_viewport(
17367                warm_text.rect,
17368                warm_text.clip,
17369                ViewportUniformParams {
17370                    width: 240,
17371                    height: 96,
17372                    offset: [0.0, 0.0],
17373                },
17374                1.0,
17375            ),
17376            "after-bound text inside the warm window must be selected by WGPU prewarm"
17377        );
17378    }
17379
17380    #[test]
17381    fn direct_translation_accepts_nearly_identity_axis_scale_noise() {
17382        let local_bounds = Rect {
17383            x: 0.0,
17384            y: 0.0,
17385            width: 393.3,
17386            height: 16.8,
17387        };
17388        let quad = [
17389            [10.0, 78.399_994],
17390            [403.3, 78.399_994],
17391            [10.0, 95.2],
17392            [403.3, 95.2],
17393        ];
17394        let transform = ProjectiveTransform::from_rect_to_quad(local_bounds, quad);
17395
17396        assert_eq!(
17397            direct_translation(transform),
17398            Some(Point::new(10.0, 78.399_994)),
17399        );
17400    }
17401
17402    #[test]
17403    fn layer_surface_requirements_keep_shape_plus_isolating_child_as_mixed_content() {
17404        let mut child = test_layer(
17405            Rect {
17406                x: 0.0,
17407                y: 0.0,
17408                width: 24.0,
17409                height: 18.0,
17410            },
17411            vec![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: 24.0,
17419                            height: 18.0,
17420                        },
17421                        brush: Brush::solid(Color::WHITE),
17422                        stroke: None,
17423                    },
17424                    clip: None,
17425                }),
17426            })],
17427        );
17428        child.transform_to_parent = ProjectiveTransform::translation(8.0, 6.0);
17429        child.graphics_layer.render_effect = Some(RenderEffect::blur(2.0));
17430
17431        let layer = test_layer(
17432            Rect {
17433                x: 0.0,
17434                y: 0.0,
17435                width: 64.0,
17436                height: 32.0,
17437            },
17438            vec![
17439                RenderNode::Primitive(PrimitiveEntry {
17440                    phase: PrimitivePhase::BeforeChildren,
17441                    node: PrimitiveNode::Draw(DrawPrimitiveNode {
17442                        primitive: DrawPrimitive::Rect {
17443                            rect: Rect {
17444                                x: 0.0,
17445                                y: 0.0,
17446                                width: 64.0,
17447                                height: 32.0,
17448                            },
17449                            brush: Brush::solid(Color::BLACK),
17450                            stroke: None,
17451                        },
17452                        clip: None,
17453                    }),
17454                }),
17455                RenderNode::Layer(Box::new(child)),
17456            ],
17457        );
17458
17459        let requirements = layer_surface_requirements(&layer);
17460
17461        assert!(requirements
17462            .surface_requirements
17463            .contains(SurfaceRequirement::MixedDirectContent));
17464        assert!(!requirements
17465            .surface_requirements
17466            .has_isolating_requirement());
17467    }
17468
17469    #[test]
17470    fn build_scene_window_filters_and_translates_items() {
17471        let mut shape = test_shape(6, BlendMode::SrcOver);
17472        shape.rect.x = 12.0;
17473        shape.rect.y = 25.0;
17474        shape.local_rect.x = 12.0;
17475        shape.local_rect.y = 25.0;
17476        shape.quad = [[12.0, 25.0], [20.0, 25.0], [12.0, 33.0], [20.0, 33.0]];
17477        shape.clip = Some(Rect {
17478            x: 11.0,
17479            y: 24.0,
17480            width: 10.0,
17481            height: 10.0,
17482        });
17483
17484        let mut image = test_image(8, BlendMode::SrcOver);
17485        image.rect.x = 18.0;
17486        image.rect.y = 27.0;
17487        image.local_rect.x = 18.0;
17488        image.local_rect.y = 27.0;
17489        image.quad = [[18.0, 27.0], [26.0, 27.0], [18.0, 35.0], [26.0, 35.0]];
17490
17491        let mut text = test_text(9);
17492        text.rect.x = 16.0;
17493        text.rect.y = 29.0;
17494        text.clip = Some(Rect {
17495            x: 15.0,
17496            y: 28.0,
17497            width: 9.0,
17498            height: 6.0,
17499        });
17500
17501        let mut shadow_shape = test_shape(7, BlendMode::SrcOver);
17502        shadow_shape.rect.x = 14.0;
17503        shadow_shape.rect.y = 26.0;
17504        shadow_shape.local_rect.x = 14.0;
17505        shadow_shape.local_rect.y = 26.0;
17506        shadow_shape.quad = [[14.0, 26.0], [22.0, 26.0], [14.0, 34.0], [22.0, 34.0]];
17507        let mut shadow = test_shadow_draw(vec![(shadow_shape, BlendMode::SrcOver)]);
17508        shadow.z_index = 7;
17509
17510        let mut nested_effect = effect_layer(6, 10);
17511        nested_effect.rect.x = 13.0;
17512        nested_effect.rect.y = 24.0;
17513        nested_effect.clip = Some(Rect {
17514            x: 15.0,
17515            y: 25.0,
17516            width: 4.0,
17517            height: 5.0,
17518        });
17519
17520        let mut nested_backdrop = backdrop_layer(8);
17521        nested_backdrop.rect.x = 17.0;
17522        nested_backdrop.rect.y = 26.0;
17523        nested_backdrop.clip = Some(Rect {
17524            x: 18.0,
17525            y: 27.0,
17526            width: 3.0,
17527            height: 4.0,
17528        });
17529
17530        let window = build_scene_window(
17531            SceneWindowSource {
17532                shapes: &[test_shape(4, BlendMode::SrcOver), shape],
17533                images: &[image],
17534                texts: &[text],
17535                shadow_draws: &[shadow],
17536                draw_ops: &[],
17537                effect_layers: &[effect_layer(2, 4), nested_effect.clone()],
17538                backdrop_layers: &[backdrop_layer(4), nested_backdrop.clone()],
17539            },
17540            5,
17541            10,
17542            Rect {
17543                x: 10.0,
17544                y: 20.0,
17545                width: 20.0,
17546                height: 20.0,
17547            },
17548        );
17549
17550        assert_eq!(window.shapes.len(), 1);
17551        assert_eq!(
17552            window.shapes[0].rect,
17553            Rect {
17554                x: 2.0,
17555                y: 5.0,
17556                width: 8.0,
17557                height: 8.0,
17558            }
17559        );
17560        assert_eq!(
17561            window.shapes[0].clip,
17562            Some(Rect {
17563                x: 1.0,
17564                y: 4.0,
17565                width: 10.0,
17566                height: 10.0,
17567            })
17568        );
17569        assert_eq!(window.images.len(), 1);
17570        assert_eq!(window.images[0].rect.x, 8.0);
17571        assert_eq!(window.images[0].rect.y, 7.0);
17572        assert_eq!(window.texts.len(), 1);
17573        assert_eq!(window.texts[0].rect.x, 6.0);
17574        assert_eq!(window.texts[0].rect.y, 9.0);
17575        assert_eq!(
17576            window.texts[0].clip,
17577            Some(Rect {
17578                x: 5.0,
17579                y: 8.0,
17580                width: 9.0,
17581                height: 6.0,
17582            })
17583        );
17584        assert_eq!(window.shadow_draws.len(), 1);
17585        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.x, 4.0);
17586        assert_eq!(window.shadow_draws[0].shapes[0].0.rect.y, 6.0);
17587        assert_eq!(window.effect_layers.len(), 1);
17588        assert_eq!(
17589            window.effect_layers[0].rect,
17590            Rect {
17591                x: 3.0,
17592                y: 4.0,
17593                width: 10.0,
17594                height: 10.0,
17595            }
17596        );
17597        assert_eq!(
17598            window.effect_layers[0].clip,
17599            Some(Rect {
17600                x: 5.0,
17601                y: 5.0,
17602                width: 4.0,
17603                height: 5.0,
17604            })
17605        );
17606        assert_eq!(window.backdrop_layers.len(), 1);
17607        assert_eq!(
17608            window.backdrop_layers[0].rect,
17609            Rect {
17610                x: 7.0,
17611                y: 6.0,
17612                width: 10.0,
17613                height: 10.0,
17614            }
17615        );
17616        assert_eq!(
17617            window.backdrop_layers[0].clip,
17618            Some(Rect {
17619                x: 8.0,
17620                y: 7.0,
17621                width: 3.0,
17622                height: 4.0,
17623            })
17624        );
17625    }
17626
17627    #[test]
17628    fn filtered_effect_layer_index_counts_only_window_members() {
17629        let effects = vec![
17630            effect_layer(0, 2),
17631            effect_layer(5, 12),
17632            effect_layer(6, 10),
17633            effect_layer(14, 20),
17634        ];
17635
17636        assert_eq!(filtered_effect_layer_index(&effects, 1, 5, 12), Some(0));
17637        assert_eq!(filtered_effect_layer_index(&effects, 2, 5, 12), Some(1));
17638        assert_eq!(filtered_effect_layer_index(&effects, 3, 5, 12), None);
17639    }
17640
17641    #[test]
17642    fn blend_mode_support_matrix_is_explicit() {
17643        assert!(is_blend_mode_supported(BlendMode::SrcOver));
17644        assert!(is_blend_mode_supported(BlendMode::DstOut));
17645        assert!(!is_blend_mode_supported(BlendMode::Clear));
17646        assert!(!is_blend_mode_supported(BlendMode::Multiply));
17647    }
17648
17649    #[test]
17650    fn collect_non_effect_segment_items_preserves_global_z_order() {
17651        let shapes = vec![
17652            test_shape(3, BlendMode::SrcOver),
17653            test_shape(1, BlendMode::DstOut),
17654        ];
17655        let images = vec![test_image(2, BlendMode::SrcOver)];
17656        let texts = vec![test_text(0)];
17657        let shadows: Vec<ShadowDraw> = Vec::new();
17658        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
17659
17660        let mut scratch = Vec::new();
17661        collect_non_effect_segment_items(
17662            &shapes,
17663            &images,
17664            &texts,
17665            &shadows,
17666            &draw_ops,
17667            0,
17668            4,
17669            &[],
17670            100,
17671            100,
17672            1.0,
17673            &mut scratch,
17674        );
17675        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
17676        assert_eq!(
17677            items,
17678            vec![
17679                SegmentDrawItem::Text(0),
17680                SegmentDrawItem::Shape(1),
17681                SegmentDrawItem::Image(0),
17682                SegmentDrawItem::Shape(0),
17683            ]
17684        );
17685    }
17686
17687    #[test]
17688    fn collect_non_effect_segment_items_filters_effect_ranges() {
17689        let shapes = vec![
17690            test_shape(1, BlendMode::SrcOver),
17691            test_shape(3, BlendMode::DstOut),
17692        ];
17693        let images = vec![test_image(2, BlendMode::SrcOver)];
17694        let texts = vec![test_text(4)];
17695        let shadows: Vec<ShadowDraw> = Vec::new();
17696        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
17697        let effect_ranges = [std::ops::Range { start: 2, end: 4 }];
17698
17699        let mut scratch = Vec::new();
17700        collect_non_effect_segment_items(
17701            &shapes,
17702            &images,
17703            &texts,
17704            &shadows,
17705            &draw_ops,
17706            0,
17707            5,
17708            &effect_ranges,
17709            100,
17710            100,
17711            1.0,
17712            &mut scratch,
17713        );
17714        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
17715        assert_eq!(
17716            items,
17717            vec![SegmentDrawItem::Shape(0), SegmentDrawItem::Text(0)]
17718        );
17719    }
17720
17721    #[test]
17722    fn collect_non_effect_segment_items_culls_offscreen_shapes_but_keeps_text_prewarm() {
17723        let mut shape = test_shape(0, BlendMode::SrcOver);
17724        shape.rect.y = 160.0;
17725        shape.local_rect.y = 160.0;
17726        shape.quad = [[0.0, 160.0], [8.0, 160.0], [0.0, 168.0], [8.0, 168.0]];
17727
17728        let shapes = vec![shape];
17729        let images = Vec::new();
17730        let mut text = test_text(1);
17731        text.rect.y = 160.0;
17732        let texts = vec![text];
17733        let shadows: Vec<ShadowDraw> = Vec::new();
17734        let draw_ops = test_draw_ops(&shapes, &images, &texts, &shadows);
17735
17736        let mut scratch = Vec::new();
17737        collect_non_effect_segment_items(
17738            &shapes,
17739            &images,
17740            &texts,
17741            &shadows,
17742            &draw_ops,
17743            0,
17744            2,
17745            &[],
17746            100,
17747            100,
17748            1.0,
17749            &mut scratch,
17750        );
17751
17752        let items: Vec<_> = scratch.iter().map(|(_, item)| *item).collect();
17753        assert_eq!(items, vec![SegmentDrawItem::Text(0)]);
17754    }
17755
17756    #[test]
17757    fn segment_command_iter_merges_non_conflicting_batches_into_one_chunk() {
17758        let ordered_items = vec![
17759            (0, SegmentDrawItem::Shape(0)),
17760            (1, SegmentDrawItem::Image(0)),
17761            (2, SegmentDrawItem::Text(0)),
17762        ];
17763        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
17764        let images = vec![test_image(1, BlendMode::DstOut)];
17765
17766        let commands: Vec<_> = SegmentCommandIter::new(
17767            &ordered_items,
17768            &shapes,
17769            &images,
17770            ShapeBatchLimits::desktop(),
17771        )
17772        .collect();
17773
17774        assert_eq!(
17775            commands,
17776            vec![SegmentRenderCommand::DrawChunk(chunk(&[
17777                SegmentBatchPlan::Shape {
17778                    start: 0,
17779                    end: 1,
17780                    blend_mode: BlendMode::SrcOver,
17781                },
17782                SegmentBatchPlan::Image {
17783                    start: 1,
17784                    end: 2,
17785                    blend_mode: BlendMode::DstOut,
17786                },
17787                SegmentBatchPlan::Text { start: 2, end: 3 },
17788            ]))]
17789        );
17790    }
17791
17792    #[test]
17793    fn segment_command_iter_keeps_layer_composites_in_ordered_draw_chunk() {
17794        let ordered_items = vec![
17795            (0, SegmentDrawItem::Shape(0)),
17796            (1, SegmentDrawItem::Composite(0)),
17797            (2, SegmentDrawItem::Image(0)),
17798            (3, SegmentDrawItem::Composite(1)),
17799            (4, SegmentDrawItem::Text(0)),
17800        ];
17801        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
17802        let images = vec![test_image(2, BlendMode::SrcOver)];
17803
17804        let commands: Vec<_> = SegmentCommandIter::new(
17805            &ordered_items,
17806            &shapes,
17807            &images,
17808            ShapeBatchLimits::desktop(),
17809        )
17810        .collect();
17811
17812        assert_eq!(
17813            commands,
17814            vec![SegmentRenderCommand::DrawChunk(chunk(&[
17815                SegmentBatchPlan::Shape {
17816                    start: 0,
17817                    end: 1,
17818                    blend_mode: BlendMode::SrcOver,
17819                },
17820                SegmentBatchPlan::Composite { start: 1, end: 2 },
17821                SegmentBatchPlan::Image {
17822                    start: 2,
17823                    end: 3,
17824                    blend_mode: BlendMode::SrcOver,
17825                },
17826                SegmentBatchPlan::Composite { start: 3, end: 4 },
17827                SegmentBatchPlan::Text { start: 4, end: 5 },
17828            ]))]
17829        );
17830    }
17831
17832    #[test]
17833    fn retain_renderable_shadow_items_culls_invisible_shadow_boundaries() {
17834        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
17835        let images = vec![test_image(2, BlendMode::SrcOver)];
17836        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
17837        shadow_shape.rect = Rect {
17838            x: 500.0,
17839            y: 500.0,
17840            width: 12.0,
17841            height: 12.0,
17842        };
17843        let shadow_draws = vec![ShadowDraw {
17844            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
17845            texts: Vec::new(),
17846            blur_radius: 8.0,
17847            clip: None,
17848            z_index: 1,
17849        }];
17850        let mut ordered_items = vec![
17851            (0, SegmentDrawItem::Shape(0)),
17852            (1, SegmentDrawItem::Shadow(0)),
17853            (2, SegmentDrawItem::Image(0)),
17854        ];
17855
17856        let culled =
17857            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
17858        let commands: Vec<_> = SegmentCommandIter::new(
17859            &ordered_items,
17860            &shapes,
17861            &images,
17862            ShapeBatchLimits::desktop(),
17863        )
17864        .collect();
17865
17866        assert_eq!(culled, 1);
17867        assert_eq!(
17868            commands,
17869            vec![SegmentRenderCommand::DrawChunk(chunk(&[
17870                SegmentBatchPlan::Shape {
17871                    start: 0,
17872                    end: 1,
17873                    blend_mode: BlendMode::SrcOver,
17874                },
17875                SegmentBatchPlan::Image {
17876                    start: 1,
17877                    end: 2,
17878                    blend_mode: BlendMode::SrcOver,
17879                },
17880            ]))]
17881        );
17882    }
17883
17884    #[test]
17885    fn retain_renderable_shadow_items_keeps_visible_shadow_boundaries() {
17886        let mut shadow_shape = test_shape(1, BlendMode::SrcOver);
17887        shadow_shape.rect = Rect {
17888            x: 20.0,
17889            y: 20.0,
17890            width: 12.0,
17891            height: 12.0,
17892        };
17893        let shadow_draws = vec![ShadowDraw {
17894            shapes: vec![(shadow_shape, BlendMode::SrcOver)],
17895            texts: Vec::new(),
17896            blur_radius: 8.0,
17897            clip: None,
17898            z_index: 1,
17899        }];
17900        let mut ordered_items = vec![(1, SegmentDrawItem::Shadow(0))];
17901
17902        let culled =
17903            retain_renderable_shadow_items(&mut ordered_items, &shadow_draws, 100, 100, 1.0, 4096);
17904
17905        assert_eq!(culled, 0);
17906        assert_eq!(ordered_items, vec![(1, SegmentDrawItem::Shadow(0))]);
17907    }
17908
17909    #[test]
17910    fn shape_data_layout_matches_the_wgsl_mirror() {
17911        // 10 x vec4-sized slots. The uniform address space requires a 16-byte
17912        // multiple, and `shape.wgsl`'s array length literal is derived from
17913        // this size — if it drifts, batches silently overrun the binding.
17914        assert_eq!(std::mem::size_of::<ShapeData>(), 160);
17915        assert_eq!(std::mem::size_of::<ShapeData>() % 16, 0);
17916        assert_eq!(std::mem::size_of::<GradientStop>(), 32);
17917    }
17918
17919    #[test]
17920    fn shape_flags_pack_kind_cap_and_join_without_collision() {
17921        assert_eq!(
17922            pack_shape_flags(SHAPE_KIND_FILL, StrokeCap::Butt, StrokeJoin::Miter),
17923            0.0
17924        );
17925        assert_eq!(
17926            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Miter),
17927            1.0
17928        );
17929        assert_eq!(
17930            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Butt, StrokeJoin::Miter),
17931            2.0
17932        );
17933        // cap in bits 2-3, join in bits 4-5
17934        assert_eq!(
17935            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Round, StrokeJoin::Miter),
17936            2.0 + 4.0
17937        );
17938        assert_eq!(
17939            pack_shape_flags(SHAPE_KIND_ARC, StrokeCap::Square, StrokeJoin::Miter),
17940            2.0 + 8.0
17941        );
17942        assert_eq!(
17943            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Round),
17944            1.0 + 16.0
17945        );
17946        assert_eq!(
17947            pack_shape_flags(SHAPE_KIND_STROKE, StrokeCap::Butt, StrokeJoin::Bevel),
17948            1.0 + 32.0
17949        );
17950        // Every combination must round-trip through f32 exactly.
17951        for kind in [SHAPE_KIND_FILL, SHAPE_KIND_STROKE, SHAPE_KIND_ARC] {
17952            for cap in [StrokeCap::Butt, StrokeCap::Round, StrokeCap::Square] {
17953                for join in [StrokeJoin::Miter, StrokeJoin::Round, StrokeJoin::Bevel] {
17954                    let packed = pack_shape_flags(kind, cap, join);
17955                    let bits = packed as u32;
17956                    assert_eq!(bits & 3, kind);
17957                    assert_eq!((bits >> 2) & 3, stroke_cap_code(cap));
17958                    assert_eq!((bits >> 4) & 3, stroke_join_code(join));
17959                    assert_eq!(packed, bits as f32, "flags must be exact in f32");
17960                }
17961            }
17962        }
17963    }
17964
17965    #[cfg(not(target_arch = "wasm32"))]
17966    #[test]
17967    fn mesh_vertex_layout_matches_the_wgsl_input() {
17968        // {pos: vec2<f32>, uv: vec2<f32>, shape_idx: u32} = 20 bytes, no
17969        // padding — the vertex buffer layout stride relies on it.
17970        assert_eq!(std::mem::size_of::<MeshVertex>(), 20);
17971    }
17972
17973    /// f32 port of `sdf_arc_band` (shape.wgsl), operation for operation: the
17974    /// same ra/rb derivation and clamp, the same mirror trick (`abs` on the
17975    /// rotated x), the same cap branches.
17976    #[cfg(not(target_arch = "wasm32"))]
17977    #[allow(clippy::too_many_arguments)]
17978    fn sdf_arc_band_reference(
17979        p: [f32; 2],
17980        center: [f32; 2],
17981        inner: f32,
17982        outer: f32,
17983        mid_sin_cos: [f32; 2],
17984        half_sin_cos: [f32; 2],
17985        cap: u32,
17986    ) -> f32 {
17987        let ra = (outer + inner) * 0.5;
17988        let rb = ((outer - inner) * 0.5).max(0.0);
17989        let sm = mid_sin_cos[0];
17990        let cm = mid_sin_cos[1];
17991        let d = [p[0] - center[0], p[1] - center[1]];
17992        let mut q = [-sm * d[0] + cm * d[1], cm * d[0] + sm * d[1]];
17993        q[0] = q[0].abs();
17994        let sc = half_sin_cos;
17995        let mut dist = if sc[1] * q[0] > sc[0] * q[1] {
17996            let dx = q[0] - sc[0] * ra;
17997            let dy = q[1] - sc[1] * ra;
17998            (dx * dx + dy * dy).sqrt() - rb
17999        } else {
18000            ((q[0] * q[0] + q[1] * q[1]).sqrt() - ra).abs() - rb
18001        };
18002        let plane = sc[1] * q[0] - sc[0] * q[1];
18003        // STROKE_CAP_BUTT = 0, STROKE_CAP_SQUARE = 2, as in the shader.
18004        if cap == 0 {
18005            dist = dist.max(plane);
18006        } else if cap == 2 {
18007            dist = dist.max(plane - rb);
18008        }
18009        dist
18010    }
18011
18012    #[cfg(not(target_arch = "wasm32"))]
18013    fn point_in_triangle(p: [f64; 2], tri: &[[f64; 2]; 3]) -> bool {
18014        let side = |a: [f64; 2], b: [f64; 2]| {
18015            (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
18016        };
18017        let d0 = side(tri[0], tri[1]);
18018        let d1 = side(tri[1], tri[2]);
18019        let d2 = side(tri[2], tri[0]);
18020        let has_neg = d0 < 0.0 || d1 < 0.0 || d2 < 0.0;
18021        let has_pos = d0 > 0.0 || d1 > 0.0 || d2 > 0.0;
18022        !(has_neg && has_pos)
18023    }
18024
18025    #[cfg(not(target_arch = "wasm32"))]
18026    fn converted_arc_shape(arc: cranpose_ui_graphics::ArcGeometry, root_scale: f32) -> ShapeData {
18027        let bounds = arc.bounds();
18028        let mut shape = test_shape(0, BlendMode::SrcOver);
18029        shape.rect = bounds;
18030        shape.local_rect = bounds;
18031        shape.quad = [
18032            [bounds.x, bounds.y],
18033            [bounds.x + bounds.width, bounds.y],
18034            [bounds.x, bounds.y + bounds.height],
18035            [bounds.x + bounds.width, bounds.y + bounds.height],
18036        ];
18037        shape.arc = Some(arc);
18038        let mut converted = ShapeData::zeroed();
18039        convert_shape_into_slots(&shape, root_scale, 0, &mut converted, &mut []);
18040        converted
18041    }
18042
18043    /// The containment invariant, checked directly: every point of the
18044    /// capture box whose (exactly ported) SDF keeps it must lie inside the
18045    /// emitted triangle set. Thin/thick, tiny/huge, full rings, near-zero
18046    /// and near-TAU sweeps, all caps, `Ri == 0` discs and pie wedges.
18047    #[cfg(not(target_arch = "wasm32"))]
18048    #[test]
18049    fn arc_mesh_contains_every_band_pixel() {
18050        use cranpose_ui_graphics::ArcGeometry;
18051        let tau = cranpose_ui_graphics::TAU;
18052        let center = Point::new(250.0, 250.0);
18053        let cases: &[(f32, f32, f32, f32, StrokeCap)] = &[
18054            // full ring, thin band
18055            (90.0, 100.0, 0.0, tau, StrokeCap::Round),
18056            // sweep > TAU normalizes to a closed ring
18057            (80.0, 100.0, 1.0, 10.0, StrokeCap::Butt),
18058            // full disc: Ri == 0
18059            (0.0, 40.0, 0.0, tau, StrokeCap::Round),
18060            // thick partial arc, every cap
18061            (30.0, 80.0, 0.7, 2.5, StrokeCap::Butt),
18062            (30.0, 80.0, 0.7, 2.5, StrokeCap::Round),
18063            (30.0, 80.0, 0.7, 2.5, StrokeCap::Square),
18064            // thin, axis-crossing sweep
18065            (99.0, 101.0, 3.0, 4.0, StrokeCap::Round),
18066            // tiny
18067            (0.6, 2.0, 0.3, 1.2, StrokeCap::Butt),
18068            // huge radius, thin band
18069            (1900.0, 1904.0, 0.1, 0.35, StrokeCap::Square),
18070            // near-zero sweep
18071            (40.0, 60.0, 5.0, 1e-3, StrokeCap::Round),
18072            // sweep near TAU: the cap pads wrap the range closed
18073            (40.0, 60.0, 0.2, tau - 1e-3, StrokeCap::Butt),
18074            // rb_m >= ra: the cap disc wraps the center (pie wedge)
18075            (0.0, 3.0, 1.0, 2.0, StrokeCap::Round),
18076            // filled annular sector (butt radial ends)
18077            (20.0, 60.0, 4.5, 1.9, StrokeCap::Butt),
18078        ];
18079        for (case, &(inner, outer, start, sweep, cap)) in cases.iter().enumerate() {
18080            // 2.75 is deliberately non-dyadic: quad corners and rect then
18081            // disagree by an ulp, which the axis-aligned gate must tolerate
18082            // (an equality-with-rect gate silently failed every arc on the
18083            // Huawei at scale 2.75).
18084            for root_scale in [1.0f32, 2.0, 2.75] {
18085                let arc = ArcGeometry::new(center, inner, outer, start, sweep, cap);
18086                assert!(!arc.is_degenerate(), "case {case} must be drawable");
18087                let converted = converted_arc_shape(arc, root_scale);
18088                let band = arc_mesh_band(&converted)
18089                    .unwrap_or_else(|| panic!("case {case} must qualify for meshing"));
18090                let mut vertices = Vec::new();
18091                let mut indices = Vec::new();
18092                let segments =
18093                    emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
18094                        .unwrap_or_else(|| panic!("case {case} must produce a mesh"));
18095                assert!(segments >= ARC_MESH_MIN_SEGMENTS);
18096                // The rasterized set is the indexed walk: triangles are index
18097                // triples into the shared vertex list.
18098                let position = |index: u32| {
18099                    let p = vertices[index as usize].position;
18100                    [p[0] as f64, p[1] as f64]
18101                };
18102                let triangles: Vec<[[f64; 2]; 3]> = indices
18103                    .chunks_exact(3)
18104                    .map(|tri| [position(tri[0]), position(tri[1]), position(tri[2])])
18105                    .collect();
18106
18107                // Sample the QUAD box, not `rect`: quad expansion rasterizes the
18108                // quad, the mesh clips to the quad, and at non-dyadic root
18109                // scales the two boxes differ by an ulp.
18110                let [qx, qy, ..] = converted.quad01;
18111                let [_, _, qr, qb] = converted.quad23;
18112                let (rw, rh) = (qr - qx, qb - qy);
18113                let cap_bits = (converted.stroke_params[1].max(0.0) as u32 >> 2) & 3;
18114                let step = (rw.max(rh) / 400.0).clamp(0.25, 2.0);
18115                let mut band_points = 0usize;
18116                let mut y = qy;
18117                while y <= qb {
18118                    let mut x = qx;
18119                    while x <= qr {
18120                        let dist = sdf_arc_band_reference(
18121                            [x, y],
18122                            [converted.arc_params[0], converted.arc_params[1]],
18123                            converted.stroke_params[3],
18124                            converted.stroke_params[2],
18125                            [converted.radii[0], converted.radii[1]],
18126                            [converted.radii[2], converted.radii[3]],
18127                            cap_bits,
18128                        );
18129                        if dist <= 0.5 {
18130                            band_points += 1;
18131                            let p = [x as f64, y as f64];
18132                            assert!(
18133                                triangles.iter().any(|tri| point_in_triangle(p, tri)),
18134                                "case {case} scale {root_scale}: band point ({x}, {y}) \
18135                                 dist {dist} escapes the mesh"
18136                            );
18137                        }
18138                        x += step;
18139                    }
18140                    y += step;
18141                }
18142                assert!(
18143                    band_points > 0,
18144                    "case {case} scale {root_scale}: the sampling grid never hit the band"
18145                );
18146            }
18147        }
18148    }
18149
18150    #[cfg(not(target_arch = "wasm32"))]
18151    #[test]
18152    fn arc_mesh_passthrough_replicates_the_quad_expansion() {
18153        let shape = test_shape(0, BlendMode::SrcOver);
18154        let mut converted = ShapeData::zeroed();
18155        convert_shape_into_slots(&shape, 1.0, 0, &mut converted, &mut []);
18156        let build =
18157            build_arc_mesh_vertices(std::slice::from_ref(&converted)).expect("within budget");
18158        assert_eq!(build.meshed_arcs, 0);
18159        assert_eq!(build.passthrough, 1);
18160        // Four shared corner vertices, six indices — amplification-free.
18161        assert_eq!(build.vertices.len(), 4);
18162        assert_eq!(build.index_prefix, vec![0, 6]);
18163        assert_eq!(build.indices, vec![0, 1, 2, 2, 1, 3]);
18164        let corners = [
18165            ([converted.quad01[0], converted.quad01[1]], [0.0f32, 0.0]),
18166            ([converted.quad01[2], converted.quad01[3]], [1.0, 0.0]),
18167            ([converted.quad23[0], converted.quad23[1]], [0.0, 1.0]),
18168            ([converted.quad23[2], converted.quad23[3]], [1.0, 1.0]),
18169        ];
18170        for (vertex, corner) in build.vertices.iter().zip(corners) {
18171            assert_eq!(vertex.position, corner.0);
18172            assert_eq!(vertex.uv, corner.1);
18173            assert_eq!(vertex.shape_idx, 0);
18174        }
18175        // The indexed walk expands to vs_main's slot order: triangles
18176        // (0, 1, 2) and (2, 1, 3).
18177        for (index, corner) in build.indices.iter().zip([0usize, 1, 2, 2, 1, 3]) {
18178            assert_eq!(build.vertices[*index as usize].position, corners[corner].0);
18179            assert_eq!(build.vertices[*index as usize].uv, corners[corner].1);
18180        }
18181    }
18182
18183    /// The indexed-topology contract for arcs whose trapezoids survive
18184    /// clipping whole: every band boundary contributes exactly one (inner,
18185    /// outer) vertex pair, both adjacent trapezoids reference it through the
18186    /// index list, and a closed ring's last segment wraps around to boundary
18187    /// zero's pair — one seam vertex pair instead of bitwise-equal copies.
18188    #[cfg(not(target_arch = "wasm32"))]
18189    #[test]
18190    fn arc_mesh_indices_share_boundary_vertices_and_wrap_closed_rings() {
18191        use cranpose_ui_graphics::ArcGeometry;
18192        let tau = cranpose_ui_graphics::TAU;
18193        // (sweep, expected boundary count relation): a closed ring wraps
18194        // (boundaries == segments), an open arc does not (segments + 1).
18195        for (sweep, closed) in [(tau, true), (1.9f32, false)] {
18196            let arc = ArcGeometry::new(
18197                Point::new(250.0, 250.0),
18198                80.0,
18199                100.0,
18200                0.7,
18201                sweep,
18202                StrokeCap::Round,
18203            );
18204            let mut converted = converted_arc_shape(arc, 1.0);
18205            // Inflate the quad box (and rect, for uv) far beyond the dilated
18206            // band so NO trapezoid is clipped: every segment must take the
18207            // shared-boundary path.
18208            converted.rect = [0.0, 0.0, 500.0, 500.0];
18209            converted.quad01 = [0.0, 0.0, 500.0, 0.0];
18210            converted.quad23 = [0.0, 500.0, 500.0, 500.0];
18211            let band = arc_mesh_band(&converted).expect("arc must qualify");
18212            let mut vertices = Vec::new();
18213            let mut indices = Vec::new();
18214            let segments = emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
18215                .expect("arc must mesh");
18216            let boundary_count = if closed { segments } else { segments + 1 };
18217            assert_eq!(
18218                vertices.len(),
18219                2 * boundary_count,
18220                "closed={closed}: every boundary owns exactly one (inner, outer) pair"
18221            );
18222            assert_eq!(indices.len(), 6 * segments);
18223            // Emission order is boundary order: boundary j's pair is
18224            // (2j, 2j + 1). Each segment must reference its own boundary and
18225            // its successor's — modulo the count exactly when closed.
18226            for j in 0..segments {
18227                let jb = (j + 1) % boundary_count;
18228                let (in_a, out_a) = (2 * j as u32, 2 * j as u32 + 1);
18229                let (in_b, out_b) = (2 * jb as u32, 2 * jb as u32 + 1);
18230                assert_eq!(
18231                    indices[6 * j..6 * j + 6],
18232                    [in_a, out_a, out_b, in_a, out_b, in_b],
18233                    "closed={closed}: segment {j} must share its boundary pairs"
18234                );
18235            }
18236            if closed {
18237                // The wrap made concrete: the final segment indexes boundary
18238                // zero's vertices.
18239                assert_eq!(indices[6 * segments - 1], 0);
18240            }
18241            // Inner vertices ride the dilated inner radius, outer vertices
18242            // the pushed-out chord radius — sanity that pairs are ordered
18243            // (inner, outer).
18244            for pair in vertices.chunks_exact(2) {
18245                let radius = |v: &MeshVertex| {
18246                    let dx = v.position[0] - 250.0;
18247                    let dy = v.position[1] - 250.0;
18248                    (dx * dx + dy * dy).sqrt()
18249                };
18250                assert!(radius(&pair[0]) < radius(&pair[1]));
18251            }
18252        }
18253    }
18254
18255    /// The private-vertex arm of the indexed topology: under the real
18256    /// tight-AABB quad the pushed-out chord vertices near the box edges get
18257    /// clipped, and those trapezoids must fan over vertices of their own —
18258    /// appended after the shared block, carrying clip-plane coordinates —
18259    /// while untouched diagonal trapezoids still share boundary pairs.
18260    #[cfg(not(target_arch = "wasm32"))]
18261    #[test]
18262    fn arc_mesh_clipped_segments_fan_over_private_vertices() {
18263        use cranpose_ui_graphics::ArcGeometry;
18264        let arc = ArcGeometry::new(
18265            Point::new(250.0, 250.0),
18266            80.0,
18267            100.0,
18268            0.0,
18269            cranpose_ui_graphics::TAU,
18270            StrokeCap::Round,
18271        );
18272        let converted = converted_arc_shape(arc, 1.0);
18273        let band = arc_mesh_band(&converted).expect("ring must qualify");
18274        let mut vertices = Vec::new();
18275        let mut indices = Vec::new();
18276        emit_arc_band_mesh(&converted, 0, &band, &mut vertices, &mut indices)
18277            .expect("ring must mesh");
18278        // Sharing must actually happen: a shared boundary vertex is used by
18279        // both of its trapezoids' fans (at least three triangle references).
18280        let mut uses = vec![0usize; vertices.len()];
18281        for &index in &indices {
18282            uses[index as usize] += 1;
18283        }
18284        assert!(
18285            uses.iter().any(|&count| count >= 3),
18286            "some boundary vertices must be shared across trapezoids"
18287        );
18288        // Clipping must actually happen, and clipped polygons index private
18289        // vertices lying bitwise ON the quad box (the clipper writes the
18290        // bound coordinate exactly; boundary vertices never touch the box —
18291        // inner ones sit strictly inside, pushed-out outer ones strictly
18292        // outside near the extremes, where they are clipped).
18293        let [left, top, ..] = converted.quad01;
18294        let [.., right, bottom] = converted.quad23;
18295        let clipped: Vec<&MeshVertex> = vertices
18296            .iter()
18297            .filter(|vertex| {
18298                let [x, y] = vertex.position;
18299                x == left || x == right || y == top || y == bottom
18300            })
18301            .collect();
18302        assert!(
18303            !clipped.is_empty(),
18304            "the tight box must clip the pushed-out chord vertices"
18305        );
18306        // Fewer unique vertices than the non-indexed emitter's
18307        // three-per-triangle — the amplification this change removes.
18308        assert!(
18309            vertices.len() < indices.len(),
18310            "{} unique vertices should undercut {} triangle corners",
18311            vertices.len(),
18312            indices.len()
18313        );
18314    }
18315
18316    #[cfg(not(target_arch = "wasm32"))]
18317    #[test]
18318    fn arc_mesh_budget_overflow_falls_back_to_whole_slot_passthrough() {
18319        use cranpose_ui_graphics::ArcGeometry;
18320        // 100 large full rings mesh at the 64-segment ceiling (well over
18321        // 4 KB of vertices + indices each), far past the byte budget
18322        // max(100 * ~960 B, ~80 KB) — the builder must refuse the whole
18323        // slot rather than truncate.
18324        let arc = ArcGeometry::new(
18325            Point::new(2000.0, 2000.0),
18326            1690.0,
18327            1710.0,
18328            0.0,
18329            cranpose_ui_graphics::TAU,
18330            StrokeCap::Round,
18331        );
18332        let converted = converted_arc_shape(arc, 1.0);
18333        let shapes = vec![converted; 100];
18334        assert!(build_arc_mesh_vertices(&shapes).is_none());
18335    }
18336
18337    #[cfg(not(target_arch = "wasm32"))]
18338    #[test]
18339    fn shape_batch_limits_follow_uniform_binding_size() {
18340        // With a 160-byte ShapeData, even a desktop-class 64 KiB binding can no
18341        // longer hold the full compile-time cap: 65536 / 160 = 409 < 768.
18342        let desktop_shapes = 65536 / std::mem::size_of::<ShapeData>();
18343        assert_eq!(desktop_shapes, 409);
18344        assert_eq!(
18345            ShapeBatchLimits::desktop(),
18346            ShapeBatchLimits {
18347                max_shapes_per_batch: desktop_shapes.min(MAX_SHAPES_PER_BATCH),
18348                max_gradient_stops: MAX_GRADIENT_STOPS,
18349                storage: false,
18350            }
18351        );
18352
18353        // The 16 KiB downlevel/GLES minimum must shrink batches to fit:
18354        // 16384 / 160-byte ShapeData = 102 shapes, 16384 / 32-byte stop = 512.
18355        let downlevel = ShapeBatchLimits::for_uniform_binding_size(16384);
18356        assert_eq!(downlevel.max_shapes_per_batch, 16384 / 160);
18357        assert_eq!(downlevel.max_shapes_per_batch, 102);
18358        assert_eq!(downlevel.max_gradient_stops, 512.min(MAX_GRADIENT_STOPS));
18359        assert!(downlevel.max_shapes_per_batch * std::mem::size_of::<ShapeData>() <= 16384);
18360        assert!(downlevel.max_gradient_stops * std::mem::size_of::<GradientStop>() <= 16384);
18361
18362        // Degenerate limits must not produce zero-sized buffers.
18363        let tiny = ShapeBatchLimits::for_uniform_binding_size(1);
18364        assert_eq!(tiny.max_shapes_per_batch, 1);
18365        assert_eq!(tiny.max_gradient_stops, 1);
18366    }
18367
18368    #[test]
18369    fn storage_shape_batch_limits_uncap_the_batch_and_start_small() {
18370        // A typical 128 MiB storage binding hits the compile-time ceilings,
18371        // not the device limit: one batch holds the whole scene.
18372        let storage = ShapeBatchLimits::for_storage_binding_size(128 << 20);
18373        assert!(storage.storage);
18374        assert_eq!(storage.max_shapes_per_batch, MAX_SHAPES_PER_STORAGE_BATCH);
18375        assert_eq!(
18376            storage.max_gradient_stops,
18377            MAX_GRADIENT_STOPS_PER_STORAGE_BATCH
18378        );
18379
18380        // The buffers must not be allocated at the multi-megabyte ceiling up
18381        // front; they start small and grow on demand.
18382        assert_eq!(
18383            storage.initial_shape_capacity(),
18384            INITIAL_STORAGE_BATCH_CAPACITY
18385        );
18386        assert_eq!(
18387            storage.initial_gradient_capacity(),
18388            INITIAL_STORAGE_BATCH_CAPACITY
18389        );
18390        assert_eq!(
18391            storage.data_binding_type(),
18392            wgpu::BufferBindingType::Storage { read_only: true }
18393        );
18394        assert!(storage
18395            .data_buffer_usage()
18396            .contains(wgpu::BufferUsages::STORAGE));
18397
18398        // Uniform mode keeps its start-at-the-cap invariant: a uniform
18399        // binding smaller than the shader's fixed array fails validation.
18400        let uniform = ShapeBatchLimits::desktop();
18401        assert_eq!(
18402            uniform.initial_shape_capacity(),
18403            uniform.max_shapes_per_batch
18404        );
18405        assert_eq!(
18406            uniform.initial_gradient_capacity(),
18407            uniform.max_gradient_stops
18408        );
18409        assert_eq!(
18410            uniform.data_binding_type(),
18411            wgpu::BufferBindingType::Uniform
18412        );
18413        assert!(uniform
18414            .data_buffer_usage()
18415            .contains(wgpu::BufferUsages::UNIFORM));
18416    }
18417
18418    #[test]
18419    fn storage_shape_shader_swaps_the_arrays_to_runtime_sized_storage() {
18420        let source = shape_shader_source(ShapeBatchLimits::for_storage_binding_size(128 << 20));
18421        assert!(
18422            source.contains("var<storage, read> shape_data: array<ShapeData>;"),
18423            "storage-mode shader must declare a runtime-sized shape array"
18424        );
18425        assert!(
18426            source.contains("var<storage, read> gradient_stops: array<GradientStop>;"),
18427            "storage-mode shader must declare a runtime-sized gradient array"
18428        );
18429        assert!(
18430            !source.contains("var<uniform> shape_data"),
18431            "the uniform shape declaration must be fully replaced"
18432        );
18433        assert!(
18434            !source.contains("var<uniform> gradient_stops"),
18435            "the uniform gradient declaration must be fully replaced"
18436        );
18437        assert!(
18438            source.contains("var<storage, read> paint: array<vec4<f32>>;"),
18439            "storage-mode shader must declare the retained paint array"
18440        );
18441        assert!(
18442            source.contains("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)"),
18443            "storage-mode shader must read paint under the paint_select flag"
18444        );
18445        assert!(
18446            source.contains("fn vs_mesh("),
18447            "the storage rewrite must leave the retained-mesh vertex entry intact"
18448        );
18449        assert!(
18450            source.contains("fn vs_shape_instanced("),
18451            "the storage rewrite must leave the instanced-quad vertex entry intact"
18452        );
18453        assert_eq!(
18454            source
18455                .matches("select(shape.color, paint[shape_idx], similarity.paint_select > 0.5)")
18456                .count(),
18457            3,
18458            "vs_main, vs_shape_instanced and vs_mesh must all read paint under \
18459             the paint_select flag (meshless retained draws ride the instanced \
18460             entry when the selection is latched on)"
18461        );
18462
18463        // The storage variant is what native devices actually compile; it
18464        // must be valid WGSL, not just textually plausible.
18465        let module = naga::front::wgsl::parse_str(&source)
18466            .expect("storage-mode shape shader must parse as WGSL");
18467        naga::valid::Validator::new(
18468            naga::valid::ValidationFlags::all(),
18469            naga::valid::Capabilities::all(),
18470        )
18471        .validate(&module)
18472        .expect("storage-mode shape shader must validate for WebGPU");
18473    }
18474
18475    #[test]
18476    fn uniform_shape_shader_keeps_the_in_record_color_and_no_paint_binding() {
18477        // The base text serves WebGL-class uniform devices, which can bind
18478        // no storage buffers: the paint array and its select must exist only
18479        // in the storage-mode rewrite.
18480        for source in [
18481            Cow::Borrowed(shaders::SHADER),
18482            shape_shader_source(ShapeBatchLimits::desktop()),
18483        ] {
18484            assert!(
18485                !source.contains("paint: array"),
18486                "the uniform variant must not declare a paint array"
18487            );
18488            assert!(
18489                source.contains("output.color = shape.color;"),
18490                "the uniform variant must read the color from ShapeData \
18491                 (this literal is also what `shape_shader_source` rewrites)"
18492            );
18493            assert!(
18494                source.contains("paint_select: f32"),
18495                "SimilarityTransform must name the flag field in both \
18496                 variants; the Rust mirror is Pod and uploads raw bytes"
18497            );
18498        }
18499    }
18500
18501    #[test]
18502    fn shipped_shape_shader_array_length_fits_the_downlevel_uniform_floor() {
18503        // The wasm build uses `shaders::SHADER` verbatim, so its declared array
18504        // length is simultaneously the wasm batch cap and the WebGL binding
18505        // size. It must fit the 16 KiB floor exactly.
18506        assert!(
18507            shaders::SHADER.contains("array<ShapeData, 102>"),
18508            "shape.wgsl array length must stay in sync with \
18509             `shape_shader_source`'s replace string and MAX_SHAPES_PER_BATCH"
18510        );
18511        assert!(102 * std::mem::size_of::<ShapeData>() <= 16384);
18512        assert!(103 * std::mem::size_of::<ShapeData>() > 16384);
18513    }
18514
18515    #[test]
18516    fn glyph_atlas_doubles_on_overflow_and_stops_at_the_device_ceiling() {
18517        // Every overflow buys one doubling, so an app that needs the old fixed
18518        // 4096 atlas reaches it in three resets and then stays there.
18519        assert_eq!(
18520            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MIN_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
18521            1024
18522        );
18523        assert_eq!(
18524            next_glyph_atlas_size(2048, TEXT_GLYPH_ATLAS_MAX_SIZE),
18525            TEXT_GLYPH_ATLAS_MAX_SIZE
18526        );
18527        assert_eq!(
18528            next_glyph_atlas_size(TEXT_GLYPH_ATLAS_MAX_SIZE, TEXT_GLYPH_ATLAS_MAX_SIZE),
18529            TEXT_GLYPH_ATLAS_MAX_SIZE
18530        );
18531
18532        // A device that only grants `downlevel_defaults()`'s 2048 caps the
18533        // growth there rather than failing to create the texture.
18534        assert_eq!(next_glyph_atlas_size(1024, 2048), 2048);
18535        assert_eq!(next_glyph_atlas_size(2048, 2048), 2048);
18536
18537        // Never zero and never wrapping, whatever the ceiling turns out to be.
18538        assert_eq!(next_glyph_atlas_size(u32::MAX, 4096), 4096);
18539        assert_eq!(next_glyph_atlas_size(0, 0), 1);
18540    }
18541
18542    #[test]
18543    fn glyph_atlas_uv_rect_normalizes_against_the_atlas_it_was_placed_in() {
18544        // The atlas grows, so a UV is only meaningful together with the size of
18545        // the texture the entry came from. Reading the size off a constant is
18546        // what would make a grown atlas sample the wrong glyph.
18547        let entry = GlyphAtlasEntry {
18548            x: 128,
18549            y: 256,
18550            width: 16,
18551            height: 32,
18552        };
18553
18554        let small = glyph_atlas_uv_rect(entry, 512);
18555        let large = glyph_atlas_uv_rect(entry, 4096);
18556
18557        assert_eq!(small.min, [128.0 / 512.0, 256.0 / 512.0]);
18558        assert_eq!(large.min, [128.0 / 4096.0, 256.0 / 4096.0]);
18559        assert_eq!(small.max, [144.0 / 512.0, 288.0 / 512.0]);
18560        assert_eq!(large.max, [144.0 / 4096.0, 288.0 / 4096.0]);
18561    }
18562
18563    #[test]
18564    fn native_shape_shader_source_uses_native_batch_limits() {
18565        let limits = ShapeBatchLimits::desktop();
18566        let source = shape_shader_source(limits);
18567
18568        assert!(source.contains(&format!(
18569            "array<ShapeData, {}>",
18570            limits.max_shapes_per_batch
18571        )));
18572        assert!(source.contains(&format!(
18573            "array<GradientStop, {}>",
18574            limits.max_gradient_stops
18575        )));
18576        // Sanity: the substitution actually fired rather than silently leaving
18577        // the downlevel literal in place.
18578        assert!(!source.contains("array<ShapeData, 146>"));
18579    }
18580
18581    #[test]
18582    fn stroked_and_arc_shapes_batch_together_with_fills() {
18583        // Strokes and arcs ride the same pipeline, the same ShapeData array and
18584        // the same blend state as fills, so a run of mixed shapes must stay a
18585        // single batch. If they ever split the batch, a polar UI built from
18586        // hundreds of arcs would pay a draw call per arc — precisely the cost
18587        // this primitive exists to remove.
18588        let fill = test_shape(0, BlendMode::SrcOver);
18589        let mut stroked = test_shape(1, BlendMode::SrcOver);
18590        stroked.stroke = Some(
18591            cranpose_ui_graphics::Stroke::new(3.0)
18592                .with_cap(StrokeCap::Round)
18593                .with_join(StrokeJoin::Bevel),
18594        );
18595        let mut arc = test_shape(2, BlendMode::SrcOver);
18596        arc.arc = Some(cranpose_ui_graphics::ArcGeometry::new(
18597            Point::new(4.0, 4.0),
18598            2.0,
18599            4.0,
18600            0.0,
18601            1.0,
18602            StrokeCap::Round,
18603        ));
18604        let trailing_fill = test_shape(3, BlendMode::SrcOver);
18605
18606        assert!(!fill.has_stroke_or_arc());
18607        assert!(stroked.has_stroke_or_arc());
18608        assert!(arc.has_stroke_or_arc());
18609        assert!(!trailing_fill.has_stroke_or_arc());
18610
18611        let shapes = vec![fill, stroked, arc, trailing_fill];
18612        let ordered_items: Vec<_> = (0..shapes.len())
18613            .map(|index| (index, SegmentDrawItem::Shape(index)))
18614            .collect();
18615        let images = Vec::new();
18616
18617        let commands: Vec<_> = SegmentCommandIter::new(
18618            &ordered_items,
18619            &shapes,
18620            &images,
18621            ShapeBatchLimits::desktop(),
18622        )
18623        .collect();
18624
18625        assert_eq!(
18626            commands,
18627            vec![SegmentRenderCommand::DrawChunk(chunk(&[
18628                SegmentBatchPlan::Shape {
18629                    start: 0,
18630                    end: 4,
18631                    blend_mode: BlendMode::SrcOver,
18632                }
18633            ]))],
18634            "mixed fill/stroke/arc runs must stay one batch"
18635        );
18636    }
18637
18638    #[cfg(not(target_arch = "wasm32"))]
18639    #[test]
18640    fn native_segment_fusion_budget_allows_small_interleaved_chunks() {
18641        let ordered_items = vec![
18642            (0, SegmentDrawItem::Shape(0)),
18643            (1, SegmentDrawItem::Image(0)),
18644            (2, SegmentDrawItem::Text(0)),
18645            (3, SegmentDrawItem::Shape(1)),
18646        ];
18647        let shapes = vec![
18648            test_shape(0, BlendMode::SrcOver),
18649            test_shape(3, BlendMode::DstOut),
18650        ];
18651        let segment = chunk(&[
18652            SegmentBatchPlan::Shape {
18653                start: 0,
18654                end: 1,
18655                blend_mode: BlendMode::SrcOver,
18656            },
18657            SegmentBatchPlan::Image {
18658                start: 1,
18659                end: 2,
18660                blend_mode: BlendMode::SrcOver,
18661            },
18662            SegmentBatchPlan::Text { start: 2, end: 3 },
18663            SegmentBatchPlan::Shape {
18664                start: 3,
18665                end: 4,
18666                blend_mode: BlendMode::DstOut,
18667            },
18668        ]);
18669
18670        let budget = native_segment_fusion_budget(
18671            &ordered_items,
18672            &shapes,
18673            &segment,
18674            ShapeBatchLimits::desktop(),
18675        )
18676        .expect("budget should be valid")
18677        .expect("chunk should fit native fusion budget");
18678
18679        assert_eq!(
18680            budget,
18681            NativeSegmentFusionBudget {
18682                shape_count: 2,
18683                gradient_stop_count: 0,
18684            }
18685        );
18686    }
18687
18688    #[cfg(not(target_arch = "wasm32"))]
18689    #[test]
18690    fn native_segment_fusion_budget_rejects_shape_uniform_overflow() {
18691        let ordered_items: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
18692            .map(|index| (index, SegmentDrawItem::Shape(index)))
18693            .collect();
18694        let shapes: Vec<_> = (0..=MAX_SHAPES_PER_BATCH)
18695            .map(|index| test_shape(index, BlendMode::SrcOver))
18696            .collect();
18697        let segment = chunk(&[
18698            SegmentBatchPlan::Shape {
18699                start: 0,
18700                end: MAX_SHAPES_PER_BATCH,
18701                blend_mode: BlendMode::SrcOver,
18702            },
18703            SegmentBatchPlan::Shape {
18704                start: MAX_SHAPES_PER_BATCH,
18705                end: MAX_SHAPES_PER_BATCH + 1,
18706                blend_mode: BlendMode::SrcOver,
18707            },
18708        ]);
18709
18710        let budget = native_segment_fusion_budget(
18711            &ordered_items,
18712            &shapes,
18713            &segment,
18714            ShapeBatchLimits::desktop(),
18715        )
18716        .expect("valid plan");
18717
18718        assert_eq!(budget, None);
18719    }
18720
18721    #[cfg(not(target_arch = "wasm32"))]
18722    #[test]
18723    fn native_segment_fusion_budget_rejects_gradient_uniform_overflow() {
18724        let ordered_items = vec![(0, SegmentDrawItem::Shape(0))];
18725        let mut shape = test_shape(0, BlendMode::SrcOver);
18726        shape.brush = Brush::linear_gradient(vec![Color::BLACK; MAX_GRADIENT_STOPS + 1]);
18727        let shapes = vec![shape];
18728        let segment = chunk(&[SegmentBatchPlan::Shape {
18729            start: 0,
18730            end: 1,
18731            blend_mode: BlendMode::SrcOver,
18732        }]);
18733
18734        let budget = native_segment_fusion_budget(
18735            &ordered_items,
18736            &shapes,
18737            &segment,
18738            ShapeBatchLimits::desktop(),
18739        )
18740        .expect("valid plan");
18741
18742        assert_eq!(budget, None);
18743    }
18744
18745    #[cfg(not(target_arch = "wasm32"))]
18746    #[test]
18747    fn native_segment_fusion_partitions_shape_uniform_overflow() {
18748        // The uniform batch cap is derived from the device binding size and
18749        // the 112-byte ShapeData, not from the compile-time ceiling.
18750        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
18751        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
18752            .map(|index| (index, SegmentDrawItem::Shape(index)))
18753            .collect();
18754        let shapes: Vec<_> = (0..=desktop_batch_cap)
18755            .map(|index| test_shape(index, BlendMode::SrcOver))
18756            .collect();
18757        let segment = chunk(&[
18758            SegmentBatchPlan::Shape {
18759                start: 0,
18760                end: desktop_batch_cap,
18761                blend_mode: BlendMode::SrcOver,
18762            },
18763            SegmentBatchPlan::Shape {
18764                start: desktop_batch_cap,
18765                end: desktop_batch_cap + 1,
18766                blend_mode: BlendMode::SrcOver,
18767            },
18768        ]);
18769
18770        let partitions = native_segment_fusion_partitions(
18771            &ordered_items,
18772            &shapes,
18773            &segment,
18774            ShapeBatchLimits::desktop(),
18775        )
18776        .expect("valid plan")
18777        .expect("overflowing segment should be partitionable");
18778
18779        assert_eq!(partitions.len(), 2);
18780        assert_eq!(
18781            partitions[0],
18782            NativeSegmentFusionPartition {
18783                chunk: chunk(&[SegmentBatchPlan::Shape {
18784                    start: 0,
18785                    end: desktop_batch_cap,
18786                    blend_mode: BlendMode::SrcOver,
18787                }]),
18788                budget: NativeSegmentFusionBudget {
18789                    shape_count: desktop_batch_cap,
18790                    gradient_stop_count: 0,
18791                },
18792            }
18793        );
18794        assert_eq!(
18795            partitions[1],
18796            NativeSegmentFusionPartition {
18797                chunk: chunk(&[SegmentBatchPlan::Shape {
18798                    start: desktop_batch_cap,
18799                    end: desktop_batch_cap + 1,
18800                    blend_mode: BlendMode::SrcOver,
18801                }]),
18802                budget: NativeSegmentFusionBudget {
18803                    shape_count: 1,
18804                    gradient_stop_count: 0,
18805                },
18806            }
18807        );
18808    }
18809
18810    #[cfg(not(target_arch = "wasm32"))]
18811    #[test]
18812    fn native_segment_fusion_partitions_gradient_uniform_overflow() {
18813        const STOPS_PER_SHAPE: usize = MAX_GRADIENT_STOPS / 2;
18814        let ordered_items = vec![
18815            (0, SegmentDrawItem::Shape(0)),
18816            (1, SegmentDrawItem::Shape(1)),
18817            (2, SegmentDrawItem::Shape(2)),
18818        ];
18819        let mut shapes = Vec::new();
18820        for index in 0..3 {
18821            let mut shape = test_shape(index, BlendMode::SrcOver);
18822            shape.brush = Brush::linear_gradient(vec![Color::BLACK; STOPS_PER_SHAPE]);
18823            shapes.push(shape);
18824        }
18825        let segment = chunk(&[SegmentBatchPlan::Shape {
18826            start: 0,
18827            end: 3,
18828            blend_mode: BlendMode::SrcOver,
18829        }]);
18830
18831        let partitions = native_segment_fusion_partitions(
18832            &ordered_items,
18833            &shapes,
18834            &segment,
18835            ShapeBatchLimits::desktop(),
18836        )
18837        .expect("valid plan")
18838        .expect("overflowing gradient segment should be partitionable");
18839
18840        assert_eq!(partitions.len(), 2);
18841        assert_eq!(
18842            partitions[0],
18843            NativeSegmentFusionPartition {
18844                chunk: chunk(&[SegmentBatchPlan::Shape {
18845                    start: 0,
18846                    end: 2,
18847                    blend_mode: BlendMode::SrcOver,
18848                }]),
18849                budget: NativeSegmentFusionBudget {
18850                    shape_count: 2,
18851                    gradient_stop_count: MAX_GRADIENT_STOPS,
18852                },
18853            }
18854        );
18855        assert_eq!(
18856            partitions[1],
18857            NativeSegmentFusionPartition {
18858                chunk: chunk(&[SegmentBatchPlan::Shape {
18859                    start: 2,
18860                    end: 3,
18861                    blend_mode: BlendMode::SrcOver,
18862                }]),
18863                budget: NativeSegmentFusionBudget {
18864                    shape_count: 1,
18865                    gradient_stop_count: STOPS_PER_SHAPE,
18866                },
18867            }
18868        );
18869    }
18870
18871    #[cfg(not(target_arch = "wasm32"))]
18872    #[test]
18873    fn native_segment_fusion_accepts_layer_composite_chunks() {
18874        let ordered_items = vec![
18875            (0, SegmentDrawItem::Shape(0)),
18876            (1, SegmentDrawItem::Composite(0)),
18877            (2, SegmentDrawItem::ShaderComposite(0)),
18878            (3, SegmentDrawItem::Shape(1)),
18879        ];
18880        let shapes = vec![
18881            test_shape(0, BlendMode::SrcOver),
18882            test_shape(1, BlendMode::SrcOver),
18883        ];
18884        let segment = chunk(&[
18885            SegmentBatchPlan::Shape {
18886                start: 0,
18887                end: 1,
18888                blend_mode: BlendMode::SrcOver,
18889            },
18890            SegmentBatchPlan::Composite { start: 1, end: 2 },
18891            SegmentBatchPlan::ShaderComposite { start: 2, end: 3 },
18892            SegmentBatchPlan::Shape {
18893                start: 3,
18894                end: 4,
18895                blend_mode: BlendMode::SrcOver,
18896            },
18897        ]);
18898
18899        let partitions = native_segment_fusion_partitions(
18900            &ordered_items,
18901            &shapes,
18902            &segment,
18903            ShapeBatchLimits::desktop(),
18904        )
18905        .expect("valid plan")
18906        .expect("composites are drawable inside the native fused pass");
18907
18908        assert_eq!(
18909            partitions,
18910            vec![NativeSegmentFusionPartition {
18911                chunk: segment,
18912                budget: NativeSegmentFusionBudget {
18913                    shape_count: 2,
18914                    gradient_stop_count: 0,
18915                },
18916            }],
18917            "layer composites and shader composites must preserve order without forcing separate render passes"
18918        );
18919    }
18920
18921    #[cfg(not(target_arch = "wasm32"))]
18922    #[test]
18923    fn native_segment_fusion_partitions_preserve_non_shape_order_at_budget_boundary() {
18924        // The uniform batch cap is derived from the device binding size and
18925        // the 112-byte ShapeData, not from the compile-time ceiling.
18926        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
18927        let ordered_items: Vec<_> = (0..desktop_batch_cap)
18928            .map(|index| (index, SegmentDrawItem::Shape(index)))
18929            .chain([
18930                (desktop_batch_cap, SegmentDrawItem::Image(0)),
18931                (
18932                    desktop_batch_cap + 1,
18933                    SegmentDrawItem::Shape(desktop_batch_cap),
18934                ),
18935            ])
18936            .collect();
18937        let shapes: Vec<_> = (0..=desktop_batch_cap)
18938            .map(|index| test_shape(index, BlendMode::SrcOver))
18939            .collect();
18940        let segment = chunk(&[
18941            SegmentBatchPlan::Shape {
18942                start: 0,
18943                end: desktop_batch_cap,
18944                blend_mode: BlendMode::SrcOver,
18945            },
18946            SegmentBatchPlan::Image {
18947                start: desktop_batch_cap,
18948                end: desktop_batch_cap + 1,
18949                blend_mode: BlendMode::SrcOver,
18950            },
18951            SegmentBatchPlan::Shape {
18952                start: desktop_batch_cap + 1,
18953                end: desktop_batch_cap + 2,
18954                blend_mode: BlendMode::SrcOver,
18955            },
18956        ]);
18957
18958        let partitions = native_segment_fusion_partitions(
18959            &ordered_items,
18960            &shapes,
18961            &segment,
18962            ShapeBatchLimits::desktop(),
18963        )
18964        .expect("valid plan")
18965        .expect("overflowing segment should be partitionable");
18966
18967        assert_eq!(partitions.len(), 2);
18968        assert_eq!(
18969            partitions[0].chunk,
18970            chunk(&[
18971                SegmentBatchPlan::Shape {
18972                    start: 0,
18973                    end: desktop_batch_cap,
18974                    blend_mode: BlendMode::SrcOver,
18975                },
18976                SegmentBatchPlan::Image {
18977                    start: desktop_batch_cap,
18978                    end: desktop_batch_cap + 1,
18979                    blend_mode: BlendMode::SrcOver,
18980                },
18981            ])
18982        );
18983        assert_eq!(
18984            partitions[1].chunk,
18985            chunk(&[SegmentBatchPlan::Shape {
18986                start: desktop_batch_cap + 1,
18987                end: desktop_batch_cap + 2,
18988                blend_mode: BlendMode::SrcOver,
18989            }])
18990        );
18991    }
18992
18993    #[test]
18994    fn segment_command_iter_keeps_repeated_batch_kinds_in_one_chunk() {
18995        let ordered_items = vec![
18996            (0, SegmentDrawItem::Shape(0)),
18997            (1, SegmentDrawItem::Image(0)),
18998            (2, SegmentDrawItem::Shape(1)),
18999        ];
19000        let shapes = vec![
19001            test_shape(0, BlendMode::SrcOver),
19002            test_shape(2, BlendMode::DstOut),
19003        ];
19004        let images = vec![test_image(1, BlendMode::SrcOver)];
19005
19006        let commands: Vec<_> = SegmentCommandIter::new(
19007            &ordered_items,
19008            &shapes,
19009            &images,
19010            ShapeBatchLimits::desktop(),
19011        )
19012        .collect();
19013
19014        assert_eq!(
19015            commands,
19016            vec![SegmentRenderCommand::DrawChunk(chunk(&[
19017                SegmentBatchPlan::Shape {
19018                    start: 0,
19019                    end: 1,
19020                    blend_mode: BlendMode::SrcOver,
19021                },
19022                SegmentBatchPlan::Image {
19023                    start: 1,
19024                    end: 2,
19025                    blend_mode: BlendMode::SrcOver,
19026                },
19027                SegmentBatchPlan::Shape {
19028                    start: 2,
19029                    end: 3,
19030                    blend_mode: BlendMode::DstOut,
19031                },
19032            ]))]
19033        );
19034    }
19035
19036    #[test]
19037    fn segment_command_iter_splits_contiguous_shape_runs_at_uniform_batch_limit() {
19038        // The uniform batch cap is derived from the device binding size and
19039        // the 112-byte ShapeData, not from the compile-time ceiling.
19040        let desktop_batch_cap = ShapeBatchLimits::desktop().max_shapes_per_batch;
19041        let ordered_items: Vec<_> = (0..=desktop_batch_cap)
19042            .map(|index| (index, SegmentDrawItem::Shape(index)))
19043            .collect();
19044        let shapes: Vec<_> = (0..=desktop_batch_cap)
19045            .map(|index| test_shape(index, BlendMode::SrcOver))
19046            .collect();
19047        let images = Vec::new();
19048
19049        let commands: Vec<_> = SegmentCommandIter::new(
19050            &ordered_items,
19051            &shapes,
19052            &images,
19053            ShapeBatchLimits::desktop(),
19054        )
19055        .collect();
19056
19057        assert_eq!(
19058            commands,
19059            vec![SegmentRenderCommand::DrawChunk(chunk(&[
19060                SegmentBatchPlan::Shape {
19061                    start: 0,
19062                    end: desktop_batch_cap,
19063                    blend_mode: BlendMode::SrcOver,
19064                },
19065                SegmentBatchPlan::Shape {
19066                    start: desktop_batch_cap,
19067                    end: desktop_batch_cap + 1,
19068                    blend_mode: BlendMode::SrcOver,
19069                },
19070            ]))]
19071        );
19072    }
19073
19074    #[test]
19075    fn segment_command_iter_keeps_shadows_as_explicit_boundaries() {
19076        let ordered_items = vec![
19077            (0, SegmentDrawItem::Shape(0)),
19078            (1, SegmentDrawItem::Shadow(0)),
19079            (2, SegmentDrawItem::Image(0)),
19080            (3, SegmentDrawItem::Text(0)),
19081        ];
19082        let shapes = vec![test_shape(0, BlendMode::SrcOver)];
19083        let images = vec![test_image(2, BlendMode::SrcOver)];
19084
19085        let commands: Vec<_> = SegmentCommandIter::new(
19086            &ordered_items,
19087            &shapes,
19088            &images,
19089            ShapeBatchLimits::desktop(),
19090        )
19091        .collect();
19092
19093        assert_eq!(
19094            commands,
19095            vec![
19096                SegmentRenderCommand::DrawChunk(chunk(&[SegmentBatchPlan::Shape {
19097                    start: 0,
19098                    end: 1,
19099                    blend_mode: BlendMode::SrcOver,
19100                }])),
19101                SegmentRenderCommand::Shadow(0),
19102                SegmentRenderCommand::DrawChunk(chunk(&[
19103                    SegmentBatchPlan::Image {
19104                        start: 2,
19105                        end: 3,
19106                        blend_mode: BlendMode::SrcOver,
19107                    },
19108                    SegmentBatchPlan::Text { start: 3, end: 4 },
19109                ])),
19110            ]
19111        );
19112    }
19113
19114    #[test]
19115    fn staged_buffer_uploads_align_new_copies_to_copy_buffer_alignment() {
19116        let mut uploads = StagedBufferUploads::default();
19117        uploads.bytes.extend_from_slice(&[1, 2]);
19118
19119        uploads.stage(UploadTarget::ImageIndex, &[3, 4, 5, 6]);
19120
19121        assert_eq!(uploads.bytes, vec![1, 2, 0, 0, 3, 4, 5, 6]);
19122        assert_eq!(
19123            uploads.copies,
19124            vec![PendingBufferCopy {
19125                source_offset: 4,
19126                target_offset: 0,
19127                size: 4,
19128                target: UploadTarget::ImageIndex,
19129            }]
19130        );
19131    }
19132
19133    #[test]
19134    fn staged_buffer_uploads_ignore_empty_payloads() {
19135        let mut uploads = StagedBufferUploads::default();
19136
19137        uploads.stage(UploadTarget::Uniform, &[]);
19138
19139        assert!(uploads.is_empty());
19140        assert!(uploads.bytes.is_empty());
19141    }
19142
19143    #[test]
19144    fn staged_buffer_uploads_return_exact_payload_slice_for_copy() {
19145        let mut uploads = StagedBufferUploads::default();
19146        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
19147        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
19148
19149        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
19150        assert_eq!(uploads.payload_for_copy(uploads.copies[1]), &[5, 6, 7, 8]);
19151    }
19152
19153    #[test]
19154    fn staged_buffer_uploads_record_destination_offsets() {
19155        let mut uploads = StagedBufferUploads::default();
19156
19157        uploads.stage_at(UploadTarget::ImageIndex, 256, &[1, 2, 3, 4]);
19158
19159        assert_eq!(uploads.copies[0].target_offset, 256);
19160        assert_eq!(uploads.payload_for_copy(uploads.copies[0]), &[1, 2, 3, 4]);
19161    }
19162
19163    #[test]
19164    fn staged_buffer_uploads_truncate_restores_previous_state() {
19165        let mut uploads = StagedBufferUploads::default();
19166        uploads.stage(UploadTarget::Uniform, &[1, 2, 3, 4]);
19167        let bytes_len = uploads.bytes.len();
19168        let copies_len = uploads.copies.len();
19169        uploads.stage(UploadTarget::ImageIndex, &[5, 6, 7, 8]);
19170
19171        uploads.truncate(bytes_len, copies_len);
19172
19173        assert_eq!(uploads.bytes, vec![1, 2, 3, 4]);
19174        assert_eq!(uploads.copies.len(), 1);
19175    }
19176
19177    #[test]
19178    fn inner_shadow_composite_mask_uses_fill_shape_and_scale() {
19179        let mut fill = test_shape(0, BlendMode::SrcOver);
19180        fill.local_rect = Rect {
19181            x: 10.0,
19182            y: 12.0,
19183            width: 40.0,
19184            height: 20.0,
19185        };
19186        fill.shape = Some(RoundedCornerShape::uniform(6.0));
19187
19188        let cutout = test_shape(1, BlendMode::DstOut);
19189        let shadow = test_shadow_draw(vec![
19190            (fill, BlendMode::SrcOver),
19191            (cutout, BlendMode::DstOut),
19192        ]);
19193
19194        let mask = inner_shadow_composite_mask(&shadow, 1.5).expect("inner mask expected");
19195        assert_eq!(mask.rect, [15.0, 18.0, 60.0, 30.0]);
19196        assert_eq!(mask.radii, [9.0, 9.0, 9.0, 9.0]);
19197    }
19198
19199    #[test]
19200    fn inner_shadow_composite_mask_is_none_without_dst_out() {
19201        let fill = test_shape(0, BlendMode::SrcOver);
19202        let shadow = test_shadow_draw(vec![(fill, BlendMode::SrcOver)]);
19203        assert!(inner_shadow_composite_mask(&shadow, 1.0).is_none());
19204    }
19205
19206    #[test]
19207    fn render_effect_support_matrix_covers_all_variants() {
19208        let blur = RenderEffect::blur(4.0);
19209        let offset = RenderEffect::offset(2.0, 3.0);
19210        let shader = RenderEffect::runtime_shader(cranpose_ui_graphics::RuntimeShader::new(
19211            r#"
19212            @group(0) @binding(0) var input_texture: texture_2d<f32>;
19213            @group(0) @binding(1) var input_sampler: sampler;
19214            @group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
19215            struct VertexOutput {
19216                @builtin(position) position: vec4<f32>,
19217                @location(0) uv: vec2<f32>,
19218            }
19219            @vertex
19220            fn fullscreen_vs(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
19221                var output: VertexOutput;
19222                let x = f32(i32(vertex_index & 1u) * 2 - 1);
19223                let y = f32(i32(vertex_index >> 1u) * 2 - 1);
19224                output.uv = vec2<f32>(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
19225                output.position = vec4<f32>(x, y, 0.0, 1.0);
19226                return output;
19227            }
19228            @fragment
19229            fn effect_fs(input: VertexOutput) -> @location(0) vec4<f32> {
19230                return textureSample(input_texture, input_sampler, input.uv);
19231            }
19232            "#,
19233        ));
19234        let chain = blur.clone().then(offset.clone());
19235
19236        assert!(is_render_effect_supported(&blur));
19237        assert!(is_render_effect_supported(&offset));
19238        assert!(is_render_effect_supported(&shader));
19239        assert!(is_render_effect_supported(&chain));
19240    }
19241
19242    #[test]
19243    fn clip_to_bounds_propagates_visual_clip_to_all_descendant_shapes() {
19244        // Simulates: root → clip_to_bounds container → child with shapes above/below clip
19245        // All shapes inside the clip_to_bounds container must have a clip set.
19246        let container_local_bounds = Rect {
19247            x: 0.0,
19248            y: 0.0,
19249            width: 800.0,
19250            height: 500.0,
19251        };
19252        // Container is placed at y=50 in parent space via transform_to_parent
19253        let container_clip_in_parent = Rect {
19254            x: 0.0,
19255            y: 50.0,
19256            width: 800.0,
19257            height: 500.0,
19258        };
19259
19260        // Shape that extends above the clip boundary (scroll content scrolled up)
19261        let shape_above = RenderNode::Primitive(PrimitiveEntry {
19262            phase: PrimitivePhase::BeforeChildren,
19263            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19264                primitive: DrawPrimitive::Rect {
19265                    rect: Rect {
19266                        x: 10.0,
19267                        y: -30.0,
19268                        width: 100.0,
19269                        height: 40.0,
19270                    },
19271                    brush: Brush::solid(Color::WHITE),
19272                    stroke: None,
19273                },
19274                clip: None,
19275            }),
19276        });
19277
19278        // Shape within the clip boundary
19279        let shape_inside = RenderNode::Primitive(PrimitiveEntry {
19280            phase: PrimitivePhase::BeforeChildren,
19281            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19282                primitive: DrawPrimitive::Rect {
19283                    rect: Rect {
19284                        x: 10.0,
19285                        y: 100.0,
19286                        width: 100.0,
19287                        height: 40.0,
19288                    },
19289                    brush: Brush::solid(Color::WHITE),
19290                    stroke: None,
19291                },
19292                clip: None,
19293            }),
19294        });
19295
19296        // Shape below the clip boundary (scroll content below viewport)
19297        let shape_below = RenderNode::Primitive(PrimitiveEntry {
19298            phase: PrimitivePhase::BeforeChildren,
19299            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19300                primitive: DrawPrimitive::Rect {
19301                    rect: Rect {
19302                        x: 10.0,
19303                        y: 600.0,
19304                        width: 100.0,
19305                        height: 40.0,
19306                    },
19307                    brush: Brush::solid(Color::WHITE),
19308                    stroke: None,
19309                },
19310                clip: None,
19311            }),
19312        });
19313
19314        // Content child layer (represents scroll content, translated up by scroll offset)
19315        let mut content_layer = test_layer(
19316            Rect {
19317                x: 0.0,
19318                y: 0.0,
19319                width: 800.0,
19320                height: 1000.0,
19321            },
19322            vec![shape_above, shape_inside, shape_below],
19323        );
19324        content_layer.transform_to_parent = ProjectiveTransform::translation(0.0, -30.0);
19325        content_layer.translated_content_context = true;
19326
19327        // Clip container (e.g. TabContent with clip_to_bounds)
19328        let mut clip_container = test_layer(
19329            container_local_bounds,
19330            vec![RenderNode::Layer(Box::new(content_layer))],
19331        );
19332        clip_container.clip_to_bounds = true;
19333        clip_container.transform_to_parent = ProjectiveTransform::translation(0.0, 50.0);
19334
19335        // Root
19336        let root = test_layer(
19337            Rect {
19338                x: 0.0,
19339                y: 0.0,
19340                width: 800.0,
19341                height: 600.0,
19342            },
19343            vec![RenderNode::Layer(Box::new(clip_container))],
19344        );
19345
19346        let mut rect_cache = HashMap::new();
19347        let mut requirements_cache = HashMap::new();
19348        let collected =
19349            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19350
19351        assert_eq!(
19352            collected.scene.shapes.len(),
19353            3,
19354            "all three shapes should be flattened into the scene"
19355        );
19356
19357        for (i, shape) in collected.scene.shapes.iter().enumerate() {
19358            assert!(
19359                shape.clip.is_some(),
19360                "shape {} at rect {:?} must have a clip from clip_to_bounds container, but clip is None",
19361                i,
19362                shape.rect
19363            );
19364            let clip = shape.clip.unwrap();
19365            assert_eq!(
19366                clip, container_clip_in_parent,
19367                "shape {} clip should match the clip_to_bounds container bounds in parent space",
19368                i
19369            );
19370        }
19371    }
19372
19373    #[test]
19374    fn clip_to_bounds_culls_child_layers_outside_boundary() {
19375        // Reproduces the out-of-clip rendering bug: a child layer with
19376        // graphics_layer.clip=true (e.g. from rounded_surface()) positioned
19377        // entirely below the parent's clip_to_bounds boundary must be culled.
19378        // Before the fix, resolve_clip returned None for non-overlapping rects,
19379        // which downstream code interpreted as "no clipping" instead of "fully clipped",
19380        // causing invisible content to render everywhere.
19381
19382        let clip_container_bounds = Rect {
19383            x: 0.0,
19384            y: 0.0,
19385            width: 800.0,
19386            height: 500.0,
19387        };
19388
19389        let shape_in_card = RenderNode::Primitive(PrimitiveEntry {
19390            phase: PrimitivePhase::BeforeChildren,
19391            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19392                primitive: DrawPrimitive::Rect {
19393                    rect: Rect {
19394                        x: 0.0,
19395                        y: 0.0,
19396                        width: 300.0,
19397                        height: 80.0,
19398                    },
19399                    brush: Brush::solid(Color::WHITE),
19400                    stroke: None,
19401                },
19402                clip: None,
19403            }),
19404        });
19405
19406        // Card layer with graphics_layer.clip=true, positioned BELOW the clip boundary
19407        let mut card_outside = crate::test_support::layer_node(
19408            Rect {
19409                x: 0.0,
19410                y: 0.0,
19411                width: 300.0,
19412                height: 80.0,
19413            },
19414            ProjectiveTransform::identity(),
19415            GraphicsLayer {
19416                clip: true,
19417                ..GraphicsLayer::default()
19418            },
19419            vec![shape_in_card.clone()],
19420        );
19421        card_outside.transform_to_parent = ProjectiveTransform::translation(10.0, 600.0);
19422
19423        // Card layer with graphics_layer.clip=true, positioned INSIDE the clip boundary
19424        let mut card_inside = crate::test_support::layer_node(
19425            Rect {
19426                x: 0.0,
19427                y: 0.0,
19428                width: 300.0,
19429                height: 80.0,
19430            },
19431            ProjectiveTransform::identity(),
19432            GraphicsLayer {
19433                clip: true,
19434                ..GraphicsLayer::default()
19435            },
19436            vec![shape_in_card],
19437        );
19438        card_inside.transform_to_parent = ProjectiveTransform::translation(10.0, 100.0);
19439
19440        // Content layer holding both cards
19441        let content = test_layer(
19442            Rect {
19443                x: 0.0,
19444                y: 0.0,
19445                width: 800.0,
19446                height: 1000.0,
19447            },
19448            vec![
19449                RenderNode::Layer(Box::new(card_inside)),
19450                RenderNode::Layer(Box::new(card_outside)),
19451            ],
19452        );
19453
19454        // Clip container
19455        let mut clip_container = test_layer(
19456            clip_container_bounds,
19457            vec![RenderNode::Layer(Box::new(content))],
19458        );
19459        clip_container.clip_to_bounds = true;
19460
19461        // Root
19462        let root = test_layer(
19463            Rect {
19464                x: 0.0,
19465                y: 0.0,
19466                width: 800.0,
19467                height: 600.0,
19468            },
19469            vec![RenderNode::Layer(Box::new(clip_container))],
19470        );
19471
19472        let mut rect_cache = HashMap::new();
19473        let mut requirements_cache = HashMap::new();
19474        let collected =
19475            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19476
19477        assert_eq!(
19478            collected.scene.shapes.len(),
19479            1,
19480            "only the card inside the clip boundary should produce shapes; \
19481             the card outside must be culled entirely"
19482        );
19483
19484        let shape = &collected.scene.shapes[0];
19485        assert!(
19486            shape.clip.is_some(),
19487            "the visible card's shape must have a clip from clip_to_bounds"
19488        );
19489    }
19490
19491    #[test]
19492    fn flattened_layer_shadow_z_index_is_below_content() {
19493        // Shadow must render behind content. When a child layer with shadow_elevation
19494        // is flattened (no isolation), its shadow z-index must be lower than any
19495        // content z-index so shadow draws render first.
19496        let shape = RenderNode::Primitive(PrimitiveEntry {
19497            phase: PrimitivePhase::BeforeChildren,
19498            node: PrimitiveNode::Draw(DrawPrimitiveNode {
19499                primitive: DrawPrimitive::Rect {
19500                    rect: Rect {
19501                        x: 0.0,
19502                        y: 0.0,
19503                        width: 100.0,
19504                        height: 100.0,
19505                    },
19506                    brush: Brush::solid(Color::WHITE),
19507                    stroke: None,
19508                },
19509                clip: None,
19510            }),
19511        });
19512
19513        let child_bounds = Rect {
19514            x: 0.0,
19515            y: 0.0,
19516            width: 100.0,
19517            height: 100.0,
19518        };
19519
19520        let child = crate::test_support::layer_node(
19521            child_bounds,
19522            ProjectiveTransform::translation(50.0, 50.0),
19523            GraphicsLayer {
19524                shadow_elevation: 20.0,
19525                ..GraphicsLayer::default()
19526            },
19527            vec![shape],
19528        );
19529
19530        let root = test_layer(
19531            Rect {
19532                x: 0.0,
19533                y: 0.0,
19534                width: 800.0,
19535                height: 600.0,
19536            },
19537            vec![RenderNode::Layer(Box::new(child))],
19538        );
19539
19540        let mut rect_cache = HashMap::new();
19541        let mut requirements_cache = HashMap::new();
19542        let collected =
19543            collect_layer_contents(&root, None, None, &mut rect_cache, &mut requirements_cache);
19544
19545        assert!(
19546            !collected.scene.shadow_draws.is_empty(),
19547            "shadow_elevation > 0 must produce shadow draws"
19548        );
19549        let max_shadow_z = collected
19550            .scene
19551            .shadow_draws
19552            .iter()
19553            .map(|s| s.z_index)
19554            .max()
19555            .unwrap();
19556        let min_content_z = collected
19557            .scene
19558            .shapes
19559            .iter()
19560            .map(|s| s.z_index)
19561            .min()
19562            .unwrap();
19563        assert!(
19564            max_shadow_z < min_content_z,
19565            "shadow z-index ({}) must be less than content z-index ({}); \
19566             shadows must render behind their content",
19567            max_shadow_z,
19568            min_content_z
19569        );
19570    }
19571
19572    /// One retained bundle op key with the fields the invalidation tests
19573    /// vary; the rest stay representative constants.
19574    #[cfg(not(target_arch = "wasm32"))]
19575    fn bundle_op(slot: u32, epoch: Option<u64>, first: u32, last: u32) -> RetainedBundleOpKey {
19576        RetainedBundleOpKey {
19577            slot,
19578            capture_epoch: epoch,
19579            first,
19580            last,
19581            retained_index: slot,
19582            has_mesh: false,
19583        }
19584    }
19585
19586    #[cfg(not(target_arch = "wasm32"))]
19587    fn bundle_key(ops: &[RetainedBundleOpKey]) -> RetainedBundleKey {
19588        RetainedBundleKey { ops: ops.to_vec() }
19589    }
19590
19591    /// The same stretch on consecutive frames reuses its bundle: one
19592    /// rebuild, then cached executes.
19593    #[cfg(not(target_arch = "wasm32"))]
19594    #[test]
19595    fn retained_bundle_cache_reuses_stable_keys() {
19596        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
19597        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
19598        let key = bundle_key(&ops);
19599
19600        assert!(!cache.hit(&key), "empty cache must miss");
19601        cache.insert(key.clone(), 111);
19602        assert_eq!(cache.get(&key), Some(&111));
19603        cache.end_frame();
19604
19605        for _ in 0..3 {
19606            assert!(cache.hit(&bundle_key(&ops)), "stable key must stay cached");
19607            cache.end_frame();
19608        }
19609        assert_eq!(cache.stats(), (1, 3), "one rebuild, three cached executes");
19610    }
19611
19612    /// Recapture (epoch bump), span reorder, count change, range change and
19613    /// slot release each change the key, so a stale bundle can never satisfy
19614    /// the lookup.
19615    #[cfg(not(target_arch = "wasm32"))]
19616    #[test]
19617    fn retained_bundle_cache_invalidates_on_any_op_change() {
19618        let ops = [bundle_op(3, Some(7), 0, 40), bundle_op(5, Some(9), 4, 12)];
19619        let variants: [Vec<RetainedBundleOpKey>; 5] = [
19620            // Recaptured slot 3: same id, bumped epoch.
19621            vec![bundle_op(3, Some(8), 0, 40), bundle_op(5, Some(9), 4, 12)],
19622            // Reordered stretch.
19623            vec![bundle_op(5, Some(9), 4, 12), bundle_op(3, Some(7), 0, 40)],
19624            // Op count changed.
19625            vec![bundle_op(3, Some(7), 0, 40)],
19626            // Draw range changed.
19627            vec![bundle_op(3, Some(7), 0, 41), bundle_op(5, Some(9), 4, 12)],
19628            // Slot 5 released: epoch gone.
19629            vec![bundle_op(3, Some(7), 0, 40), bundle_op(5, None, 4, 12)],
19630        ];
19631        for changed in variants {
19632            let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
19633            cache.insert(bundle_key(&ops), 111);
19634            cache.end_frame();
19635            assert!(
19636                !cache.hit(&RetainedBundleKey {
19637                    ops: changed.clone()
19638                }),
19639                "changed key {changed:?} must not reuse the stale bundle"
19640            );
19641        }
19642    }
19643
19644    /// Entries a frame does not use are evicted at its end — bundles pin
19645    /// slot buffers, so unused ones must not accumulate — and `clear` (the
19646    /// slot-release path) empties the cache outright.
19647    #[cfg(not(target_arch = "wasm32"))]
19648    #[test]
19649    fn retained_bundle_cache_evicts_unused_entries() {
19650        let mut cache: RetainedBundleCacheImpl<u32> = RetainedBundleCacheImpl::new();
19651        let stale = bundle_key(&[bundle_op(1, Some(1), 0, 6)]);
19652        let live = bundle_key(&[bundle_op(2, Some(2), 0, 6)]);
19653        cache.insert(stale.clone(), 1);
19654        cache.insert(live.clone(), 2);
19655        cache.end_frame();
19656
19657        assert!(cache.hit(&live));
19658        cache.end_frame();
19659
19660        assert!(
19661            !cache.hit(&stale),
19662            "entry unused for a frame must have been evicted"
19663        );
19664        assert!(cache.hit(&live), "used entry must survive eviction");
19665
19666        cache.clear();
19667        assert!(!cache.hit(&live), "clear must drop every entry");
19668    }
19669}